diff --git a/modules/builder.py b/modules/builder.py index 4dcd4cee..34fce640 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") @@ -64,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", @@ -78,13 +78,19 @@ poster_details = ["url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "f 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"] 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", @@ -130,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", @@ -142,6 +148,10 @@ custom_sort_builders = [ "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_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): @@ -165,6 +175,7 @@ class CollectionBuilder: self.sonarr_details = {} self.missing_movies = [] self.missing_shows = [] + self.missing_parts = [] self.builders = [] self.filters = [] self.tmdb_filters = [] @@ -411,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") @@ -479,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 @@ -501,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") @@ -869,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) @@ -1070,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) @@ -1086,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: @@ -1103,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: @@ -1125,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: @@ -1133,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}") @@ -1149,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"]] @@ -1388,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): @@ -1404,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: @@ -1610,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 @@ -1620,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: @@ -1628,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 @@ -1697,7 +1769,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: @@ -1890,7 +1962,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 @@ -1912,13 +1985,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/plex.py b/modules/plex.py index c0900e08..04821225 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -61,6 +61,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 = { @@ -395,10 +396,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 +408,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) diff --git a/modules/trakt.py b/modules/trakt.py index f1886281..1bba8d46 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 item["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: