From e4d1851f479298d4ded4f17bc3754200f588080b Mon Sep 17 00:00:00 2001 From: meisnate12 Date: Wed, 29 Dec 2021 10:51:22 -0500 Subject: [PATCH] #189 adds music library support and better filters --- modules/builder.py | 362 ++++++++++++++++++++-------------- modules/meta.py | 448 +++++++++++++++++++++++++++---------------- modules/plex.py | 192 +++++++++++++++++-- modules/util.py | 5 + plex_meta_manager.py | 3 +- 5 files changed, 687 insertions(+), 323 deletions(-) diff --git a/modules/builder.py b/modules/builder.py index 4972404e..7cf39946 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -3,13 +3,13 @@ from datetime import datetime, timedelta from modules import anidb, anilist, flixpatrol, icheckmovies, imdb, letterboxd, mal, plex, radarr, sonarr, stevenlu, tautulli, tmdb, trakt, tvdb, util from modules.util import Failed, ImageData, NotScheduled from PIL import Image +from plexapi.audio import Artist, Album, Track from plexapi.exceptions import BadRequest, NotFound from plexapi.video import Movie, Show, Season, Episode from urllib.parse import quote logger = logging.getLogger("Plex Meta Manager") -string_filters = ["title", "episode_title", "studio"] advance_new_agent = ["item_metadata_language", "item_use_original_title"] advance_show = ["item_episode_sorting", "item_keep_episodes", "item_delete_episodes", "item_season_display", "item_episode_sorting"] method_alias = { @@ -46,6 +46,7 @@ method_alias = { "collection_changes_webhooks": "changes_webhooks" } filter_translation = { + "record_label": "studio", "actor": "actors", "audience_rating": "audienceRating", "collection": "collections", @@ -73,6 +74,7 @@ movie_only_builders = [ "tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing", "tvdb_movie", "tvdb_movie_details", "trakt_boxoffice" ] +music_only_builders = ["item_album_sorting"] summary_details = [ "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary", "tvdb_description", "trakt_description", "letterboxd_description", "icheckmovies_description" @@ -91,7 +93,7 @@ ignored_details = [ "validate_builders", "sort_by", "libraries", "sync_to_users", "collection_name", "playlist_name", "name" ] details = ["ignore_ids", "ignore_imdb_ids", "server_preroll", "changes_webhooks", "collection_mode", - "collection_minimum", "label"] + boolean_details + scheduled_boolean + string_details + "collection_minimum", "label", "album_sorting"] + boolean_details + scheduled_boolean + string_details collectionless_details = ["collection_order", "plex_collectionless", "label", "label_sync_mode", "test"] + \ poster_details + background_details + summary_details + string_details item_bool_details = ["item_tmdb_season_titles", "item_assets", "revert_overlay", "item_lock_background", "item_lock_poster", "item_lock_title", "item_refresh"] @@ -102,56 +104,51 @@ 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" ] -parts_collection_valid = [ - "plex_search", "trakt_list", "trakt_list_details", "collection_mode", "label", "visible_library", "changes_webhooks" - "visible_home", "visible_shared", "show_missing", "save_missing", "missing_only_released", "server_preroll", - "item_lock_background", "item_lock_poster", "item_lock_title", "item_refresh", "imdb_list" -] + summary_details + poster_details + background_details + string_details -all_filters = [ - "actor", "actor.not", - "audio_language", "audio_language.not", - "audio_track_title", "audio_track_title.not", "audio_track_title.is", "audio_track_title.isnot", "audio_track_title.begins", "audio_track_title.ends", "audio_track_title.regex", - "collection", "collection.not", - "content_rating", "content_rating.not", - "country", "country.not", - "director", "director.not", - "filepath", "filepath.not", "filepath.is", "filepath.isnot", "filepath.begins", "filepath.ends", "filepath.regex", - "genre", "genre.not", - "label", "label.not", - "producer", "producer.not", - "release", "release.not", "release.before", "release.after", "release.regex", "history", - "added", "added.not", "added.before", "added.after", "added.regex", - "last_played", "last_played.not", "last_played.before", "last_played.after", "last_played.regex", - "first_episode_aired", "first_episode_aired.not", "first_episode_aired.before", "first_episode_aired.after", "first_episode_aired.regex", - "last_episode_aired", "last_episode_aired.not", "last_episode_aired.before", "last_episode_aired.after", "last_episode_aired.regex", - "title", "title.not", "title.is", "title.isnot", "title.begins", "title.ends", "title.regex", - "plays.gt", "plays.gte", "plays.lt", "plays.lte", - "tmdb_vote_count.gt", "tmdb_vote_count.gte", "tmdb_vote_count.lt", "tmdb_vote_count.lte", - "duration.gt", "duration.gte", "duration.lt", "duration.lte", - "original_language", "original_language.not", - "user_rating.gt", "user_rating.gte", "user_rating.lt", "user_rating.lte", - "audience_rating.gt", "audience_rating.gte", "audience_rating.lt", "audience_rating.lte", - "critic_rating.gt", "critic_rating.gte", "critic_rating.lt", "critic_rating.lte", - "studio", "studio.not", "studio.is", "studio.isnot", "studio.begins", "studio.ends", "studio.regex", - "subtitle_language", "subtitle_language.not", - "resolution", "resolution.not", - "writer", "writer.not", "has_collection", "has_overlay", - "year", "year.gt", "year.gte", "year.lt", "year.lte", "year.not" - "tmdb_year", "tmdb_year.gt", "tmdb_year.gte", "tmdb_year.lt", "tmdb_year.lte", "tmdb_year.not" -] +album_details = ["item_label", "item_album_sorting"] +filters_by_type = { + "movie_show_season_episode_artist_album_track": ["title", "collection", "has_collection", "added", "last_played", "user_rating", "plays"], + "movie_show_season_episode_album_track": ["year"], + "movie_show_episode_artist_track": ["filepath"], + "movie_show_episode_album": ["release", "critic_rating", "history"], + "movie_show_episode_track": ["duration"], + "movie_show_artist_album": ["genre"], + "movie_show_episode": ["actor", "content_rating", "audience_rating"], + "movie_show_album": ["label"], + "movie_episode_track": ["audio_track_title"], + "movie_show": ["studio", "original_language", "has_overlay", "tmdb_vote_count", "tmdb_year"], + "movie_episode": ["director", "producer", "writer", "resolution", "audio_language", "subtitle_language"], + "movie_artist": ["country"], + "show": ["network", "first_episode_aired", "last_episode_aired"], + "album": ["record_label"] +} +filters = { + "movie": [item for check, sub in filters_by_type.items() for item in sub if "movie" in check], + "show": [item for check, sub in filters_by_type.items() for item in sub if "show" in check], + "season": [item for check, sub in filters_by_type.items() for item in sub if "season" in check], + "episode": [item for check, sub in filters_by_type.items() for item in sub if "episode" in check], + "artist": [item for check, sub in filters_by_type.items() for item in sub if "artist" in check], + "album": [item for check, sub in filters_by_type.items() for item in sub if "album" in check], + "track": [item for check, sub in filters_by_type.items() for item in sub if "track" in check] +} tmdb_filters = ["original_language", "tmdb_vote_count", "tmdb_year", "first_episode_aired", "last_episode_aired"] -boolean_filters = ["has_collection", "has_overlay"] -movie_only_filters = [ - "audio_language", "audio_language.not", - "audio_track_title", "audio_track_title.not", "audio_track_title.is", "audio_track_title.isnot", "audio_track_title.begins", "audio_track_title.ends", "audio_track_title.regex", - "country", "country.not", - "director", "director.not", - "duration.gt", "duration.gte", "duration.lt", "duration.lte", - "subtitle_language", "subtitle_language.not", - "resolution", "resolution.not", - "writer", "writer.not" +string_filters = ["title", "studio", "record_label", "filepath", "audio_track_title"] +string_modifiers = ["", ".not", ".is", ".isnot", ".begins", ".ends", ".regex"] +tag_filters = [ + "actor", "collection", "content_rating", "country", "director", "network", "genre", "label", "producer", "year", + "writer", "original_language", "resolution", "audio_language", "subtitle_language" ] -show_only_filters = ["first_episode_aired", "last_episode_aired", "network"] +tag_modifiers = ["", ".not"] +boolean_filters = ["has_collection", "has_overlay"] +date_filters = ["release", "added", "last_played", "first_episode_aired", "last_episode_aired"] +date_modifiers = ["", ".not", ".before", ".after", ".regex"] +number_filters = ["year", "tmdb_year", "critic_rating", "audience_rating", "user_rating", "tmdb_vote_count", "plays", "duration"] +number_modifiers = [".gt", ".gte", ".lt", ".lte"] +special_filters = ["history"] +all_filters = boolean_filters + special_filters + \ + [f"{f}{m}" for f in string_filters for m in string_modifiers] + \ + [f"{f}{m}" for f in tag_filters for m in tag_modifiers] + \ + [f"{f}{m}" for f in date_filters for m in date_modifiers] + \ + [f"{f}{m}" for f in number_filters for m in number_modifiers] 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 = [ @@ -168,11 +165,20 @@ 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", "changes_webhooks" + "visible_home", "visible_shared", "show_missing", "save_missing", "missing_only_released", "server_preroll", + "item_lock_background", "item_lock_poster", "item_lock_title", "item_refresh", "imdb_list" +] + summary_details + poster_details + background_details + string_details playlist_attributes = [ "filters", "name_mapping", "show_filtered", "show_missing", "save_missing", "missing_only_released", "only_filter_missing", "delete_below_minimum", "ignore_ids", "ignore_imdb_ids", "server_preroll", "changes_webhooks", "collection_minimum", ] + custom_sort_builders + summary_details + poster_details + radarr_details + sonarr_details +music_attributes = [ + "item_label", "item_assets", "item_lock_background", "item_lock_poster", "item_lock_title", + "item_refresh", "plex_search", "plex_all", "filters" +] + details + summary_details + poster_details + background_details class CollectionBuilder: def __init__(self, config, library, metadata, name, no_missing, data, playlist=False, valid_users=None): @@ -347,16 +353,22 @@ class CollectionBuilder: logger.debug("") logger.debug("Validating Method: collection_level") if self.library.is_movie: - raise Failed(f"{self.Type} Error: collection_level attribute only works for show libraries") + raise Failed(f"{self.Type} Error: collection_level attribute only works for show and music libraries") elif self.data[methods["collection_level"]] is None: raise Failed(f"{self.Type} Error: 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: + + if (self.library.is_show and self.data[methods["collection_level"]].lower() in plex.collection_level_show_options) or \ + (self.library.is_music and self.data[methods["collection_level"]].lower() in plex.collection_level_music_options): self.collection_level = self.data[methods["collection_level"]].lower() else: - raise Failed(f"{self.Type} 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"] + if self.library.is_show: + options = "\n\tseason (Collection at the Season Level)\n\tepisode (Collection at the Episode Level)" + else: + options = "\n\talbum (Collection at the Album Level)\n\ttrack (Collection at the Track Level)" + raise Failed(f"{self.Type} Error: {self.data[methods['collection_level']]} collection_level invalid{options}") + self.parts_collection = self.collection_level in plex.collection_level_options if "tmdb_person" in methods: logger.debug("") @@ -380,7 +392,7 @@ class CollectionBuilder: self.smart_sort = "random" self.smart_label_collection = False - if "smart_label" in methods and not self.playlist: + if "smart_label" in methods and not self.playlist and not self.library.is_music: logger.debug("") logger.debug("Validating Method: smart_label") self.smart_label_collection = True @@ -441,44 +453,88 @@ class CollectionBuilder: logger.debug(f"Validating Method: {method_key}") logger.debug(f"Value: {method_data}") try: - if method_data is None and method_name in all_builders + plex.searches: raise Failed(f"{self.Type} Error: {method_final} attribute is blank") - elif method_data is None and method_final not in none_details: logger.warning(f"Collection Warning: {method_final} attribute is blank") - elif self.playlist and method_name not in playlist_attributes: raise Failed(f"{self.Type} Error: {method_final} attribute not allowed when using playlists") - elif not self.config.Trakt and "trakt" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Trakt to be configured") - elif not self.library.Radarr and "radarr" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Radarr to be configured") - elif not self.library.Sonarr and "sonarr" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Sonarr to be configured") - elif not self.library.Tautulli and "tautulli" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Tautulli to be configured") - elif not self.config.MyAnimeList and "mal" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires MyAnimeList to be configured") - elif self.library.is_movie and method_name in show_only_builders: raise Failed(f"{self.Type} Error: {method_final} attribute only works for show libraries") - elif self.library.is_show and method_name in movie_only_builders: raise Failed(f"{self.Type} 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"{self.Type} 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"{self.Type} 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"{self.Type} Error: {method_final} attribute does not work with Collection Level: {self.collection_level.capitalize()}") - elif self.smart and method_name in smart_invalid: raise Failed(f"{self.Type} Error: {method_final} attribute only works with normal collections") - elif self.collectionless and method_name not in collectionless_details: raise Failed(f"{self.Type} 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"{self.Type} Error: {method_final} builder not allowed when using smart_filter") - elif method_name in summary_details: self._summary(method_name, method_data) - elif method_name in poster_details: self._poster(method_name, method_data) - elif method_name in background_details: self._background(method_name, method_data) - elif method_name in details: self._details(method_name, method_data, method_final, methods) - elif method_name in item_details: self._item_details(method_name, method_data, method_mod, method_final, methods) - elif method_name in radarr_details: self._radarr(method_name, method_data) - elif method_name in sonarr_details: self._sonarr(method_name, method_data) - elif method_name in anidb.builders: self._anidb(method_name, method_data) - elif method_name in anilist.builders: self._anilist(method_name, method_data) - elif method_name in flixpatrol.builders: self._flixpatrol(method_name, method_data) - elif method_name in icheckmovies.builders: self._icheckmovies(method_name, method_data) - elif method_name in letterboxd.builders: self._letterboxd(method_name, method_data) - elif method_name in imdb.builders: self._imdb(method_name, method_data) - elif method_name in mal.builders: self._mal(method_name, method_data) - elif method_name in plex.builders or method_final in plex.searches: self._plex(method_name, method_data) - elif method_name in stevenlu.builders: self._stevenlu(method_name, method_data) - elif method_name in tautulli.builders: self._tautulli(method_name, method_data) - elif method_name in tmdb.builders: self._tmdb(method_name, method_data) - elif method_name in trakt.builders: self._trakt(method_name, method_data) - elif method_name in tvdb.builders: self._tvdb(method_name, method_data) - elif method_name == "filters": self._filters(method_name, method_data) - else: raise Failed(f"{self.Type} Error: {method_final} attribute not supported") + if method_data is None and method_name in all_builders + plex.searches: + raise Failed(f"{self.Type} Error: {method_final} attribute is blank") + elif method_data is None and method_final not in none_details: + logger.warning(f"Collection Warning: {method_final} attribute is blank") + elif self.playlist and method_name not in playlist_attributes: + raise Failed(f"{self.Type} Error: {method_final} attribute not allowed when using playlists") + elif not self.config.Trakt and "trakt" in method_name: + raise Failed(f"{self.Type} Error: {method_final} requires Trakt to be configured") + elif not self.library.Radarr and "radarr" in method_name: + raise Failed(f"{self.Type} Error: {method_final} requires Radarr to be configured") + elif not self.library.Sonarr and "sonarr" in method_name: + raise Failed(f"{self.Type} Error: {method_final} requires Sonarr to be configured") + elif not self.library.Tautulli and "tautulli" in method_name: + raise Failed(f"{self.Type} Error: {method_final} requires Tautulli to be configured") + elif not self.config.MyAnimeList and "mal" in method_name: + raise Failed(f"{self.Type} Error: {method_final} requires MyAnimeList to be configured") + elif self.library.is_movie and method_name in show_only_builders: + raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for show libraries") + elif self.library.is_show and method_name in movie_only_builders: + raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for movie libraries") + elif self.library.is_show and method_name in plex.movie_only_searches: + raise Failed(f"{self.Type} Error: {method_final} plex search only allowed for movie libraries") + elif self.library.is_movie and method_name in plex.show_only_searches: + raise Failed(f"{self.Type} Error: {method_final} plex search only allowed for show libraries") + elif self.library.is_music and method_name not in music_attributes: + raise Failed(f"{self.Type} Error: {method_final} attribute not allowed for music libraries") + elif self.library.is_music and method_name in album_details and self.collection_level != "album": + raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for album collections") + elif not self.library.is_music and method_name in music_only_builders: + raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for music libraries") + elif self.parts_collection and method_name not in parts_collection_valid: + raise Failed(f"{self.Type} Error: {method_final} attribute not allowed with Collection Level: {self.collection_level.capitalize()}") + elif self.smart and method_name in smart_invalid: + raise Failed(f"{self.Type} Error: {method_final} attribute only allowed with normal collections") + elif self.collectionless and method_name not in collectionless_details: + raise Failed(f"{self.Type} Error: {method_final} attribute not allowed for Collectionless collection") + elif self.smart_url and method_name in all_builders + smart_url_invalid: + raise Failed(f"{self.Type} Error: {method_final} builder not allowed when using smart_filter") + elif method_name in summary_details: + self._summary(method_name, method_data) + elif method_name in poster_details: + self._poster(method_name, method_data) + elif method_name in background_details: + self._background(method_name, method_data) + elif method_name in details: + self._details(method_name, method_data, method_final, methods) + elif method_name in item_details: + self._item_details(method_name, method_data, method_mod, method_final, methods) + elif method_name in radarr_details: + self._radarr(method_name, method_data) + elif method_name in sonarr_details: + self._sonarr(method_name, method_data) + elif method_name in anidb.builders: + self._anidb(method_name, method_data) + elif method_name in anilist.builders: + self._anilist(method_name, method_data) + elif method_name in flixpatrol.builders: + self._flixpatrol(method_name, method_data) + elif method_name in icheckmovies.builders: + self._icheckmovies(method_name, method_data) + elif method_name in letterboxd.builders: + self._letterboxd(method_name, method_data) + elif method_name in imdb.builders: + self._imdb(method_name, method_data) + elif method_name in mal.builders: + self._mal(method_name, method_data) + elif method_name in plex.builders or method_final in plex.searches: + self._plex(method_name, method_data) + elif method_name in stevenlu.builders: + self._stevenlu(method_name, method_data) + elif method_name in tautulli.builders: + self._tautulli(method_name, method_data) + elif method_name in tmdb.builders: + self._tmdb(method_name, method_data) + elif method_name in trakt.builders: + self._trakt(method_name, method_data) + elif method_name in tvdb.builders: + self._tvdb(method_name, method_data) + elif method_name == "filters": + self._filters(method_name, method_data) + else: + raise Failed(f"{self.Type} Error: {method_final} attribute not supported") except Failed as e: if self.validate_builders: raise @@ -502,7 +558,7 @@ class CollectionBuilder: if "add_existing" not in self.sonarr_details: self.sonarr_details["add_existing"] = self.library.Sonarr.add_existing if self.library.Sonarr else False - if self.smart_url or self.collectionless: + if self.smart_url or self.collectionless or self.library.is_music: self.radarr_details["add"] = False self.radarr_details["add_existing"] = False self.sonarr_details["add"] = False @@ -1123,10 +1179,8 @@ class CollectionBuilder: message = None if filter_final not in all_filters: message = f"{self.Type} Error: {filter_final} is not a valid filter attribute" - elif filter_final in movie_only_filters and self.library.is_show: - message = f"{self.Type} Error: {filter_final} filter attribute only works for movie libraries" - elif filter_final in show_only_filters and self.library.is_movie: - message = f"{self.Type} Error: {filter_final} filter attribute only works for show libraries" + elif self.collection_level in filters and filter_attr not in filters[self.collection_level]: + message = f"{self.Type} Error: {filter_final} is not a valid {self.collection_level} filter attribute" elif filter_final is None: message = f"{self.Type} Error: {filter_final} filter attribute is blank" elif filter_attr in tmdb_filters: @@ -1280,8 +1334,8 @@ class CollectionBuilder: logger.info("") logger.info("Filtering Builders:") for i, item in enumerate(items, 1): - if not isinstance(item, (Movie, Show, Season, Episode)): - logger.error(f"{self.Type} Error: Item: {item} must be Movie, Show, Season, or Episode") + if not isinstance(item, (Movie, Show, Season, Episode, Artist, Album, Track)): + logger.error(f"{self.Type} Error: Item: {item} is an invalid type") continue if item not in self.added_items: if item.ratingKey in self.filtered_keys: @@ -1318,8 +1372,14 @@ class CollectionBuilder: if plex_filter[filter_alias["type"]] not in ["shows", "seasons", "episodes"]: raise Failed(f"{self.Type} Error: type: {plex_filter[filter_alias['type']]} is invalid, must be either shows, season, or episodes") sort_type = plex_filter[filter_alias["type"]] + elif smart and "type" in filter_alias and self.library.is_music: + if plex_filter[filter_alias["type"]] not in ["artists", "albums", "tracks"]: + raise Failed(f"{self.Type} Error: type: {plex_filter[filter_alias['type']]} is invalid, must be either artists, albums, or tracks") + sort_type = plex_filter[filter_alias["type"]] elif self.library.is_show: sort_type = "shows" + elif self.library.is_music: + sort_type = "artists" else: sort_type = "movies" ms = method.split("_") @@ -1369,7 +1429,7 @@ class CollectionBuilder: mod = plex.modifier_translation[modifier] if modifier in plex.modifier_translation else modifier if arg_s is None: arg_s = arg - if attr in string_filters and modifier in ["", ".not"]: + if attr in plex.string_attributes and modifier in ["", ".not"]: mod_s = "does not contain" if modifier == ".not" else "contains" elif mod_s is None: mod_s = util.mod_displays[modifier] @@ -1379,10 +1439,14 @@ class CollectionBuilder: if final_attr not in plex.searches and not final_attr.startswith(("any", "all")): raise Failed(f"{self.Type} Error: {final_attr} is not a valid {method} attribute") - elif final_attr in plex.movie_only_searches and self.library.is_show: + elif self.library.is_show and final_attr in plex.movie_only_searches: raise Failed(f"{self.Type} Error: {final_attr} {method} attribute only works for movie libraries") - elif final_attr in plex.show_only_searches and self.library.is_movie: + elif self.library.is_movie and final_attr in plex.show_only_searches: raise Failed(f"{self.Type} Error: {final_attr} {method} attribute only works for show libraries") + elif self.library.is_music and final_attr not in plex.music_searches: + raise Failed(f"{self.Type} Error: {final_attr} {method} attribute only works for movie or show libraries") + elif not self.library.is_music and final_attr in plex.music_searches: + raise Failed(f"{self.Type} Error: {final_attr} {method} attribute only works for music libraries") elif _data is None: raise Failed(f"{self.Type} Error: {final_attr} {method} attribute is blank") elif final_attr.startswith(("any", "all")): @@ -1410,11 +1474,11 @@ class CollectionBuilder: bool_mod = "" if validation else "!" bool_arg = "true" if validation else "false" results, display_add = build_url_arg(1, mod=bool_mod, arg_s=bool_arg, mod_s="is") - elif (attr in ["title", "episode_title", "studio", "decade", "year", "episode_year"] or attr in plex.tags) and modifier in ["", ".is", ".isnot", ".not", ".begins", ".ends"]: + elif (attr in plex.tag_attributes + plex.string_attributes + plex.year_attributes) and modifier in ["", ".is", ".isnot", ".not", ".begins", ".ends"]: results = "" display_add = "" for og_value, result in validation: - built_arg = build_url_arg(quote(str(result)) if attr in string_filters else result, arg_s=og_value) + built_arg = build_url_arg(quote(str(result)) if attr in plex.string_attributes else result, arg_s=og_value) display_add += built_arg[1] results += f"{conjunction if len(results) > 0 else ''}{built_arg[0]}" else: @@ -1475,7 +1539,7 @@ class CollectionBuilder: else: logger.error(err) return valid_regex - elif attribute in ["title", "studio", "episode_title", "audio_track_title"] and modifier in ["", ".not", ".is", ".isnot", ".begins", ".ends"]: + elif attribute in plex.string_attributes + ["audio_track_title"] and modifier in ["", ".not", ".is", ".isnot", ".begins", ".ends"]: return smart_pair(util.get_list(data, split=False)) elif attribute == "original_language": return util.get_list(data, lower=True) @@ -1488,7 +1552,7 @@ class CollectionBuilder: if str(data).lower() in ["day", "month"]: return data.lower() raise Failed(f"{self.Type} Error: history attribute invalid: {data} must be a number between 1-30, day, or month") - elif attribute in plex.tags and modifier in ["", ".not"]: + elif attribute in plex.tag_attributes and modifier in ["", ".not"]: if attribute in plex.tmdb_attributes: final_values = [] for value in util.get_list(data): @@ -1517,20 +1581,18 @@ class CollectionBuilder: else: logger.error(error) return valid_list - elif attribute in ["year", "episode_year", "tmdb_year"] and modifier in [".gt", ".gte", ".lt", ".lte"]: - return self._parse(final, data, datatype="int", minimum=1800, maximum=self.current_year) elif attribute in plex.date_attributes and modifier in [".before", ".after"]: return util.validate_date(data, final, return_as="%Y-%m-%d") - elif attribute in plex.number_attributes and modifier in ["", ".not", ".gt", ".gte", ".lt", ".lte"]: - return self._parse(final, data, datatype="int") - elif attribute in plex.float_attributes and modifier in [".gt", ".gte", ".lt", ".lte"]: - return self._parse(final, data, datatype="float", minimum=0, maximum=10) - elif attribute in ["decade", "year", "episode_year", "tmdb_year"] and modifier in ["", ".not"]: + elif attribute in plex.year_attributes + ["tmdb_year"] and modifier in ["", ".not"]: final_years = [] values = util.get_list(data) for value in values: - final_years.append(self._parse(final, value, datatype="int", minimum=1800, maximum=self.current_year)) + final_years.append(self._parse(final, value, datatype="int")) return smart_pair(final_years) + elif attribute in plex.number_attributes + plex.date_attributes and modifier in ["", ".not", ".gt", ".gte", ".lt", ".lte"]: + return self._parse(final, data, datatype="int") + elif attribute in plex.float_attributes and modifier in [".gt", ".gte", ".lt", ".lte"]: + return self._parse(final, data, datatype="float", minimum=0, maximum=10) elif attribute in plex.boolean_attributes + boolean_filters: return self._parse(attribute, data, datatype="bool") else: @@ -1556,8 +1618,8 @@ class CollectionBuilder: def fetch_item(self, item): try: - current = self.library.fetchItem(item.ratingKey if isinstance(item, (Movie, Show, Season, Episode)) else int(item)) - if not isinstance(current, (Movie, Show, Season, Episode)): + current = self.library.fetchItem(item.ratingKey if isinstance(item, (Movie, Show, Season, Episode, Artist, Album, Track)) else int(item)) + if not isinstance(current, (Movie, Show, Season, Episode, Artist, Album, Track)): raise NotFound return current except (BadRequest, NotFound): @@ -1668,53 +1730,73 @@ class CollectionBuilder: return False return True - def check_filters(self, current, display): + def check_filters(self, item, display): if (self.filters or self.tmdb_filters) and not self.details["only_filter_missing"]: - util.print_return(f"Filtering {display} {current.title}") - if self.tmdb_filters: - if current.ratingKey not in self.library.movie_rating_key_map and current.ratingKey not in self.library.show_rating_key_map: - logger.warning(f"Filter Error: No {'TMDb' if self.library.is_movie else 'TVDb'} ID found for {current.title}") + util.print_return(f"Filtering {display} {item.title}") + if self.tmdb_filters and isinstance(item, (Movie, Show)): + if item.ratingKey not in self.library.movie_rating_key_map and item.ratingKey not in self.library.show_rating_key_map: + logger.warning(f"Filter Error: No {'TMDb' if self.library.is_movie else 'TVDb'} ID found for {item.title}") return False try: - if current.ratingKey in self.library.movie_rating_key_map: - t_id = self.library.movie_rating_key_map[current.ratingKey] + if item.ratingKey in self.library.movie_rating_key_map: + t_id = self.library.movie_rating_key_map[item.ratingKey] else: - t_id = self.library.show_rating_key_map[current.ratingKey] + t_id = self.library.show_rating_key_map[item.ratingKey] except Failed as e: logger.error(e) return False - if not self.check_tmdb_filter(t_id, current.ratingKey in self.library.movie_rating_key_map): + if not self.check_tmdb_filter(t_id, item.ratingKey in self.library.movie_rating_key_map): return False for filter_method, filter_data in self.filters: filter_attr, modifier, filter_final = self._split(filter_method) filter_actual = filter_translation[filter_attr] if filter_attr in filter_translation else filter_attr - if filter_attr in ["release", "added", "last_played"]: - if util.is_date_filter(getattr(current, filter_actual), modifier, filter_data, filter_final, self.current_time): + item_type = self.collection_level + if self.collection_level == "item": + if isinstance(item, Movie): + item_type = "movie" + elif isinstance(item, Show): + item_type = "show" + elif isinstance(item, Season): + item_type = "season" + elif isinstance(item, Episode): + item_type = "episode" + elif isinstance(item, Artist): + item_type = "artist" + elif isinstance(item, Album): + item_type = "album" + elif isinstance(item, Track): + item_type = "track" + else: + continue + if filter_attr not in filters[item_type]: + continue + elif filter_attr in ["release", "added", "last_played"]: + if util.is_date_filter(getattr(item, filter_actual), modifier, filter_data, filter_final, self.current_time): return False elif filter_attr in ["audio_track_title", "filepath", "title", "studio"]: values = [] if filter_attr == "audio_track_title": - for media in current.media: + for media in item.media: for part in media.parts: values.extend([a.title for a in part.audioStreams() if a.title]) elif filter_attr == "filepath": - values = [loc for loc in current.locations] + values = [loc for loc in item.locations] elif filter_attr in ["title", "studio"]: - values = [getattr(current, filter_actual)] + values = [getattr(item, filter_actual)] if util.is_string_filter(values, modifier, filter_data): return False elif filter_attr in boolean_filters: filter_check = False if filter_attr == "has_collection": - filter_check = len(current.collections) > 0 + filter_check = len(item.collections) > 0 elif filter_attr == "has_overlay": - for label in current.labels: + for label in item.labels: if label.tag.lower().endswith(" overlay"): filter_check = True if util.is_boolean_filter(filter_data, filter_check): return False elif filter_attr == "history": - item_date = current.originallyAvailableAt + item_date = item.originallyAvailableAt if item_date is None: return False elif filter_data == "day": @@ -1733,12 +1815,12 @@ class CollectionBuilder: return False elif modifier in [".gt", ".gte", ".lt", ".lte"]: divider = 60000 if filter_attr == "duration" else 1 - if util.is_number_filter(getattr(current, filter_actual) / divider, modifier, filter_data): + if util.is_number_filter(getattr(item, filter_actual) / divider, modifier, filter_data): return False else: attrs = [] if filter_attr in ["resolution", "audio_language", "subtitle_language"]: - for media in current.media: + for media in item.media: if filter_attr == "resolution": attrs.extend([media.videoResolution]) for part in media.parts: @@ -1747,16 +1829,16 @@ class CollectionBuilder: if filter_attr == "subtitle_language": attrs.extend([s.language for s in part.subtitleStreams()]) elif filter_attr in ["content_rating", "year", "rating"]: - attrs = [str(getattr(current, filter_actual))] + attrs = [str(getattr(item, filter_actual))] elif filter_attr in ["actor", "country", "director", "genre", "label", "producer", "writer", "collection"]: - attrs = [attr.tag for attr in getattr(current, filter_actual)] + attrs = [attr.tag for attr in getattr(item, filter_actual)] else: raise Failed(f"Filter Error: filter: {filter_final} not supported") if (not list(set(filter_data) & set(attrs)) and modifier == "") \ or (list(set(filter_data) & set(attrs)) and modifier == ".not"): return False - util.print_return(f"Filtering {display} {current.title}") + util.print_return(f"Filtering {display} {item.title}") return True def run_missing(self): @@ -1924,7 +2006,7 @@ class CollectionBuilder: logger.error(e) # Locking should come before refreshing since refreshing can change metadata (i.e. if specified to both lock - # background/poster and also refreshing, assume that the current background/poster should be kept) + # background/poster and also refreshing, assume that the item background/poster should be kept) if "item_lock_background" in self.item_details: self.library.query(item.lockArt) if "item_lock_poster" in self.item_details: diff --git a/modules/meta.py b/modules/meta.py index 59e71317..640e7466 100644 --- a/modules/meta.py +++ b/modules/meta.py @@ -9,6 +9,18 @@ logger = logging.getLogger("Plex Meta Manager") github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/" +advance_tags_to_edit = { + "Movie": ["metadata_language", "use_original_title"], + "Show": ["episode_sorting", "keep_episodes", "delete_episodes", "season_display", "episode_ordering", + "metadata_language", "use_original_title"], + "Artist": ["album_sort"] +} + +tags_to_edit = { + "Movie": ["genre", "label", "collection", "country", "director", "producer", "writer"], + "Show": ["genre", "label", "collection"], + "Artist": ["genre", "style", "mood", "country", "collection", "similar_artist"] +} def get_dict(attribute, attr_data, check_list=None): if check_list is None: @@ -19,14 +31,11 @@ def get_dict(attribute, attr_data, check_list=None): new_dict = {} for _name, _data in attr_data[attribute].items(): if _name in check_list: - logger.error( - f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name}") + logger.error(f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name}") elif _data is None: - logger.error( - f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} has no data") + logger.error(f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} has no data") elif not isinstance(_data, dict): - logger.error( - f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} must be a dictionary") + logger.error(f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} must be a dictionary") else: new_dict[str(_name)] = _data return new_dict @@ -221,6 +230,48 @@ class MetadataFile(DataFile): else: return self.collections + def edit_tags(self, attr, obj, group, alias, extra=None): + if attr in alias and f"{attr}.sync" in alias: + logger.error(f"Metadata Error: Cannot use {attr} and {attr}.sync together") + elif f"{attr}.remove" in alias and f"{attr}.sync" in alias: + logger.error(f"Metadata Error: Cannot use {attr}.remove and {attr}.sync together") + elif attr in alias and group[alias[attr]] is None: + logger.error(f"Metadata Error: {attr} attribute is blank") + elif f"{attr}.remove" in alias and group[alias[f"{attr}.remove"]] is None: + logger.error(f"Metadata Error: {attr}.remove attribute is blank") + elif f"{attr}.sync" in alias and group[alias[f"{attr}.sync"]] is None: + logger.error(f"Metadata Error: {attr}.sync attribute is blank") + elif attr in alias or f"{attr}.remove" in alias or f"{attr}.sync" in alias: + add_tags = util.get_list(group[alias[attr]]) if attr in alias else [] + if extra: + add_tags.extend(extra) + remove_tags = util.get_list(group[alias[f"{attr}.remove"]]) if f"{attr}.remove" in alias else None + sync_tags = util.get_list(group[alias[f"{attr}.sync"]] if group[alias[f"{attr}.sync"]] else []) if f"{attr}.sync" in alias else None + return self.library.edit_tags(attr, obj, add_tags=add_tags, remove_tags=remove_tags, sync_tags=sync_tags) + return False + + def set_images(self, obj, group, alias): + + def set_image(attr, is_poster=True, is_url=True): + if group[alias[attr]]: + return ImageData(attr, group[alias[attr]], is_poster=is_poster, is_url=is_url) + else: + logger.error(f"Metadata Error: {attr} attribute is blank") + + poster = None + background = None + if "url_poster" in alias: + poster = set_image("url_poster") + elif "file_poster" in alias: + poster = set_image("file_poster", is_url=False) + if "url_background" in alias: + background = set_image("url_background", is_poster=False) + elif "file_background" in alias: + background = set_image("file_background",is_poster=False, is_url=False) + + if poster or background: + self.library.upload_images(obj, poster=poster, background=background) + def update_metadata(self): if not self.metadata: return None @@ -229,8 +280,6 @@ class MetadataFile(DataFile): logger.info("") for mapping_name, meta in self.metadata.items(): methods = {mm.lower(): mm for mm in meta} - if self.config.test_mode and ("test" not in methods or meta[methods["test"]] is not True): - continue updated = False edits = {} @@ -243,13 +292,11 @@ class MetadataFile(DataFile): if value is None: value = group[alias[name]] try: current = str(getattr(current_item, key, "")) + final_value = None if var_type == "date": final_value = util.validate_date(value, name, return_as="%Y-%m-%d") current = current[:-9] elif var_type == "float": - if value is None: - raise Failed(f"Metadata Error: {name} attribute is blank") - final_value = None try: value = float(str(value)) if 0 <= value <= 10: @@ -258,6 +305,13 @@ class MetadataFile(DataFile): pass if final_value is None: raise Failed(f"Metadata Error: {name} attribute must be a number between 0 and 10") + elif var_type == "int": + try: + final_value = int(str(value)) + except ValueError: + pass + if final_value is None: + raise Failed(f"Metadata Error: {name} attribute must be an integer") else: final_value = value if current != str(final_value): @@ -269,13 +323,11 @@ class MetadataFile(DataFile): else: logger.error(f"Metadata Error: {name} attribute is blank") - def add_advanced_edit(attr, obj, group, alias, show_library=False, new_agent=False): + def add_advanced_edit(attr, obj, group, alias, new_agent=False): key, options = plex.item_advance_keys[f"item_{attr}"] if attr in alias: if new_agent and self.library.agent not in plex.new_plex_agents: logger.error(f"Metadata Error: {attr} attribute only works for with the New Plex Movie Agent and New Plex TV Agent") - elif show_library and not self.library.is_show: - logger.error(f"Metadata Error: {attr} attribute only works for show libraries") elif group[alias[attr]]: method_data = str(group[alias[attr]]).lower() if method_data not in options: @@ -286,54 +338,12 @@ class MetadataFile(DataFile): else: logger.error(f"Metadata Error: {attr} attribute is blank") - def edit_tags(attr, obj, group, alias, extra=None, movie_library=False): - if movie_library and not self.library.is_movie and (attr in alias or f"{attr}.sync" in alias or f"{attr}.remove" in alias): - logger.error(f"Metadata Error: {attr} attribute only works for movie libraries") - elif attr in alias and f"{attr}.sync" in alias: - logger.error(f"Metadata Error: Cannot use {attr} and {attr}.sync together") - elif f"{attr}.remove" in alias and f"{attr}.sync" in alias: - logger.error(f"Metadata Error: Cannot use {attr}.remove and {attr}.sync together") - elif attr in alias and group[alias[attr]] is None: - logger.error(f"Metadata Error: {attr} attribute is blank") - elif f"{attr}.remove" in alias and group[alias[f"{attr}.remove"]] is None: - logger.error(f"Metadata Error: {attr}.remove attribute is blank") - elif f"{attr}.sync" in alias and group[alias[f"{attr}.sync"]] is None: - logger.error(f"Metadata Error: {attr}.sync attribute is blank") - elif attr in alias or f"{attr}.remove" in alias or f"{attr}.sync" in alias: - add_tags = util.get_list(group[alias[attr]]) if attr in alias else [] - if extra: - add_tags.extend(extra) - remove_tags = util.get_list(group[alias[f"{attr}.remove"]]) if f"{attr}.remove" in alias else None - sync_tags = util.get_list(group[alias[f"{attr}.sync"]] if group[alias[f"{attr}.sync"]] else []) if f"{attr}.sync" in alias else None - return self.library.edit_tags(attr, obj, add_tags=add_tags, remove_tags=remove_tags, sync_tags=sync_tags) - return False - - def set_image(attr, group, alias, is_poster=True, is_url=True): - if group[alias[attr]]: - return ImageData(attr, group[alias[attr]], is_poster=is_poster, is_url=is_url) - else: - logger.error(f"Metadata Error: {attr} attribute is blank") - - def set_images(obj, group, alias): - poster = None - background = None - if "url_poster" in alias: - poster = set_image("url_poster", group, alias) - elif "file_poster" in alias: - poster = set_image("file_poster", group, alias, is_url=False) - if "url_background" in alias: - background = set_image("url_background", group, alias, is_poster=False) - elif "file_background" in alias: - background = set_image("file_background", group, alias, is_poster=False, is_url=False) - - if poster or background: - self.library.upload_images(obj, poster=poster, background=background) logger.info("") util.separator() logger.info("") year = None - if "year" in methods: + if "year" in methods and not self.library.is_music: next_year = datetime.now().year + 1 if meta[methods["year"]] is None: raise Failed("Metadata Error: year attribute is blank") @@ -370,15 +380,14 @@ class MetadataFile(DataFile): logger.error(f"Skipping {mapping_name}") continue - item_type = "Movie" if self.library.is_movie else "Show" - logger.info(f"Updating {item_type}: {title}...") + logger.info(f"Updating {self.library.type}: {title}...") tmdb_item = None tmdb_is_movie = None - if ("tmdb_show" in methods or "tmdb_id" in methods) and "tmdb_movie" in methods: + if not self.library.is_music and ("tmdb_show" in methods or "tmdb_id" in methods) and "tmdb_movie" in methods: logger.error("Metadata Error: Cannot use tmdb_movie and tmdb_show when editing the same metadata item") - if "tmdb_show" in methods or "tmdb_id" in methods or "tmdb_movie" in methods: + if not self.library.is_music and "tmdb_show" in methods or "tmdb_id" in methods or "tmdb_movie" in methods: try: if "tmdb_show" in methods or "tmdb_id" in methods: data = meta[methods["tmdb_show" if "tmdb_show" in methods else "tmdb_id"]] @@ -421,134 +430,245 @@ class MetadataFile(DataFile): edits = {} add_edit("title", item, meta, methods, value=title) add_edit("sort_title", item, meta, methods, key="titleSort") - add_edit("originally_available", item, meta, methods, key="originallyAvailableAt", value=originally_available, var_type="date") - add_edit("critic_rating", item, meta, methods, value=rating, key="rating", var_type="float") - add_edit("audience_rating", item, meta, methods, key="audienceRating", var_type="float") - add_edit("user_rating", item, meta, methods, key="userRating", var_type="float") - add_edit("content_rating", item, meta, methods, key="contentRating") - add_edit("original_title", item, meta, methods, key="originalTitle", value=original_title) - add_edit("studio", item, meta, methods, value=studio) - add_edit("tagline", item, meta, methods, value=tagline) + if not self.library.is_music: + add_edit("originally_available", item, meta, methods, key="originallyAvailableAt", value=originally_available, var_type="date") + add_edit("critic_rating", item, meta, methods, value=rating, key="rating", var_type="float") + add_edit("audience_rating", item, meta, methods, key="audienceRating", var_type="float") + add_edit("user_rating", item, meta, methods, key="userRating", var_type="float") + add_edit("content_rating", item, meta, methods, key="contentRating") + add_edit("original_title", item, meta, methods, key="originalTitle", value=original_title) + add_edit("studio", item, meta, methods, value=studio) + add_edit("tagline", item, meta, methods, value=tagline) add_edit("summary", item, meta, methods, value=summary) - if self.library.edit_item(item, mapping_name, item_type, edits): + if self.library.edit_item(item, mapping_name, self.library.type, edits): updated = True advance_edits = {} - for advance_edit in ["episode_sorting", "keep_episodes", "delete_episodes", "season_display", "episode_ordering", "metadata_language", "use_original_title"]: - is_show = advance_edit in ["episode_sorting", "keep_episodes", "delete_episodes", "season_display", "episode_ordering"] + for advance_edit in advance_tags_to_edit[self.library.type]: is_new_agent = advance_edit in ["metadata_language", "use_original_title"] - add_advanced_edit(advance_edit, item, meta, methods, show_library=is_show, new_agent=is_new_agent) - if self.library.edit_item(item, mapping_name, item_type, advance_edits, advanced=True): + add_advanced_edit(advance_edit, item, meta, methods, new_agent=is_new_agent) + if self.library.edit_item(item, mapping_name, self.library.type, advance_edits, advanced=True): updated = True - for tag_edit in ["genre", "label", "collection", "country", "director", "producer", "writer"]: - is_movie = tag_edit in ["country", "director", "producer", "writer"] - has_extra = genres if tag_edit == "genre" else None - if edit_tags(tag_edit, item, meta, methods, movie_library=is_movie, extra=has_extra): + for tag_edit in tags_to_edit[self.library.type]: + if self.edit_tags(tag_edit, item, meta, methods, extra=genres if tag_edit == "genre" else None): updated = True - logger.info(f"{item_type}: {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") + logger.info(f"{self.library.type}: {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") - set_images(item, meta, methods) + self.set_images(item, meta, methods) if "seasons" in methods and self.library.is_show: - if meta[methods["seasons"]]: - for season_id in meta[methods["seasons"]]: + if not meta[methods["seasons"]]: + logger.error("Metadata Error: seasons attribute is blank") + elif not isinstance(meta[methods["seasons"]], dict): + logger.error("Metadata Error: seasons attribute must be a dictionary") + else: + for season_id, season_dict in meta[methods["seasons"]].items(): updated = False logger.info("") logger.info(f"Updating season {season_id} of {mapping_name}...") - if isinstance(season_id, int): - season = None - for s in item.seasons(): - if s.index == season_id: - season = s - break - if season is None: - logger.error(f"Metadata Error: Season: {season_id} not found") + try: + if isinstance(season_id, int): + season = item.season(seasson=season_id) else: - season_dict = meta[methods["seasons"]][season_id] - season_methods = {sm.lower(): sm for sm in season_dict} - - if "title" in season_methods and season_dict[season_methods["title"]]: - title = season_dict[season_methods["title"]] - else: - title = season.title - if "sub" in season_methods: - if season_dict[season_methods["sub"]] is None: - logger.error("Metadata Error: sub attribute is blank") - elif season_dict[season_methods["sub"]] is True and "(SUB)" not in title: - title = f"{title} (SUB)" - elif season_dict[season_methods["sub"]] is False and title.endswith(" (SUB)"): - title = title[:-6] - else: - logger.error("Metadata Error: sub attribute must be True or False") - - edits = {} - add_edit("title", season, season_dict, season_methods, value=title) - add_edit("summary", season, season_dict, season_methods) - if self.library.edit_item(season, season_id, "Season", edits): - updated = True - set_images(season, season_dict, season_methods) + season = item.season(title=season_id) + except NotFound: + logger.error(f"Metadata Error: Season: {season_id} not found") + continue + season_methods = {sm.lower(): sm for sm in season_dict} + + if "title" in season_methods and season_dict[season_methods["title"]]: + title = season_dict[season_methods["title"]] else: - logger.error(f"Metadata Error: Season: {season_id} invalid, it must be an integer") + title = season.title + if "sub" in season_methods: + if season_dict[season_methods["sub"]] is None: + logger.error("Metadata Error: sub attribute is blank") + elif season_dict[season_methods["sub"]] is True and "(SUB)" not in title: + title = f"{title} (SUB)" + elif season_dict[season_methods["sub"]] is False and title.endswith(" (SUB)"): + title = title[:-6] + else: + logger.error("Metadata Error: sub attribute must be True or False") + + edits = {} + add_edit("title", season, season_dict, season_methods, value=title) + add_edit("summary", season, season_dict, season_methods) + if self.library.edit_item(season, season_id, "Season", edits): + updated = True + self.set_images(season, season_dict, season_methods) logger.info(f"Season {season_id} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") - else: - logger.error("Metadata Error: seasons attribute is blank") - elif "seasons" in methods: - logger.error("Metadata Error: seasons attribute only works for show libraries") + + if "episodes" in season_methods and self.library.is_show: + if not season_dict[season_methods["episodes"]]: + logger.error("Metadata Error: episodes attribute is blank") + elif not isinstance(season_dict[season_methods["episodes"]], dict): + logger.error("Metadata Error: episodes attribute must be a dictionary") + else: + for episode_str, episode_dict in season_dict[season_methods["episodes"]].items(): + updated = False + logger.info("") + logger.info(f"Updating episode {episode_str} in {season_id} of {mapping_name}...") + try: + if isinstance(episode_str, int): + episode = season.episode(episode=episode_str) + else: + episode = season.episode(title=episode_str) + except NotFound: + logger.error(f"Metadata Error: Episode {episode_str} in Season {season_id} not found") + continue + episode_methods = {em.lower(): em for em in episode_dict} + + if "title" in episode_methods and episode_dict[episode_methods["title"]]: + title = episode_dict[episode_methods["title"]] + else: + title = episode.title + if "sub" in episode_dict: + if episode_dict[episode_methods["sub"]] is None: + logger.error("Metadata Error: sub attribute is blank") + elif episode_dict[episode_methods["sub"]] is True and "(SUB)" not in title: + title = f"{title} (SUB)" + elif episode_dict[episode_methods["sub"]] is False and title.endswith(" (SUB)"): + title = title[:-6] + else: + logger.error("Metadata Error: sub attribute must be True or False") + edits = {} + add_edit("title", episode, episode_dict, episode_methods, value=title) + add_edit("sort_title", episode, episode_dict, episode_methods, key="titleSort") + add_edit("rating", episode, episode_dict, episode_methods, var_type="float") + add_edit("originally_available", episode, episode_dict, episode_methods, key="originallyAvailableAt", var_type="date") + add_edit("summary", episode, episode_dict, episode_methods) + if self.library.edit_item(episode, f"{episode_str} in Season: {season_id}", "Episode", edits): + updated = True + for tag_edit in ["director", "writer"]: + if self.edit_tags(tag_edit, episode, episode_dict, episode_methods): + updated = True + self.set_images(episode, episode_dict, episode_methods) + logger.info(f"Episode {episode_str} in Season {season_id} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") if "episodes" in methods and self.library.is_show: - if meta[methods["episodes"]]: - for episode_str in meta[methods["episodes"]]: + if not meta[methods["episodes"]]: + logger.error("Metadata Error: episodes attribute is blank") + elif not isinstance(meta[methods["episodes"]], dict): + logger.error("Metadata Error: episodes attribute must be a dictionary") + else: + for episode_str, episode_dict in meta[methods["episodes"]].items(): updated = False logger.info("") match = re.search("[Ss]\\d+[Ee]\\d+", episode_str) - if match: - output = match.group(0)[1:].split("E" if "E" in match.group(0) else "e") - season_id = int(output[0]) - episode_id = int(output[1]) - logger.info(f"Updating episode S{season_id}E{episode_id} of {mapping_name}...") - try: - episode = item.episode(season=season_id, episode=episode_id) - except NotFound: - logger.error(f"Metadata Error: episode {episode_id} of season {season_id} not found") - else: - episode_dict = meta[methods["episodes"]][episode_str] - episode_methods = {em.lower(): em for em in episode_dict} - - if "title" in episode_methods and episode_dict[episode_methods["title"]]: - title = episode_dict[episode_methods["title"]] - else: - title = episode.title - if "sub" in episode_dict: - if episode_dict[episode_methods["sub"]] is None: - logger.error("Metadata Error: sub attribute is blank") - elif episode_dict[episode_methods["sub"]] is True and "(SUB)" not in title: - title = f"{title} (SUB)" - elif episode_dict[episode_methods["sub"]] is False and title.endswith(" (SUB)"): - title = title[:-6] - else: - logger.error("Metadata Error: sub attribute must be True or False") - edits = {} - add_edit("title", episode, episode_dict, episode_methods, value=title) - add_edit("sort_title", episode, episode_dict, episode_methods, key="titleSort") - add_edit("rating", episode, episode_dict, episode_methods, var_type="float") - add_edit("originally_available", episode, episode_dict, episode_methods, key="originallyAvailableAt", var_type="date") - add_edit("summary", episode, episode_dict, episode_methods) - if self.library.edit_item(episode, f"{season_id} Episode: {episode_id}", "Season", edits): - updated = True - if edit_tags("director", episode, episode_dict, episode_methods): - updated = True - if edit_tags("writer", episode, episode_dict, episode_methods): - updated = True - set_images(episode, episode_dict, episode_methods) - logger.info(f"Episode S{season_id}E{episode_id} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") - else: + if not match: logger.error(f"Metadata Error: episode {episode_str} invalid must have S##E## format") + continue + output = match.group(0)[1:].split("E" if "E" in match.group(0) else "e") + season_id = int(output[0]) + episode_id = int(output[1]) + logger.info(f"Updating episode S{season_id}E{episode_id} of {mapping_name}...") + try: + episode = item.episode(season=season_id, episode=episode_id) + except NotFound: + logger.error(f"Metadata Error: episode {episode_id} of season {season_id} not found") + continue + episode_methods = {em.lower(): em for em in episode_dict} + + if "title" in episode_methods and episode_dict[episode_methods["title"]]: + title = episode_dict[episode_methods["title"]] + else: + title = episode.title + if "sub" in episode_dict: + if episode_dict[episode_methods["sub"]] is None: + logger.error("Metadata Error: sub attribute is blank") + elif episode_dict[episode_methods["sub"]] is True and "(SUB)" not in title: + title = f"{title} (SUB)" + elif episode_dict[episode_methods["sub"]] is False and title.endswith(" (SUB)"): + title = title[:-6] + else: + logger.error("Metadata Error: sub attribute must be True or False") + edits = {} + add_edit("title", episode, episode_dict, episode_methods, value=title) + add_edit("sort_title", episode, episode_dict, episode_methods, key="titleSort") + add_edit("rating", episode, episode_dict, episode_methods, var_type="float") + add_edit("originally_available", episode, episode_dict, episode_methods, key="originallyAvailableAt", var_type="date") + add_edit("summary", episode, episode_dict, episode_methods) + if self.library.edit_item(episode, f"{season_id} Episode: {episode_id}", "Season", edits): + updated = True + for tag_edit in ["director", "writer"]: + if self.edit_tags(tag_edit, episode, episode_dict, episode_methods): + updated = True + self.set_images(episode, episode_dict, episode_methods) + logger.info(f"Episode S{season_id}E{episode_id} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") + + if "albums" in methods and self.library.is_music: + if not meta[methods["albums"]]: + logger.error("Metadata Error: albums attribute is blank") + elif not isinstance(meta[methods["albums"]], dict): + logger.error("Metadata Error: albums attribute must be a dictionary") else: - logger.error("Metadata Error: episodes attribute is blank") - elif "episodes" in methods: - logger.error("Metadata Error: episodes attribute only works for show libraries") + for album_name, album_dict in meta[methods["albums"]].items(): + updated = False + logger.info("") + logger.info(f"Updating album {album_name} of {mapping_name}...") + try: + album = item.album(album_name) + except NotFound: + logger.error(f"Metadata Error: Album: {album_name} not found") + continue + album_methods = {am.lower(): am for am in album_dict} + + if "album" in album_methods and album_dict[album_methods["album"]]: + title = album_dict[album_methods["album"]] + else: + title = album.title + edits = {} + add_edit("album", album, album_dict, album_methods, key="title", value=title) + add_edit("sort_album", album, album_dict, album_methods, key="titleSort") + add_edit("rating", album, album_dict, album_methods, var_type="float") + add_edit("originally_available", album, album_dict, album_methods, key="originallyAvailableAt", var_type="date") + add_edit("record_label", album, album_dict, album_methods, key="studio") + add_edit("review", album, album_dict, album_methods, key="summary") + if self.library.edit_item(album, title, "Album", edits): + updated = True + for tag_edit in ["genre", "style", "mood", "collection", "label"]: + if self.edit_tags(tag_edit, album, album_dict, album_methods): + updated = True + self.set_images(album, album_dict, album_methods) + logger.info(f"Album: {title} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") + + if "tracks" in album_methods: + if not album_dict[album_methods["tracks"]]: + logger.error("Metadata Error: tracks attribute is blank") + elif not isinstance(album_dict[album_methods["tracks"]], dict): + logger.error("Metadata Error: tracks attribute must be a dictionary") + else: + for track_num, track_dict in album_dict[album_methods["tracks"]].items(): + updated = False + logger.info("") + logger.info(f"Updating track {track_num} on {album_name} of {mapping_name}...") + try: + if isinstance(track_num, int): + track = album.track(track=track_num) + else: + track = album.track(title=track_num) + except NotFound: + logger.error(f"Metadata Error: Track: {track_num} not found") + continue + track_methods = {tm.lower(): tm for tm in track_dict} + + if "title" in track_methods and track_dict[track_methods["title"]]: + title = track_dict[track_methods["title"]] + else: + title = track.title + edits = {} + add_edit("title", track, track_dict, track_methods, value=title) + add_edit("rating", track, track_dict, track_methods, var_type="float") + add_edit("track", track, track_dict, track_methods, key="index", var_type="int") + add_edit("disc", track, track_dict, track_methods, key="parentIndex", var_type="int") + add_edit("artist", track, track_dict, track_methods, key="originalTitle") + if self.library.edit_item(album, title, "Track", edits): + updated = True + if self.edit_tags("mood", track, track_dict, track_methods): + updated = True + logger.info(f"Track: {track_num} on Album: {title} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") class PlaylistFile(DataFile): diff --git a/modules/plex.py b/modules/plex.py index 0de6700e..188e1b2d 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -41,7 +41,47 @@ search_translation = { "audio_language": "audioLanguage", "progress": "inProgress", "episode_progress": "episode.inProgress", - "unplayed_episodes": "show.unwatchedLeaves" + "unplayed_episodes": "show.unwatchedLeaves", + "artist_title": "artist.title", + "artist_user_rating": "artist.userRating", + "artist_genre": "artist.genre", + "artist_collection": "artist.collection", + "artist_country": "artist.country", + "artist_mood": "artist.mood", + "artist_style": "artist.style", + "artist_added": "artist.addedAt", + "artist_last_played": "artist.lastViewedAt", + "artist_unmatched": "artist.unmatched", + "album_title": "album.title", + "album_year": "album.year", + "album_decade": "album.decade", + "album_genre": "album.genre", + "album_plays": "album.viewCount", + "album_last_played": "album.lastViewedAt", + "album_user_rating": "album.userRating", + "album_critic_rating": "album.rating", + "album_record_label": "album.studio", + "album_mood": "album.mood", + "album_style": "album.style", + "album_format": "album.format", + "album_type": "album.subformat", + "album_collection": "album.collection", + "album_added": "album.addedAt", + "album_released": "album.originallyAvailableAt", + "album_unmatched": "album.unmatched", + "album_source": "album.source", + "album_label": "album.label", + "track_mood": "track.mood", + "track_title": "track.title", + "track_plays": "track.viewCount", + "track_last_played": "track.lastViewedAt", + "track_skips": "track.skipCount", + "track_last_skipped": "track.lastSkippedAt", + "track_user_rating": "track.userRating", + "track_last_rated": "track.lastRatedAt", + "track_added": "track.addedAt", + "track_trash": "track.trash", + "track_source": "track.source" } show_translation = { "title": "show.title", @@ -71,6 +111,7 @@ modifier_translation = { ".before": "%3C%3C", ".after": "%3E%3E", ".begins": "%3C", ".ends": "%3E" } episode_sorting_options = {"default": "-1", "oldest": "0", "newest": "1"} +album_sorting_options = {"default": -1, "newest": 0, "oldest": 1, "name": 2} keep_episodes_options = {"all": 0, "5_latest": 5, "3_latest": 3, "latest": 1, "past_3": -3, "past_7": -7, "past_30": -30} delete_episodes_options = {"never": 0, "day": 1, "week": 7, "refresh": 100} season_display_options = {"default": -1, "show": 0, "hide": 1} @@ -83,10 +124,13 @@ metadata_language_options = {lang.lower(): lang for lang in plex_languages} metadata_language_options["default"] = None use_original_title_options = {"default": -1, "no": 0, "yes": 1} collection_order_options = ["release", "alpha", "custom"] -collection_level_options = ["episode", "season"] +collection_level_show_options = ["episode", "season"] +collection_level_music_options = ["album", "track"] +collection_level_options = collection_level_show_options + collection_level_music_options collection_mode_keys = {-1: "default", 0: "hide", 1: "hideItems", 2: "showItems"} collection_order_keys = {0: "release", 1: "alpha", 2: "custom"} item_advance_keys = { + "item_album_sorting": ("albumSort", album_sorting_options), "item_episode_sorting": ("episodeSort", episode_sorting_options), "item_keep_episodes": ("autoDeletionItemPolicyUnwatchedLibrary", keep_episodes_options), "item_delete_episodes": ("autoDeletionItemPolicyWatchedLibrary", delete_episodes_options), @@ -96,6 +140,48 @@ item_advance_keys = { "item_use_original_title": ("useOriginalTitle", use_original_title_options) } new_plex_agents = ["tv.plex.agents.movie", "tv.plex.agents.series"] +music_searches = [ + "artist_title", "artist_title.not", "artist_title.is", "artist_title.isnot", "artist_title.begins", "artist_title.ends", + "artist_user_rating.gt", "artist_user_rating.gte", "artist_user_rating.lt", "artist_user_rating.lte", + "artist_genre", "artist_genre.not", + "artist_collection", "artist_collection.not", + "artist_country", "artist_country.not", + "artist_mood", "artist_mood.not", + "artist_style", "artist_style.not", + "artist_added", "artist_added.not", "artist_added.before", "artist_added.after", + "artist_last_played", "artist_last_played.not", "artist_last_played.before", "artist_last_played.after", + "artist_unmatched", + "album_title", "album_title.not", "album_title.is", "album_title.isnot", "album_title.begins", "album_title.ends", + "album_year.gt", "album_year.gte", "album_year.lt", "album_year.lte", + "album_decade", + "album_genre", "album_genre.not", + "album_plays.gt", "album_plays.gte", "album_plays.lt", "album_plays.lte", + "album_last_played", "album_last_played.not", "album_last_played.before", "album_last_played.after", + "album_user_rating.gt", "album_user_rating.gte", "album_user_rating.lt", "album_user_rating.lte", + "album_critic_rating.gt", "album_critic_rating.gte", "album_critic_rating.lt", "album_critic_rating.lte", + "album_record_label", "album_record_label.not", "album_record_label.is", "album_record_label.isnot", "album_record_label.begins", "album_record_label.ends", + "album_mood", "album_mood.not", + "album_style", "album_style.not", + "album_format", "album_format.not", + "album_type", "album_type.not", + "album_collection", "album_collection.not", + "album_added", "album_added.not", "album_added.before", "album_added.after", + "album_released", "album_released.not", "album_released.before", "album_released.after", + "album_unmatched", + "album_source", "album_source.not", + "album_label", "album_label.not", + "track_mood", "track_mood.not", + "track_title", "track_title.not", "track_title.is", "track_title.isnot", "track_title.begins", "track_title.ends", + "track_plays.gt", "track_plays.gte", "track_plays.lt", "track_plays.lte", + "track_last_played", "track_last_played.not", "track_last_played.before", "track_last_played.after", + "track_skips.gt", "track_skips.gte", "track_skips.lt", "track_skips.lte", + "track_last_skipped", "track_last_skipped.not", "track_last_skipped.before", "track_last_skipped.after", + "track_user_rating.gt", "track_user_rating.gte", "track_user_rating.lt", "track_user_rating.lte", + "track_last_rated", "track_last_rated.not", "track_last_rated.before", "track_last_rated.after", + "track_added", "track_added.not", "track_added.before", "track_added.after", + "track_trash", + "track_source", "track_source.not" +] searches = [ "title", "title.not", "title.is", "title.isnot", "title.begins", "title.ends", "studio", "studio.not", "studio.is", "studio.isnot", "studio.begins", "studio.ends", @@ -129,7 +215,7 @@ searches = [ "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" -] +] + music_searches and_searches = [ "title.and", "studio.and", "actor.and", "audio_language.and", "collection.and", "content_rating.and", "country.and", "director.and", "genre.and", "label.and", @@ -157,18 +243,29 @@ show_only_searches = [ "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"] +string_attributes = ["title", "studio", "episode_title", "artist_title", "album_title", "album_record_label", "track_title"] +float_attributes = [ + "user_rating", "episode_user_rating", "critic_rating", "audience_rating", + "artist_user_rating", "album_user_rating", "album_critic_rating", "track_user_rating" +] boolean_attributes = [ - "hdr", "unmatched", "duplicate", "unplayed", "progress", "trash", - "unplayed_episodes", "episode_unplayed", "episode_duplicate", "episode_progress", "episode_unmatched", + "hdr", "unmatched", "duplicate", "unplayed", "progress", "trash", "unplayed_episodes", "episode_unplayed", + "episode_duplicate", "episode_progress", "episode_unmatched", "artist_unmatched", "album_unmatched", "track_trash" ] tmdb_attributes = ["actor", "director", "producer", "writer"] -date_attributes = ["added", "episode_added", "release", "episode_air_date", "last_played", "episode_last_played", "first_episode_aired", "last_episode_aired"] -number_attributes = ["plays", "episode_plays", "duration", "tmdb_vote_count"] + date_attributes +date_attributes = [ + "added", "episode_added", "release", "episode_air_date", "last_played", "episode_last_played", + "first_episode_aired", "last_episode_aired", "artist_added", "artist_last_played", "album_last_played", + "album_added", "album_released", "track_last_played", "track_last_skipped", "track_last_rated", "track_added" +] +year_attributes = ["decade", "year", "episode_year", "album_year", "album_decade"] +number_attributes = ["plays", "episode_plays", "duration", "tmdb_vote_count", "album_plays", "track_plays", "track_skips"] + year_attributes search_display = {"added": "Date Added", "release": "Release Date", "hdr": "HDR", "progress": "In Progress", "episode_progress": "Episode In Progress"} -tags = [ - "actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", - "network", "producer", "resolution", "studio", "subtitle_language", "writer" +tag_attributes = [ + "actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "network", + "producer", "resolution", "studio", "subtitle_language", "writer" "artist_genre", "artist_collection", + "artist_country", "artist_mood", "artist_style", "album_genre", "album_mood", "album_style", "album_format", + "album_type", "album_collection", "album_source", "album_label", "track_mood", "track_source" ] movie_sorts = { "title.asc": "titleSort", "title.desc": "titleSort%3Adesc", @@ -180,8 +277,12 @@ movie_sorts = { "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", "content_rating.asc": "contentRating", "content_rating.desc": "contentRating%3Adesc", "duration.asc": "duration", "duration.desc": "duration%3Adesc", + "progress.asc": "viewOffset", "progress.desc": "viewOffset%3Adesc", "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "viewed.asc": "lastViewedAt", "viewed.desc": "lastViewedAt%3Adesc", + "resolution.asc": "mediaHeight", "resolution.desc": "mediaHeight%3Adesc", + "bitrate.asc": "mediaBitrate", "bitrate.desc": "mediaBitrate%3Adesc", "random": "random" } show_sorts = { @@ -193,8 +294,10 @@ show_sorts = { "audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc", "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", "content_rating.asc": "contentRating", "content_rating.desc": "contentRating%3Adesc", - "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "unplayed.asc": "unviewedLeafCount", "unplayed.desc": "unviewedLeafCount%3Adesc", "episode_added.asc": "episode.addedAt", "episode_added.desc": "episode.addedAt%3Adesc", + "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "viewed.asc": "lastViewedAt", "viewed.desc": "lastViewedAt%3Adesc", "random": "random" } season_sorts = { @@ -215,11 +318,61 @@ episode_sorts = { "audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc", "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", "duration.asc": "duration", "duration.desc": "duration%3Adesc", + "progress.asc": "viewOffset", "progress.desc": "viewOffset%3Adesc", "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "viewed.asc": "lastViewedAt", "viewed.desc": "lastViewedAt%3Adesc", + "resolution.asc": "mediaHeight", "resolution.desc": "mediaHeight%3Adesc", + "bitrate.asc": "mediaBitrate", "bitrate.desc": "mediaBitrate%3Adesc", + "random": "random" +} +artist_sorts = { + "title.asc": "titleSort", "title.desc": "titleSort%3Adesc", + "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", + "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "played.asc": "lastViewedAt", "played.desc": "lastViewedAt%3Adesc", + "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", "random": "random" } -sort_types = {"movies": (1, movie_sorts), "shows": (2, show_sorts), "seasons": (3, season_sorts), "episodes": (4, episode_sorts)} +album_sorts = { + "title.asc": "titleSort", "title.desc": "titleSort%3Adesc", + "album_artist.asc": "artist.titleSort%2Calbum.titleSort%2Calbum.index%2Calbum.id%2Calbum.originallyAvailableAt", + "album_artist.desc": "artist.titleSort%3Adesc%2Calbum.titleSort%2Calbum.index%2Calbum.id%2Calbum.originallyAvailableAt", + "originally_available.asc": "originallyAvailableAt", "originally_available.desc": "originallyAvailableAt%3Adesc", + "release.asc": "originallyAvailableAt", "release.desc": "originallyAvailableAt%3Adesc", + "critic_rating.asc": "rating", "critic_rating.desc": "rating%3Adesc", + "audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc", + "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", + "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "played.asc": "lastViewedAt", "played.desc": "lastViewedAt%3Adesc", + "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", + "random": "random" +} +track_sorts = { + "title.asc": "titleSort", "title.desc": "titleSort%3Adesc", + "album_artist.asc": "artist.titleSort%2Calbum.titleSort%2Calbum.year%2Ctrack.absoluteIndex%2Ctrack.index%2Ctrack.titleSort%2Ctrack.id", + "album_artist.desc": "artist.titleSort%3Adesc%2Calbum.titleSort%2Calbum.year%2Ctrack.absoluteIndex%2Ctrack.index%2Ctrack.titleSort%2Ctrack.id", + "artist.asc": "originalTitle", "artist.desc": "originalTitle%3Adesc", + "album.asc": "album.titleSort", "album.desc": "album.titleSort%3Adesc", + "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", + "duration.asc": "duration", "duration.desc": "duration%3Adesc", + "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", + "added.asc": "addedAt", "added.desc": "addedAt%3Adesc", + "played.asc": "lastViewedAt", "played.desc": "lastViewedAt%3Adesc", + "rated.asc": "lastRatedAt", "rated.desc": "lastRatedAt%3Adesc", + "popularity.asc": "ratingCount", "popularity.desc": "ratingCount%3Adesc", + "bitrate.asc": "mediaBitrate", "bitrate.desc": "mediaBitrate%3Adesc", + "random": "random" +} +sort_types = { + "movies": (1, movie_sorts), + "shows": (2, show_sorts), + "seasons": (3, season_sorts), + "episodes": (4, episode_sorts), + "artists": (8, artist_sorts), + "albums": (9, album_sorts), + "tracks": (10, track_sorts) +} class Plex(Library): def __init__(self, config, params): @@ -247,7 +400,7 @@ class Plex(Library): break if not self.Plex: raise Failed(f"Plex Error: Plex Library {params['name']} not found. Options: {library_names}") - if self.Plex.type in ["movie", "show"]: + if self.Plex.type in ["movie", "show", "artist"]: self.type = self.Plex.type.capitalize() else: raise Failed(f"Plex Error: Plex Library must be a Movies or TV Shows library") @@ -256,6 +409,7 @@ class Plex(Library): self.agent = self.Plex.agent self.is_movie = self.type == "Movie" self.is_show = self.type == "Show" + self.is_music = self.type == "Artist" self.is_other = self.agent == "com.plexapp.agents.none" if self.is_other: self.type = "Video" @@ -395,6 +549,8 @@ class Plex(Library): names.append(choice.title) if choice.key not in names: names.append(choice.key) + choices[choice.title] = choice.title if title else choice.key + choices[choice.key] = choice.title if title else choice.key choices[choice.title.lower()] = choice.title if title else choice.key choices[choice.key.lower()] = choice.title if title else choice.key return choices, names @@ -636,6 +792,8 @@ class Plex(Library): def edit_tags(self, attr, obj, add_tags=None, remove_tags=None, sync_tags=None): display = "" key = builder.filter_translation[attr] if attr in builder.filter_translation else attr + attr_display = attr.replace("_", " ").title() + attr_call = attr_display.replace(" ", "") if add_tags or remove_tags or sync_tags is not None: _add_tags = add_tags if add_tags else [] _remove_tags = [t.lower() for t in remove_tags] if remove_tags else [] @@ -648,13 +806,13 @@ class Plex(Library): _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 is not None and t not in _sync_tags) or t in _remove_tags] if _add: - self.query_data(getattr(obj, f"add{attr.capitalize()}"), _add) + self.query_data(getattr(obj, f"add{attr_call}"), _add) display += f"+{', +'.join(_add)}" if _remove: - self.query_data(getattr(obj, f"remove{attr.capitalize()}"), _remove) + self.query_data(getattr(obj, f"remove{attr_call}"), _remove) display += f"-{', -'.join(_remove)}" if len(display) > 0: - logger.info(f"{obj.title[:25]:<25} | {attr.capitalize()} | {display}") + logger.info(f"{obj.title[:25]:<25} | {attr_display} | {display}") return len(display) > 0 def find_assets(self, item, name=None, upload=True, overlay=None, folders=None, create=None): diff --git a/modules/util.py b/modules/util.py index 8d8ff3b9..219341dd 100644 --- a/modules/util.py +++ b/modules/util.py @@ -2,6 +2,7 @@ import glob, logging, os, re, signal, sys, time, traceback from datetime import datetime, timedelta from logging.handlers import RotatingFileHandler from pathvalidate import is_valid_filename, sanitize_filename +from plexapi.audio import Artist, Album, Track from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.video import Season, Episode, Movie @@ -261,6 +262,10 @@ def item_title(item): return f"{text}: {item.parentTitle}: {item.title}" elif isinstance(item, Movie) and item.year: return f"{item.title} ({item.year})" + elif isinstance(item, Album): + return f"{item.parentTitle}: {item.title}" + elif isinstance(item, Track): + return f"{item.grandparentTitle}: {item.parentTitle}: {item.title}" else: return item.title diff --git a/plex_meta_manager.py b/plex_meta_manager.py index 7f8c0b4d..32274dd2 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -246,7 +246,7 @@ def update_libraries(config): logger.debug(f"Optimize: {library.optimize}") logger.debug(f"Timeout: {library.timeout}") - if not library.is_other: + if not library.is_other and not library.is_music: logger.info("") util.separator(f"Mapping {library.name} Library", space=False, border=False) logger.info("") @@ -776,7 +776,6 @@ def run_collection(config, library, metadata, requested_collections): if builder.run_again and (len(builder.run_again_movies) > 0 or len(builder.run_again_shows) > 0): library.run_again.append(builder) - except NotScheduled as e: util.print_multiline(e, info=True) except Failed as e: