diff --git a/modules/anilist.py b/modules/anilist.py index 6e82d86c..29e2806a 100644 --- a/modules/anilist.py +++ b/modules/anilist.py @@ -14,7 +14,7 @@ mod_searches = [ "episodes.gt", "episodes.gte", "episodes.lt", "episodes.lte", "duration.gt", "duration.gte", "duration.lt", "duration.lte", "score.gt", "score.gte", "score.lt", "score.lte", "popularity.gt", "popularity.gte", "popularity.lt", "popularity.lte" ] -no_mod_searches = ["search", "season", "year", "adult", "min_tag_percent"] +no_mod_searches = ["search", "season", "year", "adult", "min_tag_percent", "limit", "sort_by"] searches = mod_searches + no_mod_searches search_types = { "search": "String", "season": "MediaSeason", "seasonYear": "Int", "isAdult": "Boolean", "minimumTagRank": "Int", @@ -101,6 +101,8 @@ class AniList: final = ani_attr if attr in no_mod_searches else f"{ani_attr}_{mod_translation[mod]}" if attr in ["start", "end"]: value = int(util.validate_date(value, f"anilist_search {key}", return_as="%Y%m%d")) + elif attr in ["season", "format", "status", "genre", "tag", "tag_category"]: + value = self.options[attr.replace("_", " ").title()][value.lower().replace(" / ", "-").replace(" ", "-")] if mod == "gte": value -= 1 elif mod == "lte": @@ -178,7 +180,7 @@ class AniList: for d in util.get_list(data): data_check = d.lower().replace(" / ", "-").replace(" ", "-") if data_check in self.options[name]: - valid.append(self.options[name][data_check]) + valid.append(d) if len(valid) > 0: return valid raise Failed(f"AniList Error: {name}: {data} does not exist\nOptions: {', '.join([v for k, v in self.options[name].items()])}") diff --git a/modules/builder.py b/modules/builder.py index 235cce03..a3a992cd 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -4,7 +4,7 @@ from modules import anidb, anilist, icheckmovies, imdb, letterboxd, mal, plex, r from modules.util import Failed, ImageData from PIL import Image from plexapi.exceptions import BadRequest, NotFound -from plexapi.video import Movie, Show +from plexapi.video import Movie, Show, Season, Episode from urllib.parse import quote logger = logging.getLogger("Plex Meta Manager") @@ -40,7 +40,8 @@ method_alias = { "show_title": "title", "seasonyear": "year", "isadult": "adult", "startdate": "start", "enddate": "end", "averagescore": "score", "minimum_tag_percentage": "min_tag_percent", "minimumtagrank": "min_tag_percent", "minimum_tag_rank": "min_tag_percent", - "anilist_tag": "anilist_search", "anilist_genre": "anilist_search", "anilist_season": "anilist_search" + "anilist_tag": "anilist_search", "anilist_genre": "anilist_search", "anilist_season": "anilist_search", + "mal_producer": "mal_studio", "mal_licensor": "mal_studio" } filter_translation = { "actor": "actors", @@ -63,7 +64,7 @@ filter_translation = { modifier_alias = {".greater": ".gt", ".less": ".lt"} all_builders = anidb.builders + anilist.builders + icheckmovies.builders + imdb.builders + letterboxd.builders + \ mal.builders + plex.builders + stevenlu.builders + tautulli.builders + tmdb.builders + trakt.builders + tvdb.builders -show_only_builders = ["tmdb_network", "tmdb_show", "tmdb_show_details", "tvdb_show", "tvdb_show_details"] +show_only_builders = ["tmdb_network", "tmdb_show", "tmdb_show_details", "tvdb_show", "tvdb_show_details", "collection_level"] movie_only_builders = [ "letterboxd_list", "letterboxd_list_details", "icheckmovies_list", "icheckmovies_list_details", "stevenlu_popular", "tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing", @@ -75,15 +76,21 @@ summary_details = [ ] poster_details = ["url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "file_poster"] background_details = ["url_background", "tmdb_background", "tvdb_background", "file_background"] -boolean_details = ["visible_library", "visible_home", "visible_shared", "show_filtered", "show_missing", "save_missing", "item_assets", "missing_only_released"] +boolean_details = ["visible_library", "visible_home", "visible_shared", "show_filtered", "show_missing", "save_missing", "item_assets", "missing_only_released", "revert_overlay"] string_details = ["sort_title", "content_rating", "name_mapping"] -ignored_details = ["smart_filter", "smart_label", "smart_url", "run_again", "schedule", "sync_mode", "template", "test", "tmdb_person", "build_collection", "collection_order", "validate_builders"] -details = ["collection_mode", "collection_order", "label"] + boolean_details + string_details +ignored_details = [ + "smart_filter", "smart_label", "smart_url", "run_again", "schedule", "sync_mode", "template", "test", + "tmdb_person", "build_collection", "collection_order", "collection_level", "validate_builders" +] +details = ["collection_mode", "collection_order", "collection_level", "label"] + boolean_details + string_details collectionless_details = ["collection_order", "plex_collectionless", "label", "label_sync_mode", "test"] + \ poster_details + background_details + summary_details + string_details item_details = ["item_label", "item_radarr_tag", "item_sonarr_tag", "item_overlay"] + list(plex.item_advance_keys.keys()) radarr_details = ["radarr_add", "radarr_add_existing", "radarr_folder", "radarr_monitor", "radarr_search", "radarr_availability", "radarr_quality", "radarr_tag"] -sonarr_details = ["sonarr_add", "sonarr_add_existing", "sonarr_folder", "sonarr_monitor", "sonarr_language", "sonarr_series", "sonarr_quality", "sonarr_season", "sonarr_search", "sonarr_cutoff_search", "sonarr_tag"] +sonarr_details = [ + "sonarr_add", "sonarr_add_existing", "sonarr_folder", "sonarr_monitor", "sonarr_language", "sonarr_series", + "sonarr_quality", "sonarr_season", "sonarr_search", "sonarr_cutoff_search", "sonarr_tag" +] all_filters = [ "actor", "actor.not", "audio_language", "audio_language.not", @@ -129,7 +136,7 @@ movie_only_filters = [ "writer", "writer.not" ] show_only_filters = ["first_episode_aired", "last_episode_aired", "network"] -smart_invalid = ["collection_order"] +smart_invalid = ["collection_order", "collection_level"] smart_url_invalid = ["filters", "run_again", "sync_mode", "show_filtered", "show_missing", "save_missing", "smart_label"] + radarr_details + sonarr_details custom_sort_builders = [ "tmdb_list", "tmdb_popular", "tmdb_now_playing", "tmdb_top_rated", @@ -139,8 +146,12 @@ custom_sort_builders = [ "tautulli_popular", "tautulli_watched", "letterboxd_list", "icheckmovies_list", "anilist_top_rated", "anilist_popular", "anilist_season", "anilist_studio", "anilist_genre", "anilist_tag", "anilist_search", "mal_all", "mal_airing", "mal_upcoming", "mal_tv", "mal_movie", "mal_ova", "mal_special", - "mal_popular", "mal_favorite", "mal_suggested", "mal_userlist", "mal_season", "mal_genre", "mal_producer" + "mal_popular", "mal_favorite", "mal_suggested", "mal_userlist", "mal_season", "mal_genre", "mal_studio" ] +parts_collection_valid = [ + "plex_search", "trakt_list", "trakt_list_details", "collection_mode", "label", "visible_library", + "visible_home", "visible_shared", "show_missing", "save_missing", "missing_only_released" + ] + summary_details + poster_details + background_details + string_details class CollectionBuilder: def __init__(self, config, library, metadata, name, no_missing, data): @@ -164,6 +175,7 @@ class CollectionBuilder: self.sonarr_details = {} self.missing_movies = [] self.missing_shows = [] + self.missing_parts = [] self.builders = [] self.filters = [] self.tmdb_filters = [] @@ -410,6 +422,21 @@ class CollectionBuilder: else: raise Failed(f"Collection Error: {self.data[methods['collection_order']]} collection_order invalid\n\trelease (Order Collection by release dates)\n\talpha (Order Collection Alphabetically)\n\tcustom (Custom Order Collection)") + self.collection_level = "movie" if self.library.is_movie else "show" + if "collection_level" in methods: + logger.debug("") + logger.debug("Validating Method: collection_level") + if self.data[methods["collection_level"]] is None: + raise Failed(f"Collection Warning: collection_level attribute is blank") + else: + logger.debug(f"Value: {self.data[methods['collection_level']]}") + if self.data[methods["collection_level"]].lower() in plex.collection_level_options: + self.collection_level = self.data[methods["collection_level"]].lower() + else: + raise Failed(f"Collection Error: {self.data[methods['collection_level']]} collection_level invalid\n\tseason (Collection at the Season Level)\n\tepisode (Collection at the Episode Level)") + self.parts_collection = self.collection_level in ["season", "episode"] + self.media_type = self.collection_level.capitalize() + if "tmdb_person" in methods: logger.debug("") logger.debug("Validating Method: tmdb_person") @@ -478,6 +505,8 @@ class CollectionBuilder: cant_interact("smart_url", "collectionless") cant_interact("smart_url", "run_again") cant_interact("smart_label_collection", "smart_url", fail=True) + cant_interact("smart_label_collection", "parts_collection", fail=True) + cant_interact("smart_url", "parts_collection", fail=True) self.smart = self.smart_url or self.smart_label_collection @@ -500,6 +529,7 @@ class CollectionBuilder: elif self.library.is_show and method_name in movie_only_builders: raise Failed(f"Collection Error: {method_final} attribute only works for movie libraries") elif self.library.is_show and method_name in plex.movie_only_searches: raise Failed(f"Collection Error: {method_final} plex search only works for movie libraries") elif self.library.is_movie and method_name in plex.show_only_searches: raise Failed(f"Collection Error: {method_final} plex search only works for show libraries") + elif self.parts_collection and method_name not in parts_collection_valid: raise Failed(f"Collection Error: {method_final} attribute does not work with Collection Level: {self.details['collection_level'].capitalize()}") elif self.smart and method_name in smart_invalid: raise Failed(f"Collection Error: {method_final} attribute only works with normal collections") elif self.collectionless and method_name not in collectionless_details: raise Failed(f"Collection Error: {method_final} attribute does not work for Collectionless collection") elif self.smart_url and method_name in all_builders + smart_url_invalid: raise Failed(f"Collection Error: {method_final} builder not allowed when using smart_filter") @@ -763,9 +793,7 @@ class CollectionBuilder: new_dictionary = {} for search_method, search_data in dict_data.items(): search_attr, modifier, search_final = self._split(search_method) - if search_data is None: - raise Failed(f"Collection Error: {method_name} {search_final} attribute is blank") - elif search_final not in anilist.searches: + if search_final not in anilist.searches: raise Failed(f"Collection Error: {method_name} {search_final} attribute not supported") elif search_attr == "season": new_dictionary[search_attr] = util.parse(search_attr, search_data, parent=method_name, default=current_season, options=util.seasons) @@ -777,6 +805,8 @@ class CollectionBuilder: if "season" not in dict_methods: logger.warning(f"Collection Warning: {method_name} season attribute not found using this season: {current_season} by default") new_dictionary["season"] = current_season + elif search_data is None: + raise Failed(f"Collection Error: {method_name} {search_final} attribute is blank") elif search_attr == "adult": new_dictionary[search_attr] = util.parse(search_attr, search_data, datatype="bool", parent=method_name) elif search_attr in ["episodes", "duration", "score", "popularity"]: @@ -791,7 +821,7 @@ class CollectionBuilder: new_dictionary[search_attr] = str(search_data) elif search_final not in ["sort_by", "limit"]: raise Failed(f"Collection Error: {method_name} {search_final} attribute not supported") - if len(new_dictionary) > 0: + if len(new_dictionary) == 0: raise Failed(f"Collection Error: {method_name} must have at least one valid search option") new_dictionary["sort_by"] = util.parse("sort_by", dict_data, methods=dict_methods, parent=method_name, default="score", options=["score", "popular"]) new_dictionary["limit"] = util.parse("limit", dict_data, datatype="int", methods=dict_methods, default=0, parent=method_name) @@ -829,7 +859,7 @@ class CollectionBuilder: for mal_id in util.get_int_list(method_data, "MyAnimeList ID"): self.builders.append((method_name, mal_id)) elif method_name in ["mal_all", "mal_airing", "mal_upcoming", "mal_tv", "mal_ova", "mal_movie", "mal_special", "mal_popular", "mal_favorite", "mal_suggested"]: - self.builders.append((method_name, util.parse(method_name, method_data, datatype="int", default=10))) + self.builders.append((method_name, util.parse(method_name, method_data, datatype="int", default=10, maximum=100 if method_name == "mal_suggested" else 500))) elif method_name in ["mal_season", "mal_userlist"]: for dict_data, dict_methods in util.parse(method_name, method_data, datatype="dictlist"): if method_name == "mal_season": @@ -850,7 +880,7 @@ class CollectionBuilder: "sort_by": util.parse("sort_by", dict_data, methods=dict_methods, parent=method_name, default="score", options=mal.userlist_sort_options, translation=mal.userlist_sort_translation), "limit": util.parse("limit", dict_data, datatype="int", methods=dict_methods, default=100, parent=method_name, maximum=1000) })) - elif method_name in ["mal_genre", "mal_producer"]: + elif method_name in ["mal_genre", "mal_studio"]: id_name = f"{method_name[4:]}_id" final_data = [] for data in util.get_list(method_data): @@ -868,7 +898,8 @@ class CollectionBuilder: for dict_data, dict_methods in util.parse(method_name, method_data, datatype="dictlist"): new_dictionary = {} if method_name == "plex_search": - new_dictionary = self.build_filter("plex_search", dict_data) + type_override = f"{self.collection_level}s" if self.collection_level in plex.collection_level_options else None + new_dictionary = self.build_filter("plex_search", dict_data, type_override=type_override) elif method_name == "plex_collectionless": prefix_list = util.parse("exclude_prefix", dict_data, datatype="list", methods=dict_methods) exact_list = util.parse("exclude", dict_data, datatype="list", methods=dict_methods) @@ -1069,12 +1100,12 @@ class CollectionBuilder: for i, input_data in enumerate(ids, 1): input_id, id_type = input_data util.print_return(f"Parsing ID {i}/{total_ids}") - if id_type == "tmdb": + if id_type == "tmdb" and not self.parts_collection: if input_id in self.library.movie_map: rating_keys.append(self.library.movie_map[input_id][0]) elif input_id not in self.missing_movies: self.missing_movies.append(input_id) - elif id_type in ["tvdb", "tmdb_show"]: + elif id_type in ["tvdb", "tmdb_show"] and not self.parts_collection: if id_type == "tmdb_show": try: input_id = self.config.Convert.tmdb_to_tvdb(input_id, fail=True) @@ -1085,7 +1116,7 @@ class CollectionBuilder: rating_keys.append(self.library.show_map[input_id][0]) elif input_id not in self.missing_shows: self.missing_shows.append(input_id) - elif id_type == "imdb": + elif id_type == "imdb" and not self.parts_collection: if input_id in self.library.imdb_map: rating_keys.append(self.library.imdb_map[input_id][0]) else: @@ -1102,6 +1133,28 @@ class CollectionBuilder: except Failed as e: logger.error(e) continue + elif id_type == "tvdb_season" and self.collection_level == "season": + show_id, season_num = input_id.split("_") + if int(show_id) in self.library.show_map: + show_item = self.library.fetchItem(self.library.show_map[int(show_id)][0]) + try: + episode_item = show_item.season(season=int(season_num)) + rating_keys.append(episode_item.ratingKey) + except NotFound: + self.missing_parts.append(f"{show_item.title} Season: {season_num} Missing") + elif int(show_id) not in self.missing_shows: + self.missing_shows.append(int(show_id)) + elif id_type == "tvdb_episode" and self.collection_level == "episode": + show_id, season_num, episode_num = input_id.split("_") + if int(show_id) in self.library.show_map: + show_item = self.library.fetchItem(self.library.show_map[int(show_id)][0]) + try: + episode_item = show_item.episode(season=int(season_num), episode=int(episode_num)) + rating_keys.append(episode_item.ratingKey) + except NotFound: + self.missing_parts.append(f"{show_item.title} Season: {season_num} Episode: {episode_num} Missing") + elif int(show_id) not in self.missing_shows: + self.missing_shows.append(int(show_id)) util.print_end() if len(rating_keys) > 0: @@ -1124,7 +1177,7 @@ class CollectionBuilder: except Failed as e: logger.error(e) continue - current_title = f"{current.title} ({current.year})" if current.year else current.title + current_title = self.item_title(current) if self.check_filters(current, f"{(' ' * (max_length - len(str(i))))}{i}/{total}"): self.rating_keys.append(key) else: @@ -1132,7 +1185,7 @@ class CollectionBuilder: if self.details["show_filtered"] is True: logger.info(f"{name} Collection | X | {current_title}") - def build_filter(self, method, plex_filter, smart=False): + def build_filter(self, method, plex_filter, smart=False, type_override=None): if smart: logger.info("") logger.info(f"Validating Method: {method}") @@ -1148,7 +1201,9 @@ class CollectionBuilder: if "any" in filter_alias and "all" in filter_alias: raise Failed(f"Collection Error: Cannot have more then one base") - if smart and "type" in filter_alias and self.library.is_show: + if type_override: + sort_type = type_override + elif smart and "type" in filter_alias and self.library.is_show: if plex_filter[filter_alias["type"]] not in ["shows", "seasons", "episodes"]: raise Failed(f"Collection Error: type: {plex_filter[filter_alias['type']]} is invalid, must be either shows, season, or episodes") sort_type = plex_filter[filter_alias["type"]] @@ -1387,8 +1442,8 @@ class CollectionBuilder: def fetch_item(self, item): try: - current = self.library.fetchItem(item.ratingKey if isinstance(item, (Movie, Show)) else int(item)) - if not isinstance(current, (Movie, Show)): + current = self.library.fetchItem(item.ratingKey if isinstance(item, (Movie, Show, Season, Episode)) else int(item)) + if not isinstance(current, (Movie, Show, Season, Episode)): raise NotFound return current except (BadRequest, NotFound): @@ -1403,19 +1458,17 @@ class CollectionBuilder: except Failed as e: logger.error(e) continue - current_title = f"{current.title} ({current.year})" if current.year else current.title current_operation = "=" if current in collection_items else "+" - logger.info(util.adjust_space(f"{name} Collection | {current_operation} | {current_title}")) + logger.info(util.adjust_space(f"{name} Collection | {current_operation} | {self.item_title(current)}")) if current in collection_items: self.plex_map[current.ratingKey] = None elif self.smart_label_collection: self.library.query_data(current.addLabel, name) else: self.library.query_data(current.addCollection, name) - media_type = f"{'Movie' if self.library.is_movie else 'Show'}{'s' if total > 1 else ''}" util.print_end() logger.info("") - logger.info(f"{total} {media_type} Processed") + logger.info(f"{total} {self.collection_level.capitalize()}{'s' if total > 1 else ''} Processed") def check_tmdb_filter(self, item_id, is_movie, item=None, check_released=False): if self.tmdb_filters or check_released: @@ -1609,6 +1662,26 @@ class CollectionBuilder: logger.error(e) if self.run_again: self.run_again_shows.extend(missing_tvdb_ids) + if len(self.missing_parts) > 0 and self.library.is_show and self.details["save_missing"] is True: + for missing in self.missing_parts: + logger.info(f"{self.name} Collection | X | {missing}") + + def item_title(self, item): + if self.collection_level == "season": + if f"Season {item.index}" == item.title: + return f"{item.parentTitle} {item.title}" + else: + return f"{item.parentTitle} Season {item.index}: {item.title}" + elif self.collection_level == "episode": + text = f"{item.grandparentTitle} S{util.add_zero(item.parentIndex)}E{util.add_zero(item.index)}" + if f"Season {item.parentIndex}" == item.parentTitle: + return f"{text}: {item.title}" + else: + return f"{text}: {item.parentTitle}: {item.title}" + elif self.collection_level == "movie" and item.year: + return f"{item.title} ({item.year})" + else: + return item.title def sync_collection(self): count_removed = 0 @@ -1619,7 +1692,7 @@ class CollectionBuilder: util.separator(f"Removed from {self.name} Collection", space=False, border=False) logger.info("") self.library.reload(item) - logger.info(f"{self.name} Collection | - | {item.title}") + logger.info(f"{self.name} Collection | - | {self.item_title(item)}") if self.smart_label_collection: self.library.query_data(item.removeLabel, self.name) else: @@ -1627,7 +1700,7 @@ class CollectionBuilder: count_removed += 1 if count_removed > 0: logger.info("") - logger.info(f"{count_removed} {'Movie' if self.library.is_movie else 'Show'}{'s' if count_removed == 1 else ''} Removed") + logger.info(f"{count_removed} {self.collection_level.capitalize()}{'s' if count_removed == 1 else ''} Removed") def update_item_details(self): add_tags = self.item_details["item_label"] if "item_label" in self.item_details else None @@ -1675,10 +1748,14 @@ class CollectionBuilder: temp_image = os.path.join(overlay_folder, f"temp.png") overlay = (overlay_name, overlay_folder, overlay_image, temp_image) + revert = "revert_overlay" in self.details and self.details["revert_overlay"] + if revert: + overlay = None + tmdb_ids = [] tvdb_ids = [] for item in items: - if int(item.ratingKey) in rating_keys: + if int(item.ratingKey) in rating_keys and not revert: rating_keys.remove(int(item.ratingKey)) if self.details["item_assets"] or overlay is not None: try: @@ -1696,7 +1773,7 @@ class CollectionBuilder: key, options = plex.item_advance_keys[method_name] if getattr(item, key) != options[method_data]: advance_edits[key] = options[method_data] - self.library.edit_item(item, item.title, "Movie" if self.library.is_movie else "Show", advance_edits, advanced=True) + self.library.edit_item(item, item.title, self.collection_level.capitalize(), advance_edits, advanced=True) if len(tmdb_ids) > 0: if "item_radarr_tag" in self.item_details: @@ -1889,7 +1966,8 @@ class CollectionBuilder: logger.debug(keys) logger.debug(self.rating_keys) for key in self.rating_keys: - logger.info(f"Moving {keys[key].title} {'after {}'.format(keys[previous].title) if previous else 'to the beginning'}") + text = f"after {self.item_title(keys[previous])}" if previous else "to the beginning" + logger.info(f"Moving {self.item_title(keys[key])} {text}") self.library.move_item(self.obj, key, after=previous) previous = key @@ -1911,13 +1989,12 @@ class CollectionBuilder: except (BadRequest, NotFound): logger.error(f"Plex Error: Item {rating_key} not found") continue - current_title = f"{current.title} ({current.year})" if current.year else current.title if current in collection_items: - logger.info(f"{name} Collection | = | {current_title}") + logger.info(f"{name} Collection | = | {self.item_title(current)}") else: self.library.query_data(current.addLabel if self.smart_label_collection else current.addCollection, name) - logger.info(f"{name} Collection | + | {current_title}") - logger.info(f"{len(rating_keys)} {'Movie' if self.library.is_movie else 'Show'}{'s' if len(rating_keys) > 1 else ''} Processed") + logger.info(f"{name} Collection | + | {self.item_title(current)}") + logger.info(f"{len(rating_keys)} {self.collection_level.capitalize()}{'s' if len(rating_keys) > 1 else ''} Processed") if len(self.run_again_movies) > 0: logger.info("") diff --git a/modules/config.py b/modules/config.py index efad2b29..bbb276fa 100644 --- a/modules/config.py +++ b/modules/config.py @@ -47,7 +47,7 @@ class Config: yaml.YAML().allow_duplicate_keys = True try: - new_config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path, encoding="utf-8")) + new_config, _, _ = yaml.util.load_yaml_guess_indent(open(self.config_path, encoding="utf-8")) def replace_attr(all_data, attr, par): if "settings" not in all_data: all_data["settings"] = {} @@ -90,7 +90,7 @@ class Config: if "trakt" in new_config: new_config["trakt"] = new_config.pop("trakt") if "mal" in new_config: new_config["mal"] = new_config.pop("mal") if "anidb" in new_config: new_config["anidb"] = new_config.pop("anidb") - yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=ind, block_seq_indent=bsi) + yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2) self.data = new_config except yaml.scanner.ScannerError as e: raise Failed(f"YAML Error: {util.tab_new_lines(e)}") @@ -111,12 +111,12 @@ class Config: if data is None or attribute not in data: message = f"{text} not found" if parent and save is True: - loaded_config, ind_in, bsi_in = yaml.util.load_yaml_guess_indent(open(self.config_path)) + loaded_config, _, _ = yaml.util.load_yaml_guess_indent(open(self.config_path)) endline = f"\n{parent} sub-attribute {attribute} added to config" if parent not in loaded_config or not loaded_config[parent]: loaded_config[parent] = {attribute: default} elif attribute not in loaded_config[parent]: loaded_config[parent][attribute] = default else: endline = "" - yaml.round_trip_dump(loaded_config, open(self.config_path, "w"), indent=ind_in, block_seq_indent=bsi_in) + yaml.round_trip_dump(loaded_config, open(self.config_path, "w"), indent=None, block_seq_indent=2) elif data[attribute] is None: if default_is_none is True: return None else: message = f"{text} is blank" diff --git a/modules/mal.py b/modules/mal.py index 02416400..196f2135 100644 --- a/modules/mal.py +++ b/modules/mal.py @@ -7,7 +7,7 @@ logger = logging.getLogger("Plex Meta Manager") builders = [ "mal_id", "mal_all", "mal_airing", "mal_upcoming", "mal_tv", "mal_ova", "mal_movie", "mal_special", - "mal_popular", "mal_favorite", "mal_season", "mal_suggested", "mal_userlist", "mal_genre", "mal_producer" + "mal_popular", "mal_favorite", "mal_season", "mal_suggested", "mal_userlist", "mal_genre", "mal_studio" ] mal_ranked_name = { "mal_all": "all", "mal_airing": "airing", "mal_upcoming": "upcoming", "mal_tv": "tv", "mal_ova": "ova", @@ -17,7 +17,7 @@ mal_ranked_pretty = { "mal_all": "MyAnimeList All", "mal_airing": "MyAnimeList Airing", "mal_upcoming": "MyAnimeList Upcoming", "mal_tv": "MyAnimeList TV", "mal_ova": "MyAnimeList OVA", "mal_movie": "MyAnimeList Movie", "mal_special": "MyAnimeList Special", "mal_popular": "MyAnimeList Popular", - "mal_favorite": "MyAnimeList Favorite", "mal_genre": "MyAnimeList Genre", "mal_producer": "MyAnimeList Producer" + "mal_favorite": "MyAnimeList Favorite", "mal_genre": "MyAnimeList Genre", "mal_studio": "MyAnimeList Studio" } season_sort_translation = {"score": "anime_score", "anime_score": "anime_score", "members": "anime_num_list_users", "anime_num_list_users": "anime_num_list_users"} season_sort_options = ["score", "members"] @@ -191,15 +191,15 @@ class MyAnimeList: util.print_end() return mal_ids - def _producer(self, producer_id, limit): - data = self._jiken_request(f"/producer/{producer_id}") + def _studio(self, studio_id, limit): + data = self._jiken_request(f"/producer/{studio_id}") if "anime" not in data: - raise Failed(f"MyAnimeList Error: No MyAnimeList IDs for Producer ID: {producer_id}") + raise Failed(f"MyAnimeList Error: No MyAnimeList IDs for Studio ID: {studio_id}") mal_ids = [] count = 1 while True: if count > 1: - data = self._jiken_request(f"/producer/{producer_id}/{count}") + data = self._jiken_request(f"/producer/{studio_id}/{count}") if "anime" not in data: break mal_ids.extend([anime["mal_id"] for anime in data["anime"]]) @@ -218,9 +218,9 @@ class MyAnimeList: elif method == "mal_genre": logger.info(f"Processing {mal_ranked_pretty[method]} ID: {data['genre_id']}") mal_ids = self._genre(data["genre_id"], data["limit"]) - elif method == "mal_producer": - logger.info(f"Processing {mal_ranked_pretty[method]} ID: {data['producer_id']}") - mal_ids = self._producer(data["producer_id"], data["limit"]) + elif method == "mal_studio": + logger.info(f"Processing {mal_ranked_pretty[method]} ID: {data['studio_id']}") + mal_ids = self._studio(data["studio_id"], data["limit"]) elif method == "mal_season": logger.info(f"Processing MyAnimeList Season: {data['limit']} Anime from {data['season'].title()} {data['year']} sorted by {pretty_names[data['sort_by']]}") mal_ids = self._season(data["season"], data["year"], data["sort_by"], data["limit"]) diff --git a/modules/plex.py b/modules/plex.py index e7e9f113..de4bf480 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -16,28 +16,54 @@ logger = logging.getLogger("Plex Meta Manager") builders = ["plex_all", "plex_collectionless", "plex_search"] search_translation = { - "audio_language": "audioLanguage", - "content_rating": "contentRating", - "subtitle_language": "subtitleLanguage", - "added": "addedAt", - "release": "originallyAvailableAt", - "audience_rating": "audienceRating", + "episode_title": "episode.title", + "network": "show.network", "critic_rating": "rating", + "audience_rating": "audienceRating", "user_rating": "userRating", - "plays": "viewCount", - "unplayed": "unwatched", - "episode_title": "episode.title", + "episode_user_rating": "episode.userRating", + "content_rating": "contentRating", + "episode_year": "episode.year", + "release": "originallyAvailableAt", + "episode_unmatched": "episode.unmatched", + "episode_duplicate": "episode.duplicate", + "added": "addedAt", "episode_added": "episode.addedAt", "episode_air_date": "episode.originallyAvailableAt", - "episode_year": "episode.year", - "episode_user_rating": "episode.userRating", - "episode_plays": "episode.viewCount" + "plays": "viewCount", + "episode_plays": "episode.viewCount", + "last_played": "lastViewedAt", + "episode_last_played": "episode.lastViewedAt", + "unplayed": "unwatched", + "episode_unplayed": "episode.unwatched", + "subtitle_language": "subtitleLanguage", + "audio_language": "audioLanguage", + "progress": "inProgress", + "episode_progress": "episode.inProgress", + "unplayed_episodes": "show.unwatchedLeaves" } show_translation = { + "title": "show.title", + "studio": "show.studio", + "rating": "show.rating", + "audienceRating": "show.audienceRating", + "userRating": "show.userRating", + "contentRating": "show.contentRating", + "year": "show.year", + "originallyAvailableAt": "show.originallyAvailableAt", + "unmatched": "show.unmatched", + "genre": "show.genre", + "collection": "show.collection", + "actor": "show.actor", + "addedAt": "show.addedAt", + "viewCount": "show.viewCount", + "lastViewedAt": "show.lastViewedAt", + "resolution": "episode.resolution", "hdr": "episode.hdr", - "audioLanguage": "episode.audioLanguage", "subtitleLanguage": "episode.subtitleLanguage", - "resolution": "episode.resolution" + "audioLanguage": "episode.audioLanguage", + "trash": "episode.trash", + "label": "show.label", } modifier_translation = { "": "", ".not": "!", ".gt": "%3E%3E", ".gte": "%3E", ".lt": "%3C%3C", ".lte": "%3C", @@ -61,6 +87,7 @@ collection_mode_options = { "show_items": "showItems", "showitems": "showItems" } collection_order_options = ["release", "alpha", "custom"] +collection_level_options = ["episode", "season"] collection_mode_keys = {-1: "default", 0: "hide", 1: "hideItems", 2: "showItems"} collection_order_keys = {0: "release", 1: "alpha", 2: "custom"} item_advance_keys = { @@ -119,8 +146,8 @@ or_searches = [ ] movie_only_searches = [ "country", "country.not", "director", "director.not", "producer", "producer.not", "writer", "writer.not", - "decade", "duplicate", "unplayed", "progress", "trash", - "plays.gt", "plays.gte", "plays.lt", "plays.lte", "duration.gt", "duration.gte", "duration.lt", "duration.lte" + "decade", "duplicate", "unplayed", "progress", + "duration.gt", "duration.gte", "duration.lt", "duration.lte" ] show_only_searches = [ "network", "network.not", @@ -128,9 +155,11 @@ show_only_searches = [ "episode_added", "episode_added.not", "episode_added.before", "episode_added.after", "episode_air_date", "episode_air_date.not", "episode_air_date.before", "episode_air_date.after", + "episode_last_played", "episode_last_played.not", "episode_last_played.before", "episode_last_played.after", "episode_plays.gt", "episode_plays.gte", "episode_plays.lt", "episode_plays.lte", "episode_user_rating.gt", "episode_user_rating.gte", "episode_user_rating.lt", "episode_user_rating.lte", - "episode_year", "episode_year.not", "episode_year.gt", "episode_year.gte", "episode_year.lt", "episode_year.lte" + "episode_year", "episode_year.not", "episode_year.gt", "episode_year.gte", "episode_year.lt", "episode_year.lte", + "unplayed_episodes", "episode_unplayed", "episode_duplicate", "episode_progress", "episode_unmatched", ] float_attributes = ["user_rating", "episode_user_rating", "critic_rating", "audience_rating"] boolean_attributes = [ @@ -226,12 +255,17 @@ class Plex: self.Plex = next((s for s in self.PlexServer.library.sections() if s.title == params["name"]), None) if not self.Plex: raise Failed(f"Plex Error: Plex Library {params['name']} not found") - if self.Plex.type not in ["movie", "show"]: + if self.Plex.type in ["movie", "show"]: + self.type = self.Plex.type.capitalize() + else: raise Failed(f"Plex Error: Plex Library must be a Movies or TV Shows library") self.agent = self.Plex.agent - self.is_movie = self.Plex.type == "movie" - self.is_show = self.Plex.type == "show" + self.is_movie = self.type == "Movie" + self.is_show = self.type == "Show" + self.is_other = self.agent == "com.plexapp.agents.none" + if self.is_other: + self.type = "Video" self.collections = [] self.metadatas = [] @@ -258,7 +292,7 @@ class Plex: self.metadatas.extend([c for c in meta_obj.metadata]) self.metadata_files.append(meta_obj) except Failed as e: - logger.error(e) + util.print_multiline(e, error=True) if len(self.metadata_files) == 0: logger.info("") @@ -336,7 +370,7 @@ class Plex: return self.PlexServer.fetchItem(data) def get_all(self): - logger.info(f"Loading All {'Movie' if self.is_movie else 'Show'}s from Library: {self.name}") + logger.info(f"Loading All {self.type}s from Library: {self.name}") key = f"/library/sections/{self.Plex.key}/all?type={utils.searchType(self.Plex.TYPE)}" container_start = 0 container_size = plexapi.X_PLEX_CONTAINER_SIZE @@ -345,7 +379,7 @@ class Plex: results.extend(self.fetchItems(key, container_start, container_size)) util.print_return(f"Loaded: {container_start}/{self.Plex._totalViewSize}") container_start += container_size - logger.info(util.adjust_space(f"Loaded {self.Plex._totalViewSize} {'Movies' if self.is_movie else 'Shows'}")) + logger.info(util.adjust_space(f"Loaded {self.Plex._totalViewSize} {self.type}s")) return results @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @@ -395,10 +429,6 @@ class Plex: @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) def _upload_image(self, item, image): - logger.debug(item) - logger.debug(image.is_poster) - logger.debug(image.is_url) - logger.debug(image.location) if image.is_poster and image.is_url: item.uploadPoster(url=image.location) elif image.is_poster: @@ -411,8 +441,6 @@ class Plex: @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) def upload_file_poster(self, item, image): - logger.debug(item) - logger.debug(image) item.uploadPoster(filepath=image) self.reload(item) @@ -439,6 +467,7 @@ class Plex: if overlay is not None: overlay_name, overlay_folder, overlay_image, temp_image = overlay + self.reload(item) item_labels = {item_tag.tag.lower(): item_tag.tag for item_tag in item.labels} for item_label in item_labels: if item_label.endswith(" overlay") and item_label != f"{overlay_name.lower()} overlay": @@ -455,14 +484,18 @@ class Plex: shutil.copyfile(temp_image, os.path.join(overlay_folder, f"{item.ratingKey}.png")) while util.is_locked(temp_image): time.sleep(1) - new_poster = Image.open(temp_image).convert("RGBA") - new_poster = new_poster.resize(overlay_image.size, Image.ANTIALIAS) - new_poster.paste(overlay_image, (0, 0), overlay_image) - new_poster.save(temp_image) - self.upload_file_poster(item, temp_image) - self.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"]) - poster_uploaded = True - logger.info(f"Detail: Overlay: {overlay_name} applied to {item.title}") + try: + new_poster = Image.open(temp_image).convert("RGBA") + new_poster = new_poster.resize(overlay_image.size, Image.ANTIALIAS) + new_poster.paste(overlay_image, (0, 0), overlay_image) + new_poster.save(temp_image) + self.upload_file_poster(item, temp_image) + self.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"]) + poster_uploaded = True + logger.info(f"Detail: Overlay: {overlay_name} applied to {item.title}") + except OSError as e: + util.print_stacktrace() + logger.error(f"Overlay Error: {e}") background_uploaded = False if background is not None: @@ -601,10 +634,9 @@ class Plex: return valid_collections def get_rating_keys(self, method, data): - media_type = "Movie" if self.is_movie else "Show" items = [] if method == "plex_all": - logger.info(f"Processing Plex All {media_type}s") + logger.info(f"Processing Plex All {self.type}s") items = self.get_all() elif method == "plex_search": util.print_multiline(data[1], info=True) @@ -645,7 +677,7 @@ class Plex: break if add_item: items.append(item) - logger.info(util.adjust_space(f"Processed {len(all_items)} {'Movies' if self.is_movie else 'Shows'}")) + logger.info(util.adjust_space(f"Processed {len(all_items)} {self.type}s")) else: raise Failed(f"Plex Error: Method {method} not supported") if len(items) > 0: @@ -669,7 +701,7 @@ class Plex: try: yaml.round_trip_dump(self.missing, open(self.missing_path, "w")) except yaml.scanner.ScannerError as e: - logger.error(f"YAML Error: {util.tab_new_lines(e)}") + util.print_multiline(f"YAML Error: {util.tab_new_lines(e)}", error=True) def get_collection_items(self, collection, smart_label_collection): if smart_label_collection: @@ -692,7 +724,7 @@ class Plex: def map_guids(self): items = self.get_all() - logger.info(f"Mapping {'Movie' if self.is_movie else 'Show'} Library: {self.name}") + logger.info(f"Mapping {self.type} Library: {self.name}") logger.info("") for i, item in enumerate(items, 1): util.print_return(f"Processing: {i}/{len(items)} {item.title}") @@ -708,7 +740,7 @@ class Plex: if imdb_id: util.add_dict_list(imdb_id, item.ratingKey, self.imdb_map) logger.info("") - logger.info(util.adjust_space(f"Processed {len(items)} {'Movies' if self.is_movie else 'Shows'}")) + logger.info(util.adjust_space(f"Processed {len(items)} {self.type}s")) return items def get_tmdb_from_map(self, item): @@ -741,27 +773,28 @@ class Plex: return False def edit_tags(self, attr, obj, add_tags=None, remove_tags=None, sync_tags=None): - updated = False + display = "" key = builder.filter_translation[attr] if attr in builder.filter_translation else attr if add_tags or remove_tags or sync_tags: _add_tags = add_tags if add_tags else [] _remove_tags = [t.lower() for t in remove_tags] if remove_tags else [] _sync_tags = [t.lower() for t in sync_tags] if sync_tags else [] try: + self.reload(obj) _item_tags = [item_tag.tag.lower() for item_tag in getattr(obj, key)] except BadRequest: _item_tags = [] _add = [f"{t[:1].upper()}{t[1:]}" for t in _add_tags + _sync_tags if t.lower() not in _item_tags] _remove = [t for t in _item_tags if (_sync_tags and t not in _sync_tags) or t in _remove_tags] if _add: - updated = True self.query_data(getattr(obj, f"add{attr.capitalize()}"), _add) - logger.info(f"Detail: {attr.capitalize()} {','.join(_add)} added to {obj.title}") + display += f"+{', +'.join(_add)}" if _remove: - updated = True self.query_data(getattr(obj, f"remove{attr.capitalize()}"), _remove) - logger.info(f"Detail: {attr.capitalize()} {','.join(_remove)} removed to {obj.title}") - return updated + display += f"-{', -'.join(_remove)}" + if len(display) > 0: + logger.info(f"{obj.title[:25]:<25} | {attr.capitalize()} | {display}") + return len(display) > 0 def update_item_from_assets(self, item, overlay=None, create=False): name = os.path.basename(os.path.dirname(str(item.locations[0])) if self.is_movie else str(item.locations[0])) diff --git a/modules/trakt.py b/modules/trakt.py index f1886281..ee5772ac 100644 --- a/modules/trakt.py +++ b/modules/trakt.py @@ -16,6 +16,7 @@ sorts = [ "rank", "added", "title", "released", "runtime", "popularity", "percentage", "votes", "random", "my_rating", "watched", "collected" ] +id_translation = {"movie": "tmdb", "show": "tvdb", "season": "TVDb Season", "episode": "TVDb Episode"} class Trakt: def __init__(self, config, params): @@ -142,26 +143,31 @@ class Trakt: except Failed: raise Failed(f"Trakt Error: List {data} not found") - def _parse(self, items, top=True, item_type=None): + def _parse(self, items, typeless=False, item_type=None): ids = [] for item in items: - if top: - if item_type: - data = item[item_type] - elif item["type"] in ["movie", "show"]: - data = item[item["type"]] - else: - continue - else: + if typeless: data = item - if item_type: - id_type = "TMDb" if item_type == "movie" else "TVDb" + current_type = None + elif item_type: + data = item[item_type] + current_type = item_type + elif "type" in item and item["type"] in id_translation: + data = item["movie" if item["type"] == "movie" else "show"] + current_type = item["type"] else: - id_type = "TMDb" if item["type"] == "movie" else "TVDb" - if data["ids"][id_type.lower()]: - ids.append((data["ids"][id_type.lower()], id_type.lower())) + continue + id_type = "tmdb" if current_type == "movie" else "tvdb" + if data["ids"][id_type]: + final_id = data["ids"][id_type] + if current_type == "episode": + final_id = f"{final_id}_{item[current_type]['season']}" + if current_type in ["episode", "season"]: + final_id = f"{final_id}_{item[current_type]['number']}" + final_type = f"{id_type}_{current_type}" if current_type in ["episode", "season"] else id_type + ids.append((final_id, final_type)) else: - logger.error(f"Trakt Error: No {id_type} ID found for {data['title']} ({data['year']})") + logger.error(f"Trakt Error: No {id_type.upper().replace('B', 'b')} ID found for {data['title']} ({data['year']})") return ids def _user_list(self, data): @@ -184,7 +190,7 @@ class Trakt: def _pagenation(self, pagenation, amount, is_movie): items = self._request(f"/{'movies' if is_movie else 'shows'}/{pagenation}?limit={amount}") - return self._parse(items, top=pagenation != "popular", item_type="movie" if is_movie else "show") + return self._parse(items, typeless=pagenation == "popular", item_type="movie" if is_movie else "show") def validate_trakt(self, trakt_lists, is_movie, trakt_type="list"): values = util.get_list(trakt_lists, split=False) diff --git a/modules/util.py b/modules/util.py index 8b55a3cb..b972270f 100644 --- a/modules/util.py +++ b/modules/util.py @@ -66,6 +66,9 @@ def tab_new_lines(data): def make_ordinal(n): return f"{n}{'th' if 11 <= (n % 100) <= 13 else ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)]}" +def add_zero(number): + return str(number) if len(str(number)) > 1 else f"0{number}" + def add_dict_list(keys, value, dict_map): for key in keys: if key in dict_map: diff --git a/plex_meta_manager.py b/plex_meta_manager.py index 92ca3858..62e6e082 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -108,7 +108,7 @@ def start(config_path, is_test=False, time_scheduled=None, requested_collections logger.info(util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")) logger.info(util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")) logger.info(util.centered(" |___/ ")) - logger.info(util.centered(" Version: 1.12.0 ")) + logger.info(util.centered(" Version: 1.12.1 ")) if time_scheduled: start_type = f"{time_scheduled} " elif is_test: start_type = "Test " elif requested_collections: start_type = "Collections " @@ -125,7 +125,7 @@ def start(config_path, is_test=False, time_scheduled=None, requested_collections update_libraries(config) except Exception as e: util.print_stacktrace() - logger.critical(e) + util.print_multiline(e, critical=True) logger.info("") util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}") logger.removeHandler(file_handler) @@ -144,12 +144,14 @@ def update_libraries(config): os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) logger.info("") util.separator(f"{library.name} Library") - logger.info("") - util.separator(f"Mapping {library.name} Library", space=False, border=False) - logger.info("") - items = library.map_guids() + items = None + if not library.is_other: + logger.info("") + util.separator(f"Mapping {library.name} Library", space=False, border=False) + logger.info("") + items = library.map_guids() if not config.test_mode and not config.resume_from and not collection_only and library.mass_update: - mass_metadata(config, library, items) + mass_metadata(config, library, items=items) for metadata in library.metadata_files: logger.info("") util.separator(f"Running Metadata File\n{metadata.path}") @@ -198,7 +200,7 @@ def update_libraries(config): if library.assets_for_all and not collection_only: logger.info("") - util.separator(f"All {'Movies' if library.is_movie else 'Shows'} Assets Check for {library.name} Library", space=False, border=False) + util.separator(f"All {library.type}s Assets Check for {library.name} Library", space=False, border=False) logger.info("") for col in unmanaged_collections: poster, background = library.find_collection_assets(col, create=library.create_asset_folders) @@ -257,10 +259,12 @@ def update_libraries(config): if library.optimize: library.query(library.PlexServer.library.optimize) -def mass_metadata(config, library, items): +def mass_metadata(config, library, items=None): logger.info("") - util.separator(f"Mass Editing {'Movie' if library.is_movie else 'Show'} Library: {library.name}") + util.separator(f"Mass Editing {library.type} Library: {library.name}") logger.info("") + if items is None: + items = library.get_all() if library.split_duplicates: items = library.search(**{"duplicate": True}) for item in items: @@ -366,18 +370,7 @@ def mass_metadata(config, library, items): new_genres = tvdb_item.genres else: raise Failed - item_genres = [genre.tag for genre in item.genres] - display_str = "" - add_genre = [genre for genre in (g for g in new_genres if g not in item_genres)] - if len(add_genre) > 0: - display_str += f"+{', +'.join(add_genre)}" - library.query_data(item.addGenre, add_genre) - remove_genre = [genre for genre in (g for g in item_genres if g not in new_genres)] - if len(remove_genre) > 0: - display_str += f"-{', -'.join(remove_genre)}" - library.query_data(item.removeGenre, remove_genre) - if len(display_str) > 0: - logger.info(util.adjust_space(f"{item.title[:25]:<25} | Genres | {display_str}")) + library.edit_tags("genre", item, sync_tags=new_genres) except Failed: pass if library.mass_audience_rating_update: