#189 adds music library support and better filters

pull/556/head
meisnate12 3 years ago
parent 6788fa8ae4
commit e4d1851f47

@ -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 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 modules.util import Failed, ImageData, NotScheduled
from PIL import Image from PIL import Image
from plexapi.audio import Artist, Album, Track
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.video import Movie, Show, Season, Episode from plexapi.video import Movie, Show, Season, Episode
from urllib.parse import quote from urllib.parse import quote
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
string_filters = ["title", "episode_title", "studio"]
advance_new_agent = ["item_metadata_language", "item_use_original_title"] 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"] advance_show = ["item_episode_sorting", "item_keep_episodes", "item_delete_episodes", "item_season_display", "item_episode_sorting"]
method_alias = { method_alias = {
@ -46,6 +46,7 @@ method_alias = {
"collection_changes_webhooks": "changes_webhooks" "collection_changes_webhooks": "changes_webhooks"
} }
filter_translation = { filter_translation = {
"record_label": "studio",
"actor": "actors", "actor": "actors",
"audience_rating": "audienceRating", "audience_rating": "audienceRating",
"collection": "collections", "collection": "collections",
@ -73,6 +74,7 @@ movie_only_builders = [
"tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing", "tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing",
"tvdb_movie", "tvdb_movie_details", "trakt_boxoffice" "tvdb_movie", "tvdb_movie_details", "trakt_boxoffice"
] ]
music_only_builders = ["item_album_sorting"]
summary_details = [ summary_details = [
"summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary", "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary",
"tvdb_description", "trakt_description", "letterboxd_description", "icheckmovies_description" "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" "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", 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"] + \ collectionless_details = ["collection_order", "plex_collectionless", "label", "label_sync_mode", "test"] + \
poster_details + background_details + summary_details + string_details 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"] 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_add", "sonarr_add_existing", "sonarr_folder", "sonarr_monitor", "sonarr_language", "sonarr_series",
"sonarr_quality", "sonarr_season", "sonarr_search", "sonarr_cutoff_search", "sonarr_tag" "sonarr_quality", "sonarr_season", "sonarr_search", "sonarr_cutoff_search", "sonarr_tag"
] ]
parts_collection_valid = [ album_details = ["item_label", "item_album_sorting"]
"plex_search", "trakt_list", "trakt_list_details", "collection_mode", "label", "visible_library", "changes_webhooks" filters_by_type = {
"visible_home", "visible_shared", "show_missing", "save_missing", "missing_only_released", "server_preroll", "movie_show_season_episode_artist_album_track": ["title", "collection", "has_collection", "added", "last_played", "user_rating", "plays"],
"item_lock_background", "item_lock_poster", "item_lock_title", "item_refresh", "imdb_list" "movie_show_season_episode_album_track": ["year"],
] + summary_details + poster_details + background_details + string_details "movie_show_episode_artist_track": ["filepath"],
all_filters = [ "movie_show_episode_album": ["release", "critic_rating", "history"],
"actor", "actor.not", "movie_show_episode_track": ["duration"],
"audio_language", "audio_language.not", "movie_show_artist_album": ["genre"],
"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", "movie_show_episode": ["actor", "content_rating", "audience_rating"],
"collection", "collection.not", "movie_show_album": ["label"],
"content_rating", "content_rating.not", "movie_episode_track": ["audio_track_title"],
"country", "country.not", "movie_show": ["studio", "original_language", "has_overlay", "tmdb_vote_count", "tmdb_year"],
"director", "director.not", "movie_episode": ["director", "producer", "writer", "resolution", "audio_language", "subtitle_language"],
"filepath", "filepath.not", "filepath.is", "filepath.isnot", "filepath.begins", "filepath.ends", "filepath.regex", "movie_artist": ["country"],
"genre", "genre.not", "show": ["network", "first_episode_aired", "last_episode_aired"],
"label", "label.not", "album": ["record_label"]
"producer", "producer.not", }
"release", "release.not", "release.before", "release.after", "release.regex", "history", filters = {
"added", "added.not", "added.before", "added.after", "added.regex", "movie": [item for check, sub in filters_by_type.items() for item in sub if "movie" in check],
"last_played", "last_played.not", "last_played.before", "last_played.after", "last_played.regex", "show": [item for check, sub in filters_by_type.items() for item in sub if "show" in check],
"first_episode_aired", "first_episode_aired.not", "first_episode_aired.before", "first_episode_aired.after", "first_episode_aired.regex", "season": [item for check, sub in filters_by_type.items() for item in sub if "season" in check],
"last_episode_aired", "last_episode_aired.not", "last_episode_aired.before", "last_episode_aired.after", "last_episode_aired.regex", "episode": [item for check, sub in filters_by_type.items() for item in sub if "episode" in check],
"title", "title.not", "title.is", "title.isnot", "title.begins", "title.ends", "title.regex", "artist": [item for check, sub in filters_by_type.items() for item in sub if "artist" in check],
"plays.gt", "plays.gte", "plays.lt", "plays.lte", "album": [item for check, sub in filters_by_type.items() for item in sub if "album" in check],
"tmdb_vote_count.gt", "tmdb_vote_count.gte", "tmdb_vote_count.lt", "tmdb_vote_count.lte", "track": [item for check, sub in filters_by_type.items() for item in sub if "track" in check]
"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"
]
tmdb_filters = ["original_language", "tmdb_vote_count", "tmdb_year", "first_episode_aired", "last_episode_aired"] tmdb_filters = ["original_language", "tmdb_vote_count", "tmdb_year", "first_episode_aired", "last_episode_aired"]
boolean_filters = ["has_collection", "has_overlay"] string_filters = ["title", "studio", "record_label", "filepath", "audio_track_title"]
movie_only_filters = [ string_modifiers = ["", ".not", ".is", ".isnot", ".begins", ".ends", ".regex"]
"audio_language", "audio_language.not", tag_filters = [
"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", "actor", "collection", "content_rating", "country", "director", "network", "genre", "label", "producer", "year",
"country", "country.not", "writer", "original_language", "resolution", "audio_language", "subtitle_language"
"director", "director.not",
"duration.gt", "duration.gte", "duration.lt", "duration.lte",
"subtitle_language", "subtitle_language.not",
"resolution", "resolution.not",
"writer", "writer.not"
] ]
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_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 smart_url_invalid = ["filters", "run_again", "sync_mode", "show_filtered", "show_missing", "save_missing", "smart_label"] + radarr_details + sonarr_details
custom_sort_builders = [ 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_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" "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 = [ playlist_attributes = [
"filters", "name_mapping", "show_filtered", "show_missing", "save_missing", "filters", "name_mapping", "show_filtered", "show_missing", "save_missing",
"missing_only_released", "only_filter_missing", "delete_below_minimum", "ignore_ids", "ignore_imdb_ids", "missing_only_released", "only_filter_missing", "delete_below_minimum", "ignore_ids", "ignore_imdb_ids",
"server_preroll", "changes_webhooks", "collection_minimum", "server_preroll", "changes_webhooks", "collection_minimum",
] + custom_sort_builders + summary_details + poster_details + radarr_details + sonarr_details ] + 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: class CollectionBuilder:
def __init__(self, config, library, metadata, name, no_missing, data, playlist=False, valid_users=None): 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("")
logger.debug("Validating Method: collection_level") logger.debug("Validating Method: collection_level")
if self.library.is_movie: 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: elif self.data[methods["collection_level"]] is None:
raise Failed(f"{self.Type} Error: collection_level attribute is blank") raise Failed(f"{self.Type} Error: collection_level attribute is blank")
else: else:
logger.debug(f"Value: {self.data[methods['collection_level']]}") 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() self.collection_level = self.data[methods["collection_level"]].lower()
else: 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)") if self.library.is_show:
self.parts_collection = self.collection_level in ["season", "episode"] 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: if "tmdb_person" in methods:
logger.debug("") logger.debug("")
@ -380,7 +392,7 @@ class CollectionBuilder:
self.smart_sort = "random" self.smart_sort = "random"
self.smart_label_collection = False 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("")
logger.debug("Validating Method: smart_label") logger.debug("Validating Method: smart_label")
self.smart_label_collection = True self.smart_label_collection = True
@ -441,44 +453,88 @@ class CollectionBuilder:
logger.debug(f"Validating Method: {method_key}") logger.debug(f"Validating Method: {method_key}")
logger.debug(f"Value: {method_data}") logger.debug(f"Value: {method_data}")
try: 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") if method_data is None and method_name in all_builders + plex.searches:
elif method_data is None and method_final not in none_details: logger.warning(f"Collection Warning: {method_final} attribute is blank") raise Failed(f"{self.Type} Error: {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 method_data is None and method_final not in none_details:
elif not self.config.Trakt and "trakt" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Trakt to be configured") logger.warning(f"Collection Warning: {method_final} attribute is blank")
elif not self.library.Radarr and "radarr" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Radarr to be configured") elif self.playlist and method_name not in playlist_attributes:
elif not self.library.Sonarr and "sonarr" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Sonarr to be configured") raise Failed(f"{self.Type} Error: {method_final} attribute not allowed when using playlists")
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.Trakt and "trakt" in method_name:
elif not self.config.MyAnimeList and "mal" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires MyAnimeList to be configured") raise Failed(f"{self.Type} Error: {method_final} requires Trakt 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 not self.library.Radarr and "radarr" in method_name:
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") raise Failed(f"{self.Type} Error: {method_final} requires Radarr to be configured")
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 not self.library.Sonarr and "sonarr" in method_name:
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") raise Failed(f"{self.Type} Error: {method_final} requires Sonarr to be configured")
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 not self.library.Tautulli and "tautulli" in method_name:
elif self.smart and method_name in smart_invalid: raise Failed(f"{self.Type} Error: {method_final} attribute only works with normal collections") raise Failed(f"{self.Type} Error: {method_final} requires Tautulli to be configured")
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 not self.config.MyAnimeList and "mal" in method_name:
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") raise Failed(f"{self.Type} Error: {method_final} requires MyAnimeList to be configured")
elif method_name in summary_details: self._summary(method_name, method_data) elif self.library.is_movie and method_name in show_only_builders:
elif method_name in poster_details: self._poster(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for show libraries")
elif method_name in background_details: self._background(method_name, method_data) elif self.library.is_show and method_name in movie_only_builders:
elif method_name in details: self._details(method_name, method_data, method_final, methods) raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for movie libraries")
elif method_name in item_details: self._item_details(method_name, method_data, method_mod, method_final, methods) elif self.library.is_show and method_name in plex.movie_only_searches:
elif method_name in radarr_details: self._radarr(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} plex search only allowed for movie libraries")
elif method_name in sonarr_details: self._sonarr(method_name, method_data) elif self.library.is_movie and method_name in plex.show_only_searches:
elif method_name in anidb.builders: self._anidb(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} plex search only allowed for show libraries")
elif method_name in anilist.builders: self._anilist(method_name, method_data) elif self.library.is_music and method_name not in music_attributes:
elif method_name in flixpatrol.builders: self._flixpatrol(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute not allowed for music libraries")
elif method_name in icheckmovies.builders: self._icheckmovies(method_name, method_data) elif self.library.is_music and method_name in album_details and self.collection_level != "album":
elif method_name in letterboxd.builders: self._letterboxd(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for album collections")
elif method_name in imdb.builders: self._imdb(method_name, method_data) elif not self.library.is_music and method_name in music_only_builders:
elif method_name in mal.builders: self._mal(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute only allowed for music libraries")
elif method_name in plex.builders or method_final in plex.searches: self._plex(method_name, method_data) elif self.parts_collection and method_name not in parts_collection_valid:
elif method_name in stevenlu.builders: self._stevenlu(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute not allowed with Collection Level: {self.collection_level.capitalize()}")
elif method_name in tautulli.builders: self._tautulli(method_name, method_data) elif self.smart and method_name in smart_invalid:
elif method_name in tmdb.builders: self._tmdb(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute only allowed with normal collections")
elif method_name in trakt.builders: self._trakt(method_name, method_data) elif self.collectionless and method_name not in collectionless_details:
elif method_name in tvdb.builders: self._tvdb(method_name, method_data) raise Failed(f"{self.Type} Error: {method_final} attribute not allowed for Collectionless collection")
elif method_name == "filters": self._filters(method_name, method_data) elif self.smart_url and method_name in all_builders + smart_url_invalid:
else: raise Failed(f"{self.Type} Error: {method_final} attribute not supported") 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: except Failed as e:
if self.validate_builders: if self.validate_builders:
raise raise
@ -502,7 +558,7 @@ class CollectionBuilder:
if "add_existing" not in self.sonarr_details: if "add_existing" not in self.sonarr_details:
self.sonarr_details["add_existing"] = self.library.Sonarr.add_existing if self.library.Sonarr else False 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"] = False
self.radarr_details["add_existing"] = False self.radarr_details["add_existing"] = False
self.sonarr_details["add"] = False self.sonarr_details["add"] = False
@ -1123,10 +1179,8 @@ class CollectionBuilder:
message = None message = None
if filter_final not in all_filters: if filter_final not in all_filters:
message = f"{self.Type} Error: {filter_final} is not a valid filter attribute" 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: elif self.collection_level in filters and filter_attr not in filters[self.collection_level]:
message = f"{self.Type} Error: {filter_final} filter attribute only works for movie libraries" message = f"{self.Type} Error: {filter_final} is not a valid {self.collection_level} filter attribute"
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 filter_final is None: elif filter_final is None:
message = f"{self.Type} Error: {filter_final} filter attribute is blank" message = f"{self.Type} Error: {filter_final} filter attribute is blank"
elif filter_attr in tmdb_filters: elif filter_attr in tmdb_filters:
@ -1280,8 +1334,8 @@ class CollectionBuilder:
logger.info("") logger.info("")
logger.info("Filtering Builders:") logger.info("Filtering Builders:")
for i, item in enumerate(items, 1): for i, item in enumerate(items, 1):
if not isinstance(item, (Movie, Show, Season, Episode)): if not isinstance(item, (Movie, Show, Season, Episode, Artist, Album, Track)):
logger.error(f"{self.Type} Error: Item: {item} must be Movie, Show, Season, or Episode") logger.error(f"{self.Type} Error: Item: {item} is an invalid type")
continue continue
if item not in self.added_items: if item not in self.added_items:
if item.ratingKey in self.filtered_keys: if item.ratingKey in self.filtered_keys:
@ -1318,8 +1372,14 @@ class CollectionBuilder:
if plex_filter[filter_alias["type"]] not in ["shows", "seasons", "episodes"]: 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") 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"]] 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: elif self.library.is_show:
sort_type = "shows" sort_type = "shows"
elif self.library.is_music:
sort_type = "artists"
else: else:
sort_type = "movies" sort_type = "movies"
ms = method.split("_") ms = method.split("_")
@ -1369,7 +1429,7 @@ class CollectionBuilder:
mod = plex.modifier_translation[modifier] if modifier in plex.modifier_translation else modifier mod = plex.modifier_translation[modifier] if modifier in plex.modifier_translation else modifier
if arg_s is None: if arg_s is None:
arg_s = arg 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" mod_s = "does not contain" if modifier == ".not" else "contains"
elif mod_s is None: elif mod_s is None:
mod_s = util.mod_displays[modifier] 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")): 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") 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") 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") 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: elif _data is None:
raise Failed(f"{self.Type} Error: {final_attr} {method} attribute is blank") raise Failed(f"{self.Type} Error: {final_attr} {method} attribute is blank")
elif final_attr.startswith(("any", "all")): elif final_attr.startswith(("any", "all")):
@ -1410,11 +1474,11 @@ class CollectionBuilder:
bool_mod = "" if validation else "!" bool_mod = "" if validation else "!"
bool_arg = "true" if validation else "false" bool_arg = "true" if validation else "false"
results, display_add = build_url_arg(1, mod=bool_mod, arg_s=bool_arg, mod_s="is") 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 = "" results = ""
display_add = "" display_add = ""
for og_value, result in validation: 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] display_add += built_arg[1]
results += f"{conjunction if len(results) > 0 else ''}{built_arg[0]}" results += f"{conjunction if len(results) > 0 else ''}{built_arg[0]}"
else: else:
@ -1475,7 +1539,7 @@ class CollectionBuilder:
else: else:
logger.error(err) logger.error(err)
return valid_regex 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)) return smart_pair(util.get_list(data, split=False))
elif attribute == "original_language": elif attribute == "original_language":
return util.get_list(data, lower=True) return util.get_list(data, lower=True)
@ -1488,7 +1552,7 @@ class CollectionBuilder:
if str(data).lower() in ["day", "month"]: if str(data).lower() in ["day", "month"]:
return data.lower() return data.lower()
raise Failed(f"{self.Type} Error: history attribute invalid: {data} must be a number between 1-30, day, or month") 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: if attribute in plex.tmdb_attributes:
final_values = [] final_values = []
for value in util.get_list(data): for value in util.get_list(data):
@ -1517,20 +1581,18 @@ class CollectionBuilder:
else: else:
logger.error(error) logger.error(error)
return valid_list 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"]: elif attribute in plex.date_attributes and modifier in [".before", ".after"]:
return util.validate_date(data, final, return_as="%Y-%m-%d") 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"]: elif attribute in plex.year_attributes + ["tmdb_year"] and modifier in ["", ".not"]:
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"]:
final_years = [] final_years = []
values = util.get_list(data) values = util.get_list(data)
for value in values: 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) 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: elif attribute in plex.boolean_attributes + boolean_filters:
return self._parse(attribute, data, datatype="bool") return self._parse(attribute, data, datatype="bool")
else: else:
@ -1556,8 +1618,8 @@ class CollectionBuilder:
def fetch_item(self, item): def fetch_item(self, item):
try: try:
current = self.library.fetchItem(item.ratingKey if isinstance(item, (Movie, Show, Season, Episode)) else int(item)) 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)): if not isinstance(current, (Movie, Show, Season, Episode, Artist, Album, Track)):
raise NotFound raise NotFound
return current return current
except (BadRequest, NotFound): except (BadRequest, NotFound):
@ -1668,53 +1730,73 @@ class CollectionBuilder:
return False return False
return True 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"]: if (self.filters or self.tmdb_filters) and not self.details["only_filter_missing"]:
util.print_return(f"Filtering {display} {current.title}") util.print_return(f"Filtering {display} {item.title}")
if self.tmdb_filters: if self.tmdb_filters and isinstance(item, (Movie, Show)):
if current.ratingKey not in self.library.movie_rating_key_map and current.ratingKey not in self.library.show_rating_key_map: 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 {current.title}") logger.warning(f"Filter Error: No {'TMDb' if self.library.is_movie else 'TVDb'} ID found for {item.title}")
return False return False
try: try:
if current.ratingKey in self.library.movie_rating_key_map: if item.ratingKey in self.library.movie_rating_key_map:
t_id = self.library.movie_rating_key_map[current.ratingKey] t_id = self.library.movie_rating_key_map[item.ratingKey]
else: 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: except Failed as e:
logger.error(e) logger.error(e)
return False 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 return False
for filter_method, filter_data in self.filters: for filter_method, filter_data in self.filters:
filter_attr, modifier, filter_final = self._split(filter_method) filter_attr, modifier, filter_final = self._split(filter_method)
filter_actual = filter_translation[filter_attr] if filter_attr in filter_translation else filter_attr filter_actual = filter_translation[filter_attr] if filter_attr in filter_translation else filter_attr
if filter_attr in ["release", "added", "last_played"]: item_type = self.collection_level
if util.is_date_filter(getattr(current, filter_actual), modifier, filter_data, filter_final, self.current_time): 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 return False
elif filter_attr in ["audio_track_title", "filepath", "title", "studio"]: elif filter_attr in ["audio_track_title", "filepath", "title", "studio"]:
values = [] values = []
if filter_attr == "audio_track_title": if filter_attr == "audio_track_title":
for media in current.media: for media in item.media:
for part in media.parts: for part in media.parts:
values.extend([a.title for a in part.audioStreams() if a.title]) values.extend([a.title for a in part.audioStreams() if a.title])
elif filter_attr == "filepath": elif filter_attr == "filepath":
values = [loc for loc in current.locations] values = [loc for loc in item.locations]
elif filter_attr in ["title", "studio"]: 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): if util.is_string_filter(values, modifier, filter_data):
return False return False
elif filter_attr in boolean_filters: elif filter_attr in boolean_filters:
filter_check = False filter_check = False
if filter_attr == "has_collection": if filter_attr == "has_collection":
filter_check = len(current.collections) > 0 filter_check = len(item.collections) > 0
elif filter_attr == "has_overlay": elif filter_attr == "has_overlay":
for label in current.labels: for label in item.labels:
if label.tag.lower().endswith(" overlay"): if label.tag.lower().endswith(" overlay"):
filter_check = True filter_check = True
if util.is_boolean_filter(filter_data, filter_check): if util.is_boolean_filter(filter_data, filter_check):
return False return False
elif filter_attr == "history": elif filter_attr == "history":
item_date = current.originallyAvailableAt item_date = item.originallyAvailableAt
if item_date is None: if item_date is None:
return False return False
elif filter_data == "day": elif filter_data == "day":
@ -1733,12 +1815,12 @@ class CollectionBuilder:
return False return False
elif modifier in [".gt", ".gte", ".lt", ".lte"]: elif modifier in [".gt", ".gte", ".lt", ".lte"]:
divider = 60000 if filter_attr == "duration" else 1 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 return False
else: else:
attrs = [] attrs = []
if filter_attr in ["resolution", "audio_language", "subtitle_language"]: if filter_attr in ["resolution", "audio_language", "subtitle_language"]:
for media in current.media: for media in item.media:
if filter_attr == "resolution": if filter_attr == "resolution":
attrs.extend([media.videoResolution]) attrs.extend([media.videoResolution])
for part in media.parts: for part in media.parts:
@ -1747,16 +1829,16 @@ class CollectionBuilder:
if filter_attr == "subtitle_language": if filter_attr == "subtitle_language":
attrs.extend([s.language for s in part.subtitleStreams()]) attrs.extend([s.language for s in part.subtitleStreams()])
elif filter_attr in ["content_rating", "year", "rating"]: 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"]: 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: else:
raise Failed(f"Filter Error: filter: {filter_final} not supported") raise Failed(f"Filter Error: filter: {filter_final} not supported")
if (not list(set(filter_data) & set(attrs)) and modifier == "") \ if (not list(set(filter_data) & set(attrs)) and modifier == "") \
or (list(set(filter_data) & set(attrs)) and modifier == ".not"): or (list(set(filter_data) & set(attrs)) and modifier == ".not"):
return False return False
util.print_return(f"Filtering {display} {current.title}") util.print_return(f"Filtering {display} {item.title}")
return True return True
def run_missing(self): def run_missing(self):
@ -1924,7 +2006,7 @@ class CollectionBuilder:
logger.error(e) logger.error(e)
# Locking should come before refreshing since refreshing can change metadata (i.e. if specified to both lock # 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: if "item_lock_background" in self.item_details:
self.library.query(item.lockArt) self.library.query(item.lockArt)
if "item_lock_poster" in self.item_details: if "item_lock_poster" in self.item_details:

@ -9,6 +9,18 @@ logger = logging.getLogger("Plex Meta Manager")
github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/" 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): def get_dict(attribute, attr_data, check_list=None):
if check_list is None: if check_list is None:
@ -19,14 +31,11 @@ def get_dict(attribute, attr_data, check_list=None):
new_dict = {} new_dict = {}
for _name, _data in attr_data[attribute].items(): for _name, _data in attr_data[attribute].items():
if _name in check_list: if _name in check_list:
logger.error( logger.error(f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name}")
f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name}")
elif _data is None: elif _data is None:
logger.error( logger.error(f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} has no data")
f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} has no data")
elif not isinstance(_data, dict): elif not isinstance(_data, dict):
logger.error( logger.error(f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} must be a dictionary")
f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} must be a dictionary")
else: else:
new_dict[str(_name)] = _data new_dict[str(_name)] = _data
return new_dict return new_dict
@ -221,6 +230,48 @@ class MetadataFile(DataFile):
else: else:
return self.collections 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): def update_metadata(self):
if not self.metadata: if not self.metadata:
return None return None
@ -229,8 +280,6 @@ class MetadataFile(DataFile):
logger.info("") logger.info("")
for mapping_name, meta in self.metadata.items(): for mapping_name, meta in self.metadata.items():
methods = {mm.lower(): mm for mm in meta} 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 updated = False
edits = {} edits = {}
@ -243,13 +292,11 @@ class MetadataFile(DataFile):
if value is None: value = group[alias[name]] if value is None: value = group[alias[name]]
try: try:
current = str(getattr(current_item, key, "")) current = str(getattr(current_item, key, ""))
final_value = None
if var_type == "date": if var_type == "date":
final_value = util.validate_date(value, name, return_as="%Y-%m-%d") final_value = util.validate_date(value, name, return_as="%Y-%m-%d")
current = current[:-9] current = current[:-9]
elif var_type == "float": elif var_type == "float":
if value is None:
raise Failed(f"Metadata Error: {name} attribute is blank")
final_value = None
try: try:
value = float(str(value)) value = float(str(value))
if 0 <= value <= 10: if 0 <= value <= 10:
@ -258,6 +305,13 @@ class MetadataFile(DataFile):
pass pass
if final_value is None: if final_value is None:
raise Failed(f"Metadata Error: {name} attribute must be a number between 0 and 10") 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: else:
final_value = value final_value = value
if current != str(final_value): if current != str(final_value):
@ -269,13 +323,11 @@ class MetadataFile(DataFile):
else: else:
logger.error(f"Metadata Error: {name} attribute is blank") 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}"] key, options = plex.item_advance_keys[f"item_{attr}"]
if attr in alias: if attr in alias:
if new_agent and self.library.agent not in plex.new_plex_agents: 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") 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]]: elif group[alias[attr]]:
method_data = str(group[alias[attr]]).lower() method_data = str(group[alias[attr]]).lower()
if method_data not in options: if method_data not in options:
@ -286,54 +338,12 @@ class MetadataFile(DataFile):
else: else:
logger.error(f"Metadata Error: {attr} attribute is blank") 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("") logger.info("")
util.separator() util.separator()
logger.info("") logger.info("")
year = None year = None
if "year" in methods: if "year" in methods and not self.library.is_music:
next_year = datetime.now().year + 1 next_year = datetime.now().year + 1
if meta[methods["year"]] is None: if meta[methods["year"]] is None:
raise Failed("Metadata Error: year attribute is blank") raise Failed("Metadata Error: year attribute is blank")
@ -370,15 +380,14 @@ class MetadataFile(DataFile):
logger.error(f"Skipping {mapping_name}") logger.error(f"Skipping {mapping_name}")
continue continue
item_type = "Movie" if self.library.is_movie else "Show" logger.info(f"Updating {self.library.type}: {title}...")
logger.info(f"Updating {item_type}: {title}...")
tmdb_item = None tmdb_item = None
tmdb_is_movie = 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") 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: try:
if "tmdb_show" in methods or "tmdb_id" in methods: if "tmdb_show" in methods or "tmdb_id" in methods:
data = meta[methods["tmdb_show" if "tmdb_show" in methods else "tmdb_id"]] data = meta[methods["tmdb_show" if "tmdb_show" in methods else "tmdb_id"]]
@ -421,6 +430,7 @@ class MetadataFile(DataFile):
edits = {} edits = {}
add_edit("title", item, meta, methods, value=title) add_edit("title", item, meta, methods, value=title)
add_edit("sort_title", item, meta, methods, key="titleSort") add_edit("sort_title", item, meta, methods, key="titleSort")
if not self.library.is_music:
add_edit("originally_available", item, meta, methods, key="originallyAvailableAt", value=originally_available, var_type="date") 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("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("audience_rating", item, meta, methods, key="audienceRating", var_type="float")
@ -430,43 +440,42 @@ class MetadataFile(DataFile):
add_edit("studio", item, meta, methods, value=studio) add_edit("studio", item, meta, methods, value=studio)
add_edit("tagline", item, meta, methods, value=tagline) add_edit("tagline", item, meta, methods, value=tagline)
add_edit("summary", item, meta, methods, value=summary) 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 updated = True
advance_edits = {} advance_edits = {}
for advance_edit in ["episode_sorting", "keep_episodes", "delete_episodes", "season_display", "episode_ordering", "metadata_language", "use_original_title"]: for advance_edit in advance_tags_to_edit[self.library.type]:
is_show = advance_edit in ["episode_sorting", "keep_episodes", "delete_episodes", "season_display", "episode_ordering"]
is_new_agent = advance_edit in ["metadata_language", "use_original_title"] 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) add_advanced_edit(advance_edit, item, meta, methods, new_agent=is_new_agent)
if self.library.edit_item(item, mapping_name, item_type, advance_edits, advanced=True): if self.library.edit_item(item, mapping_name, self.library.type, advance_edits, advanced=True):
updated = True updated = True
for tag_edit in ["genre", "label", "collection", "country", "director", "producer", "writer"]: for tag_edit in tags_to_edit[self.library.type]:
is_movie = tag_edit in ["country", "director", "producer", "writer"] if self.edit_tags(tag_edit, item, meta, methods, extra=genres if tag_edit == "genre" else None):
has_extra = genres if tag_edit == "genre" else None
if edit_tags(tag_edit, item, meta, methods, movie_library=is_movie, extra=has_extra):
updated = True 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 "seasons" in methods and self.library.is_show:
if meta[methods["seasons"]]: if not meta[methods["seasons"]]:
for season_id in 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 updated = False
logger.info("") logger.info("")
logger.info(f"Updating season {season_id} of {mapping_name}...") logger.info(f"Updating season {season_id} of {mapping_name}...")
try:
if isinstance(season_id, int): if isinstance(season_id, int):
season = None season = item.season(seasson=season_id)
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")
else: else:
season_dict = meta[methods["seasons"]][season_id] 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} season_methods = {sm.lower(): sm for sm in season_dict}
if "title" in season_methods and season_dict[season_methods["title"]]: if "title" in season_methods and season_dict[season_methods["title"]]:
@ -488,22 +497,69 @@ class MetadataFile(DataFile):
add_edit("summary", season, season_dict, season_methods) add_edit("summary", season, season_dict, season_methods)
if self.library.edit_item(season, season_id, "Season", edits): if self.library.edit_item(season, season_id, "Season", edits):
updated = True updated = True
set_images(season, season_dict, season_methods) self.set_images(season, season_dict, season_methods)
else:
logger.error(f"Metadata Error: Season: {season_id} invalid, it must be an integer")
logger.info(f"Season {season_id} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}") logger.info(f"Season {season_id} of {mapping_name} Details Update {'Complete' if updated else 'Not Needed'}")
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: else:
logger.error("Metadata Error: seasons attribute is blank") for episode_str, episode_dict in season_dict[season_methods["episodes"]].items():
elif "seasons" in methods: updated = False
logger.error("Metadata Error: seasons attribute only works for show libraries") 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 "episodes" in methods and self.library.is_show:
if meta[methods["episodes"]]: if not meta[methods["episodes"]]:
for episode_str in 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 updated = False
logger.info("") logger.info("")
match = re.search("[Ss]\\d+[Ee]\\d+", episode_str) match = re.search("[Ss]\\d+[Ee]\\d+", episode_str)
if match: 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") output = match.group(0)[1:].split("E" if "E" in match.group(0) else "e")
season_id = int(output[0]) season_id = int(output[0])
episode_id = int(output[1]) episode_id = int(output[1])
@ -512,8 +568,7 @@ class MetadataFile(DataFile):
episode = item.episode(season=season_id, episode=episode_id) episode = item.episode(season=season_id, episode=episode_id)
except NotFound: except NotFound:
logger.error(f"Metadata Error: episode {episode_id} of season {season_id} not found") logger.error(f"Metadata Error: episode {episode_id} of season {season_id} not found")
else: continue
episode_dict = meta[methods["episodes"]][episode_str]
episode_methods = {em.lower(): em for em in episode_dict} episode_methods = {em.lower(): em for em in episode_dict}
if "title" in episode_methods and episode_dict[episode_methods["title"]]: if "title" in episode_methods and episode_dict[episode_methods["title"]]:
@ -537,18 +592,83 @@ class MetadataFile(DataFile):
add_edit("summary", episode, episode_dict, episode_methods) add_edit("summary", episode, episode_dict, episode_methods)
if self.library.edit_item(episode, f"{season_id} Episode: {episode_id}", "Season", edits): if self.library.edit_item(episode, f"{season_id} Episode: {episode_id}", "Season", edits):
updated = True updated = True
if edit_tags("director", episode, episode_dict, episode_methods): for tag_edit in ["director", "writer"]:
updated = True if self.edit_tags(tag_edit, episode, episode_dict, episode_methods):
if edit_tags("writer", episode, episode_dict, episode_methods):
updated = True updated = True
set_images(episode, episode_dict, episode_methods) 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'}") 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: else:
logger.error(f"Metadata Error: episode {episode_str} invalid must have S##E## format") 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: else:
logger.error("Metadata Error: episodes attribute is blank") title = album.title
elif "episodes" in methods: edits = {}
logger.error("Metadata Error: episodes attribute only works for show libraries") 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): class PlaylistFile(DataFile):

@ -41,7 +41,47 @@ search_translation = {
"audio_language": "audioLanguage", "audio_language": "audioLanguage",
"progress": "inProgress", "progress": "inProgress",
"episode_progress": "episode.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 = { show_translation = {
"title": "show.title", "title": "show.title",
@ -71,6 +111,7 @@ modifier_translation = {
".before": "%3C%3C", ".after": "%3E%3E", ".begins": "%3C", ".ends": "%3E" ".before": "%3C%3C", ".after": "%3E%3E", ".begins": "%3C", ".ends": "%3E"
} }
episode_sorting_options = {"default": "-1", "oldest": "0", "newest": "1"} 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} 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} delete_episodes_options = {"never": 0, "day": 1, "week": 7, "refresh": 100}
season_display_options = {"default": -1, "show": 0, "hide": 1} 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 metadata_language_options["default"] = None
use_original_title_options = {"default": -1, "no": 0, "yes": 1} use_original_title_options = {"default": -1, "no": 0, "yes": 1}
collection_order_options = ["release", "alpha", "custom"] 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_mode_keys = {-1: "default", 0: "hide", 1: "hideItems", 2: "showItems"}
collection_order_keys = {0: "release", 1: "alpha", 2: "custom"} collection_order_keys = {0: "release", 1: "alpha", 2: "custom"}
item_advance_keys = { item_advance_keys = {
"item_album_sorting": ("albumSort", album_sorting_options),
"item_episode_sorting": ("episodeSort", episode_sorting_options), "item_episode_sorting": ("episodeSort", episode_sorting_options),
"item_keep_episodes": ("autoDeletionItemPolicyUnwatchedLibrary", keep_episodes_options), "item_keep_episodes": ("autoDeletionItemPolicyUnwatchedLibrary", keep_episodes_options),
"item_delete_episodes": ("autoDeletionItemPolicyWatchedLibrary", delete_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) "item_use_original_title": ("useOriginalTitle", use_original_title_options)
} }
new_plex_agents = ["tv.plex.agents.movie", "tv.plex.agents.series"] 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 = [ searches = [
"title", "title.not", "title.is", "title.isnot", "title.begins", "title.ends", "title", "title.not", "title.is", "title.isnot", "title.begins", "title.ends",
"studio", "studio.not", "studio.is", "studio.isnot", "studio.begins", "studio.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_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_user_rating.gt", "episode_user_rating.gte", "episode_user_rating.lt", "episode_user_rating.lte",
"episode_year", "episode_year.not", "episode_year.gt", "episode_year.gte", "episode_year.lt", "episode_year.lte" "episode_year", "episode_year.not", "episode_year.gt", "episode_year.gte", "episode_year.lt", "episode_year.lte"
] ] + music_searches
and_searches = [ and_searches = [
"title.and", "studio.and", "actor.and", "audio_language.and", "collection.and", "title.and", "studio.and", "actor.and", "audio_language.and", "collection.and",
"content_rating.and", "country.and", "director.and", "genre.and", "label.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", "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", "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 = [ boolean_attributes = [
"hdr", "unmatched", "duplicate", "unplayed", "progress", "trash", "hdr", "unmatched", "duplicate", "unplayed", "progress", "trash", "unplayed_episodes", "episode_unplayed",
"unplayed_episodes", "episode_unplayed", "episode_duplicate", "episode_progress", "episode_unmatched", "episode_duplicate", "episode_progress", "episode_unmatched", "artist_unmatched", "album_unmatched", "track_trash"
] ]
tmdb_attributes = ["actor", "director", "producer", "writer"] 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"] date_attributes = [
number_attributes = ["plays", "episode_plays", "duration", "tmdb_vote_count"] + 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"} search_display = {"added": "Date Added", "release": "Release Date", "hdr": "HDR", "progress": "In Progress", "episode_progress": "Episode In Progress"}
tags = [ tag_attributes = [
"actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "network",
"network", "producer", "resolution", "studio", "subtitle_language", "writer" "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 = { movie_sorts = {
"title.asc": "titleSort", "title.desc": "titleSort%3Adesc", "title.asc": "titleSort", "title.desc": "titleSort%3Adesc",
@ -180,8 +277,12 @@ movie_sorts = {
"user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc",
"content_rating.asc": "contentRating", "content_rating.desc": "contentRating%3Adesc", "content_rating.asc": "contentRating", "content_rating.desc": "contentRating%3Adesc",
"duration.asc": "duration", "duration.desc": "duration%3Adesc", "duration.asc": "duration", "duration.desc": "duration%3Adesc",
"progress.asc": "viewOffset", "progress.desc": "viewOffset%3Adesc",
"plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc",
"added.asc": "addedAt", "added.desc": "addedAt%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" "random": "random"
} }
show_sorts = { show_sorts = {
@ -193,8 +294,10 @@ show_sorts = {
"audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc", "audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc",
"user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc",
"content_rating.asc": "contentRating", "content_rating.desc": "contentRating%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", "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" "random": "random"
} }
season_sorts = { season_sorts = {
@ -215,11 +318,61 @@ episode_sorts = {
"audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc", "audience_rating.asc": "audienceRating", "audience_rating.desc": "audienceRating%3Adesc",
"user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc", "user_rating.asc": "userRating", "user_rating.desc": "userRating%3Adesc",
"duration.asc": "duration", "duration.desc": "duration%3Adesc", "duration.asc": "duration", "duration.desc": "duration%3Adesc",
"progress.asc": "viewOffset", "progress.desc": "viewOffset%3Adesc",
"plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc", "plays.asc": "viewCount", "plays.desc": "viewCount%3Adesc",
"added.asc": "addedAt", "added.desc": "addedAt%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" "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): class Plex(Library):
def __init__(self, config, params): def __init__(self, config, params):
@ -247,7 +400,7 @@ class Plex(Library):
break break
if not self.Plex: if not self.Plex:
raise Failed(f"Plex Error: Plex Library {params['name']} not found. Options: {library_names}") 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() self.type = self.Plex.type.capitalize()
else: else:
raise Failed(f"Plex Error: Plex Library must be a Movies or TV Shows library") 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.agent = self.Plex.agent
self.is_movie = self.type == "Movie" self.is_movie = self.type == "Movie"
self.is_show = self.type == "Show" self.is_show = self.type == "Show"
self.is_music = self.type == "Artist"
self.is_other = self.agent == "com.plexapp.agents.none" self.is_other = self.agent == "com.plexapp.agents.none"
if self.is_other: if self.is_other:
self.type = "Video" self.type = "Video"
@ -395,6 +549,8 @@ class Plex(Library):
names.append(choice.title) names.append(choice.title)
if choice.key not in names: if choice.key not in names:
names.append(choice.key) 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.title.lower()] = choice.title if title else choice.key
choices[choice.key.lower()] = choice.title if title else choice.key choices[choice.key.lower()] = choice.title if title else choice.key
return choices, names 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): def edit_tags(self, attr, obj, add_tags=None, remove_tags=None, sync_tags=None):
display = "" display = ""
key = builder.filter_translation[attr] if attr in builder.filter_translation else attr 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: if add_tags or remove_tags or sync_tags is not None:
_add_tags = add_tags if add_tags else [] _add_tags = add_tags if add_tags else []
_remove_tags = [t.lower() for t in remove_tags] if remove_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] _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] _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: 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)}" display += f"+{', +'.join(_add)}"
if _remove: 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)}" display += f"-{', -'.join(_remove)}"
if len(display) > 0: 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 return len(display) > 0
def find_assets(self, item, name=None, upload=True, overlay=None, folders=None, create=None): def find_assets(self, item, name=None, upload=True, overlay=None, folders=None, create=None):

@ -2,6 +2,7 @@ import glob, logging, os, re, signal, sys, time, traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathvalidate import is_valid_filename, sanitize_filename from pathvalidate import is_valid_filename, sanitize_filename
from plexapi.audio import Artist, Album, Track
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.video import Season, Episode, Movie from plexapi.video import Season, Episode, Movie
@ -261,6 +262,10 @@ def item_title(item):
return f"{text}: {item.parentTitle}: {item.title}" return f"{text}: {item.parentTitle}: {item.title}"
elif isinstance(item, Movie) and item.year: elif isinstance(item, Movie) and item.year:
return f"{item.title} ({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: else:
return item.title return item.title

@ -246,7 +246,7 @@ def update_libraries(config):
logger.debug(f"Optimize: {library.optimize}") logger.debug(f"Optimize: {library.optimize}")
logger.debug(f"Timeout: {library.timeout}") logger.debug(f"Timeout: {library.timeout}")
if not library.is_other: if not library.is_other and not library.is_music:
logger.info("") logger.info("")
util.separator(f"Mapping {library.name} Library", space=False, border=False) util.separator(f"Mapping {library.name} Library", space=False, border=False)
logger.info("") 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): if builder.run_again and (len(builder.run_again_movies) > 0 or len(builder.run_again_shows) > 0):
library.run_again.append(builder) library.run_again.append(builder)
except NotScheduled as e: except NotScheduled as e:
util.print_multiline(e, info=True) util.print_multiline(e, info=True)
except Failed as e: except Failed as e:

Loading…
Cancel
Save