$484 add playlists

This commit is contained in:
meisnate12 2021-12-17 09:24:46 -05:00
parent 03b02a2575
commit a5a27d25da
10 changed files with 559 additions and 119 deletions

View file

@ -85,7 +85,7 @@ boolean_details = [
string_details = ["sort_title", "content_rating", "name_mapping"]
ignored_details = [
"smart_filter", "smart_label", "smart_url", "run_again", "schedule", "sync_mode", "template", "test", "delete_not_scheduled",
"tmdb_person", "build_collection", "collection_order", "collection_level", "validate_builders", "collection_name", "sort_by"
"tmdb_person", "build_collection", "collection_order", "collection_level", "validate_builders", "collection_name", "sort_by", "libraries"
]
details = ["ignore_ids", "ignore_imdb_ids", "server_preroll", "collection_changes_webhooks", "collection_mode",
"collection_minimum", "label"] + boolean_details + string_details
@ -165,6 +165,11 @@ custom_sort_builders = [
"mal_all", "mal_airing", "mal_upcoming", "mal_tv", "mal_movie", "mal_ova", "mal_special",
"mal_popular", "mal_favorite", "mal_suggested", "mal_userlist", "mal_season", "mal_genre", "mal_studio"
]
playlist_attributes = [
"playlist_name", "filters", "name_mapping", "show_filtered", "show_missing", "save_missing",
"missing_only_released", "only_filter_missing", "delete_below_minimum", "ignore_ids", "ignore_imdb_ids",
"server_preroll", "collection_changes_webhooks", "collection_minimum"
] + custom_sort_builders + summary_details + poster_details + radarr_details + sonarr_details
class CollectionBuilder:
def __init__(self, config, library, metadata, name, no_missing, data, playlist=False):
@ -197,7 +202,7 @@ class CollectionBuilder:
self.builders = []
self.filters = []
self.tmdb_filters = []
self.rating_keys = []
self.added_items = []
self.filtered_keys = {}
self.run_again_movies = []
self.run_again_shows = []
@ -460,7 +465,7 @@ class CollectionBuilder:
suffix = f" and could not be found to delete"
raise NotScheduled(f"{self.schedule}\n\nCollection {self.name} not scheduled to run{suffix}")
self.collectionless = "plex_collectionless" in methods
self.collectionless = "plex_collectionless" in methods and not self.playlist
self.validate_builders = True
if "validate_builders" in methods:
@ -477,7 +482,7 @@ class CollectionBuilder:
self.run_again = self._parse("run_again", self.data, datatype="bool", methods=methods, default=False)
self.build_collection = True
if "build_collection" in methods:
if "build_collection" in methods and not self.playlist:
logger.debug("")
logger.debug("Validating Method: build_collection")
logger.debug(f"Value: {data[methods['build_collection']]}")
@ -496,8 +501,8 @@ class CollectionBuilder:
else:
self.sync = self.data[methods["sync_mode"]].lower() == "sync"
self.custom_sort = False
if "collection_order" in methods:
self.custom_sort = self.playlist
if "collection_order" in methods and not self.playlist:
logger.debug("")
logger.debug("Validating Method: collection_order")
if self.data[methods["collection_order"]] is None:
@ -512,7 +517,7 @@ class CollectionBuilder:
raise Failed(f"{self.Type} Error: {self.data[methods['collection_order']]} collection_order invalid\n\trelease (Order Collection by release dates)\n\talpha (Order Collection Alphabetically)\n\tcustom (Custom Order Collection)")
self.sort_by = None
if "sort_by" in methods:
if "sort_by" in methods and not self.playlist:
logger.debug("")
logger.debug("Validating Method: sort_by")
if self.data[methods["sort_by"]] is None:
@ -525,7 +530,9 @@ class CollectionBuilder:
self.sort_by = self.data[methods["sort_by"]]
self.collection_level = "movie" if self.library.is_movie else "show"
if "collection_level" in methods:
if self.playlist:
self.collection_level = "item"
if "collection_level" in methods and not self.playlist:
logger.debug("")
logger.debug("Validating Method: collection_level")
if self.library.is_movie:
@ -562,7 +569,7 @@ class CollectionBuilder:
self.smart_sort = "random"
self.smart_label_collection = False
if "smart_label" in methods:
if "smart_label" in methods and not self.playlist:
logger.debug("")
logger.debug("Validating Method: smart_label")
self.smart_label_collection = True
@ -579,7 +586,7 @@ class CollectionBuilder:
self.smart_url = None
self.smart_type_key = None
self.smart_filter_details = ""
if "smart_url" in methods:
if "smart_url" in methods and not self.playlist:
logger.debug("")
logger.debug("Validating Method: smart_url")
if not self.data[methods["smart_url"]]:
@ -591,7 +598,7 @@ class CollectionBuilder:
except ValueError:
raise Failed(f"{self.Type} Error: smart_url is incorrectly formatted")
if "smart_filter" in methods:
if "smart_filter" in methods and not self.playlist:
self.smart_type_key, self.smart_filter_details, self.smart_url = self.build_filter("smart_filter", self.data[methods["smart_filter"]], smart=True)
def cant_interact(attr1, attr2, fail=False):
@ -626,6 +633,7 @@ class CollectionBuilder:
try:
if method_data is None and method_name in all_builders + plex.searches: raise Failed(f"{self.Type} Error: {method_final} attribute is blank")
elif method_data is None and method_final not in none_details: logger.warning(f"Collection Warning: {method_final} attribute is blank")
elif self.playlist and method_name not in playlist_attributes: raise Failed(f"{self.Type} Error: {method_final} attribute not allowed when using playlists")
elif not self.config.Trakt and "trakt" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Trakt to be configured")
elif not self.library.Radarr and "radarr" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Radarr to be configured")
elif not self.library.Sonarr and "sonarr" in method_name: raise Failed(f"{self.Type} Error: {method_final} requires Sonarr to be configured")
@ -703,7 +711,7 @@ class CollectionBuilder:
if self.build_collection:
try:
self.obj = self.library.get_collection(self.name)
self.obj = self.library.get_playlist(self.name) if self.playlist else self.library.get_collection(self.name)
if (self.smart and not self.obj.smart) or (not self.smart and self.obj.smart):
logger.info("")
logger.error(f"{self.Type} Error: Converting {self.obj.title} to a {'smart' if self.smart else 'normal'} collection")
@ -1442,14 +1450,14 @@ class CollectionBuilder:
if not isinstance(item, (Movie, Show, Season, Episode)):
logger.error(f"{self.Type} Error: Item: {item} must be Movie, Show, Season, or Episode")
continue
if item.ratingKey not in self.rating_keys:
if item not in self.added_items:
if item.ratingKey in self.filtered_keys:
if self.details["show_filtered"] is True:
logger.info(f"{name} {self.Type} | X | {self.filtered_keys[item.ratingKey]}")
else:
current_title = self.item_title(item)
current_title = util.item_title(item)
if self.check_filters(item, f"{(' ' * (max_length - len(str(i))))}{i}/{total}"):
self.rating_keys.append(item.ratingKey)
self.added_items.append(item)
else:
self.filtered_keys[item.ratingKey] = current_title
if self.details["show_filtered"] is True:
@ -1724,29 +1732,34 @@ class CollectionBuilder:
def add_to_collection(self):
name, collection_items = self.library.get_collection_name_and_items(self.obj if self.obj else self.name, self.smart_label_collection)
total = len(self.rating_keys)
total = len(self.added_items)
amount_added = 0
for i, item in enumerate(self.rating_keys, 1):
try:
current = self.fetch_item(item)
except Failed as e:
logger.error(e)
continue
current_operation = "=" if current in collection_items else "+"
logger.info(util.adjust_space(f"{name} Collection | {current_operation} | {self.item_title(current)}"))
if current in collection_items:
self.plex_map[current.ratingKey] = None
playlist_adds = []
for item in self.added_items:
current_operation = "=" if item in collection_items else "+"
logger.info(util.adjust_space(f"{name} {self.Type} | {current_operation} | {util.item_title(item)}"))
if item in collection_items:
self.plex_map[item.ratingKey] = None
else:
self.library.alter_collection(current, name, smart_label_collection=self.smart_label_collection)
if self.playlist:
playlist_adds.append(item)
else:
self.library.alter_collection(item, name, smart_label_collection=self.smart_label_collection)
amount_added += 1
if self.details["collection_changes_webhooks"]:
if self.library.is_movie and current.ratingKey in self.library.movie_rating_key_map:
add_id = self.library.movie_rating_key_map[current.ratingKey]
elif self.library.is_show and current.ratingKey in self.library.show_rating_key_map:
add_id = self.library.show_rating_key_map[current.ratingKey]
if self.library.is_movie and item.ratingKey in self.library.movie_rating_key_map:
add_id = self.library.movie_rating_key_map[item.ratingKey]
elif self.library.is_show and item.ratingKey in self.library.show_rating_key_map:
add_id = self.library.show_rating_key_map[item.ratingKey]
else:
add_id = None
self.notification_additions.append({"title": current.title, "id": add_id})
self.notification_additions.append({"title": item.title, "id": add_id})
if self.playlist and playlist_adds and not self.obj:
self.obj = self.library.create_playlist(self.name, playlist_adds)
logger.info("")
logger.info(f"Playlist: {self.name} created")
elif self.playlist and playlist_adds:
self.obj.addItems(playlist_adds)
util.print_end()
logger.info("")
logger.info(f"{total} {self.collection_level.capitalize()}{'s' if total > 1 else ''} Processed")
@ -1754,15 +1767,20 @@ class CollectionBuilder:
def sync_collection(self):
amount_removed = 0
playlist_removes = []
for ratingKey, item in self.plex_map.items():
if item is not None:
if amount_removed == 0:
logger.info("")
util.separator(f"Removed from {self.name} Collection", space=False, border=False)
util.separator(f"Removed from {self.name} {self.Type}", space=False, border=False)
logger.info("")
self.library.reload(item)
logger.info(f"{self.name} Collection | - | {self.item_title(item)}")
self.library.alter_collection(item, self.name, smart_label_collection=self.smart_label_collection, add=False)
logger.info(f"{self.name} {self.Type} | - | {util.item_title(item)}")
if self.playlist:
playlist_removes.append(item)
else:
self.library.alter_collection(item, self.name, smart_label_collection=self.smart_label_collection, add=False)
amount_removed += 1
if self.details["collection_changes_webhooks"]:
if self.library.is_movie and item.ratingKey in self.library.movie_rating_key_map:
remove_id = self.library.movie_rating_key_map[item.ratingKey]
@ -1771,7 +1789,8 @@ class CollectionBuilder:
else:
remove_id = None
self.notification_removals.append({"title": item.title, "id": remove_id})
amount_removed += 1
if self.playlist and playlist_removes:
self.obj.removeItems(playlist_removes)
if amount_removed > 0:
logger.info("")
logger.info(f"{amount_removed} {self.collection_level.capitalize()}{'s' if amount_removed == 1 else ''} Removed")
@ -1914,10 +1933,10 @@ class CollectionBuilder:
if self.check_tmdb_filter(missing_id, True, item=movie, check_released=self.details["missing_only_released"]):
missing_movies_with_names.append((current_title, missing_id))
if self.details["show_missing"] is True:
logger.info(f"{self.name} Collection | ? | {current_title} (TMDb: {missing_id})")
logger.info(f"{self.name} {self.Type} | ? | {current_title} (TMDb: {missing_id})")
else:
if self.details["show_filtered"] is True and self.details["show_missing"] is True:
logger.info(f"{self.name} Collection | X | {current_title} (TMDb: {missing_id})")
logger.info(f"{self.name} {self.Type} | X | {current_title} (TMDb: {missing_id})")
logger.info("")
logger.info(f"{len(missing_movies_with_names)} Movie{'s' if len(missing_movies_with_names) > 1 else ''} Missing")
if len(missing_movies_with_names) > 0:
@ -1949,10 +1968,10 @@ class CollectionBuilder:
if self.check_tmdb_filter(missing_id, False, check_released=self.details["missing_only_released"]):
missing_shows_with_names.append((show.title, missing_id))
if self.details["show_missing"] is True:
logger.info(f"{self.name} Collection | ? | {show.title} (TVDB: {missing_id})")
logger.info(f"{self.name} {self.Type} | ? | {show.title} (TVDB: {missing_id})")
else:
if self.details["show_filtered"] is True and self.details["show_missing"] is True:
logger.info(f"{self.name} Collection | X | {show.title} (TVDb: {missing_id})")
logger.info(f"{self.name} {self.Type} | X | {show.title} (TVDb: {missing_id})")
logger.info("")
logger.info(f"{len(missing_shows_with_names)} Show{'s' if len(missing_shows_with_names) > 1 else ''} Missing")
if len(missing_shows_with_names) > 0:
@ -1975,46 +1994,23 @@ class CollectionBuilder:
self.run_again_shows.extend(missing_tvdb_ids)
if len(self.missing_parts) > 0 and self.library.is_show and self.details["save_missing"] is True:
for missing in self.missing_parts:
logger.info(f"{self.name} Collection | X | {missing}")
logger.info(f"{self.name} {self.Type} | X | {missing}")
return added_to_radarr, added_to_sonarr
def item_title(self, item):
if self.collection_level == "season":
if f"Season {item.index}" == item.title:
return f"{item.parentTitle} {item.title}"
else:
return f"{item.parentTitle} Season {item.index}: {item.title}"
elif self.collection_level == "episode":
text = f"{item.grandparentTitle} S{util.add_zero(item.parentIndex)}E{util.add_zero(item.index)}"
if f"Season {item.parentIndex}" == item.parentTitle:
return f"{text}: {item.title}"
else:
return f"{text}: {item.parentTitle}: {item.title}"
elif self.collection_level == "movie" and item.year:
return f"{item.title} ({item.year})"
else:
return item.title
def load_collection_items(self):
if self.build_collection and self.obj:
self.items = self.library.get_collection_items(self.obj, self.smart_label_collection)
elif not self.build_collection:
logger.info("")
util.separator(f"Items Found for {self.name} Collection", space=False, border=False)
util.separator(f"Items Found for {self.name} {self.Type}", space=False, border=False)
logger.info("")
for rk in self.rating_keys:
try:
item = self.fetch_item(rk)
logger.info(f"{item.title} (Rating Key: {rk})")
self.items.append(item)
except Failed as e:
logger.error(e)
self.items = self.added_items
if not self.items:
raise Failed(f"Plex Error: No Collection items found")
raise Failed(f"Plex Error: No {self.Type} items found")
def update_item_details(self):
logger.info("")
util.separator(f"Updating Details of the Items in {self.name} Collection", space=False, border=False)
util.separator(f"Updating Details of the Items in {self.name} {self.Type}", space=False, border=False)
logger.info("")
overlay = None
overlay_folder = None
@ -2136,13 +2132,13 @@ class CollectionBuilder:
self.library.create_smart_collection(self.name, smart_type, self.smart_url)
except Failed:
raise Failed(f"{self.Type} Error: Label: {self.name} was not added to any items in the Library")
self.obj = self.library.get_collection(self.name)
self.obj = self.library.get_playlist(self.name) if self.playlist else self.library.get_collection(self.name)
if not self.exists:
self.created = True
def update_details(self):
logger.info("")
util.separator(f"Updating Details of {self.name} Collection", space=False, border=False)
util.separator(f"Updating Details of {self.name} {self.Type}", space=False, border=False)
logger.info("")
if self.smart_url and self.smart_url != self.library.smart_filter(self.obj):
self.library.update_smart_collection(self.obj, self.smart_url)
@ -2150,7 +2146,7 @@ class CollectionBuilder:
edits = {}
def get_summary(summary_method, summaries):
logger.info(f"Detail: {summary_method} updated Collection Summary")
logger.info(f"Detail: {summary_method} updated {self.Type} Summary")
return summaries[summary_method]
if "summary" in self.summaries: summary = get_summary("summary", self.summaries)
elif "tmdb_description" in self.summaries: summary = get_summary("tmdb_description", self.summaries)
@ -2176,8 +2172,12 @@ class CollectionBuilder:
else: summary = None
if summary:
if str(summary) != str(self.obj.summary):
edits["summary.value"] = summary
edits["summary.locked"] = 1
if self.playlist:
self.obj.edit(summary=str(summary))
logger.info("Details: have been updated")
else:
edits["summary.value"] = summary
edits["summary.locked"] = 1
if "sort_title" in self.details:
if str(self.details["sort_title"]) != str(self.obj.titleSort):
@ -2267,7 +2267,7 @@ class CollectionBuilder:
elif "tvdb_show_details" in self.posters: self.collection_poster = ImageData("tvdb_show_details", self.posters["tvdb_show_details"])
elif "tmdb_show_details" in self.posters: self.collection_poster = ImageData("tmdb_show_details", self.posters["tmdb_show_details"])
else:
logger.info("No poster collection detail or asset folder found")
logger.info(f"No poster {self.type} detail or asset folder found")
self.collection_background = None
if len(self.backgrounds) > 0:
@ -2286,35 +2286,28 @@ class CollectionBuilder:
elif "tvdb_show_details" in self.backgrounds: self.collection_background = ImageData("tvdb_show_details", self.backgrounds["tvdb_show_details"], is_poster=False)
elif "tmdb_show_details" in self.backgrounds: self.collection_background = ImageData("tmdb_show_details", self.backgrounds["tmdb_show_details"], is_poster=False)
else:
logger.info("No background collection detail or asset folder found")
logger.info(f"No background {self.type} detail or asset folder found")
if self.collection_poster or self.collection_background:
self.library.upload_images(self.obj, poster=self.collection_poster, background=self.collection_background)
def sort_collection(self):
logger.info("")
util.separator(f"Sorting {self.name} Collection", space=False, border=False)
util.separator(f"Sorting {self.name} {self.Type}", space=False, border=False)
logger.info("")
if self.sort_by:
search_data = self.build_filter("plex_search", {"sort_by": self.sort_by, "any": {"collection": self.name}})
keys = {}
rating_keys = []
for item in self.library.get_filter_items(search_data[2]):
keys[item.ratingKey] = item
rating_keys.append(item.ratingKey)
items = self.library.get_filter_items(search_data[2])
else:
keys = {_i.ratingKey: _i for _i in self.library.get_collection_items(self.obj, self.smart_label_collection)}
rating_keys = self.rating_keys
items = self.added_items
previous = None
logger.debug(keys)
logger.debug(rating_keys)
for key in rating_keys:
text = f"after {self.item_title(keys[previous])}" if previous else "to the beginning"
logger.info(f"Moving {self.item_title(keys[key])} {text}")
self.library.move_item(self.obj, key, after=previous)
previous = key
for item in items:
text = f"after {util.item_title(previous)}" if previous else "to the beginning"
logger.info(f"Moving {util.item_title(item)} {text}")
self.library.moveItem(self.obj, item, previous)
previous = item
def send_notifications(self):
def send_notifications(self, playlist=False):
if self.obj and self.details["collection_changes_webhooks"] and \
(self.created or len(self.notification_additions) > 0 or len(self.notification_removals) > 0):
self.obj.reload()
@ -2327,7 +2320,8 @@ class CollectionBuilder:
created=self.created,
deleted=self.deleted,
additions=self.notification_additions,
removals=self.notification_removals
removals=self.notification_removals,
playlist=playlist
)
except Failed as e:
util.print_stacktrace()
@ -2354,10 +2348,10 @@ class CollectionBuilder:
logger.error(f"Plex Error: Item {rating_key} not found")
continue
if current in collection_items:
logger.info(f"{name} Collection | = | {self.item_title(current)}")
logger.info(f"{name} {self.Type} | = | {util.item_title(current)}")
else:
self.library.alter_collection(current, name, smart_label_collection=self.smart_label_collection)
logger.info(f"{name} Collection | + | {self.item_title(current)}")
logger.info(f"{name} {self.Type} | + | {util.item_title(current)}")
if self.library.is_movie and current.ratingKey in self.library.movie_rating_key_map:
add_id = self.library.movie_rating_key_map[current.ratingKey]
elif self.library.is_show and current.ratingKey in self.library.show_rating_key_map:
@ -2379,7 +2373,7 @@ class CollectionBuilder:
continue
if self.details["show_missing"] is True:
current_title = f"{movie.title} ({util.validate_date(movie.release_date, 'test').year})" if movie.release_date else movie.title
logger.info(f"{name} Collection | ? | {current_title} (TMDb: {missing_id})")
logger.info(f"{name} {self.Type} | ? | {current_title} (TMDb: {missing_id})")
logger.info("")
logger.info(f"{len(self.run_again_movies)} Movie{'s' if len(self.run_again_movies) > 1 else ''} Missing")
@ -2393,5 +2387,5 @@ class CollectionBuilder:
logger.error(e)
continue
if self.details["show_missing"] is True:
logger.info(f"{name} Collection | ? | {title} (TVDb: {missing_id})")
logger.info(f"{name} {self.Type} | ? | {title} (TVDb: {missing_id})")
logger.info(f"{len(self.run_again_shows)} Show{'s' if len(self.run_again_shows) > 1 else ''} Missing")

View file

@ -13,6 +13,7 @@ from modules.letterboxd import Letterboxd
from modules.mal import MyAnimeList
from modules.notifiarr import Notifiarr
from modules.omdb import OMDb
from modules.playlist import PlaylistFile
from modules.plex import Plex
from modules.radarr import Radarr
from modules.sonarr import Sonarr
@ -28,7 +29,7 @@ from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager")
sync_modes = {"append": "Only Add Items to the Collection", "sync": "Add & Remove Items from the Collection"}
sync_modes = {"append": "Only Add Items to the Collection or Playlist", "sync": "Add & Remove Items from the Collection or Playlist"}
mass_update_options = {"tmdb": "Use TMDb Metadata", "omdb": "Use IMDb Metadata through OMDb"}
class ConfigFile:
@ -94,6 +95,7 @@ class ConfigFile:
hooks("collection_removal")
new_config["libraries"][library]["webhooks"]["collection_changes"] = changes if changes else None
if "libraries" in new_config: new_config["libraries"] = new_config.pop("libraries")
if "playlists" in new_config: new_config["playlists"] = new_config.pop("playlists")
if "settings" in new_config: new_config["settings"] = new_config.pop("settings")
if "webhooks" in new_config:
temp = new_config.pop("webhooks")
@ -342,8 +344,6 @@ class ConfigFile:
else:
logger.warning("mal attribute not found")
util.separator()
self.AniDB = None
if "anidb" in self.data:
util.separator()
@ -360,6 +360,58 @@ class ConfigFile:
if self.AniDB is None:
self.AniDB = AniDB(self, None)
util.separator()
self.playlist_names = []
self.playlist_files = []
if "playlists" in self.data:
logger.info("Reading in Playlist Files")
if self.data["playlists"] is None:
raise Failed("Config Error: playlists attribute is blank")
playlists_pairs = []
paths_to_check = self.data["playlists"] if isinstance(self.data["playlists"], list) else [self.data["playlists"]]
for path in paths_to_check:
if isinstance(path, dict):
def check_dict(attr):
if attr in path:
if path[attr] is None:
err = f"Config Error: playlists {attr} is blank"
self.errors.append(err)
logger.error(err)
else:
return path[attr]
url = check_dict("url")
if url:
playlists_pairs.append(("URL", url))
git = check_dict("git")
if git:
playlists_pairs.append(("Git", git))
file = check_dict("file")
if file:
playlists_pairs.append(("File", file))
folder = check_dict("folder")
if folder:
if os.path.isdir(folder):
yml_files = util.glob_filter(os.path.join(folder, "*.yml"))
if yml_files:
playlists_pairs.extend([("File", yml) for yml in yml_files])
else:
logger.error(f"Config Error: No YAML (.yml) files found in {folder}")
else:
logger.error(f"Config Error: Folder not found: {folder}")
else:
playlists_pairs.append(("File", path))
for file_type, playlist_file in playlists_pairs:
try:
playlist_obj = PlaylistFile(self, file_type, playlist_file)
self.playlist_names.extend([p for p in playlist_obj.playlists])
self.playlist_files.append(playlist_obj)
except Failed as e:
util.print_multiline(e, error=True)
else:
logger.warning("playlists attribute not found")
self.TVDb = TVDb(self, self.general["tvdb_language"])
self.IMDb = IMDb(self)
self.Convert = Convert(self)
@ -423,7 +475,6 @@ class ConfigFile:
for library_name, lib in libs.items():
if self.requested_libraries and library_name not in self.requested_libraries:
continue
util.separator()
params = {
"mapping_name": str(library_name),
"name": str(lib["library_name"]) if lib and "library_name" in lib and lib["library_name"] else str(library_name)
@ -674,10 +725,10 @@ class ConfigFile:
self.notify(e)
raise
def notify(self, text, library=None, collection=None, critical=True):
def notify(self, text, server=None, library=None, collection=None, playlist=None, critical=True):
for error in util.get_list(text, split=False):
try:
self.Webhooks.error_hooks(error, library=library, collection=collection, critical=critical)
self.Webhooks.error_hooks(error, server=server, library=library, collection=collection, playlist=playlist, critical=critical)
except Failed as e:
util.print_stacktrace()
logger.error(f"Webhooks Error: {e}")

View file

@ -81,7 +81,7 @@ class FlixPatrol:
list_url = flixpatrol_list.strip()
if not list_url.startswith(tuple([v for k, v in urls.items()])):
fails = "\n".join([f"{v} (For {k.replace('_', ' ').title()})" for k, v in urls.items()])
raise Failed(f"FlixPatrol Error: {list_url} must begin with either:{fails}")
raise Failed(f"FlixPatrol Error: {list_url} must begin with either:\n{fails}")
elif len(self._parse_list(list_url, language, is_movie)) > 0:
valid_lists.append(list_url)
else:

View file

@ -192,8 +192,9 @@ class Library(ABC):
if background_uploaded:
self.config.Cache.update_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds", item.art, background.compare)
@abstractmethod
def notify(self, text, collection=None, critical=True):
self.config.notify(text, library=self, collection=collection, critical=critical)
pass
@abstractmethod
def _upload_image(self, item, image):

66
modules/playlist.py Normal file
View file

@ -0,0 +1,66 @@
import logging, os, re
from datetime import datetime
from modules import plex, util
from modules.util import Failed, ImageData
from plexapi.exceptions import NotFound
from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager")
github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/"
class PlaylistFile:
def __init__(self, config, file_type, path):
self.config = config
self.type = file_type
self.path = path
self.playlists = {}
self.templates = {}
try:
logger.info("")
logger.info(f"Loading Playlist File {file_type}: {path}")
if file_type in ["URL", "Git"]:
content_path = path if file_type == "URL" else f"{github_base}{path}.yml"
response = self.config.get(content_path)
if response.status_code >= 400:
raise Failed(f"URL Error: No file found at {content_path}")
content = response.content
elif os.path.exists(os.path.abspath(path)):
content = open(path, encoding="utf-8")
else:
raise Failed(f"File Error: File does not exist {path}")
data, ind, bsi = yaml.util.load_yaml_guess_indent(content)
if data and "playlists" in data:
if data["playlists"]:
if isinstance(data["playlists"], dict):
for _name, _data in data["playlists"].items():
if _name in self.config.playlist_names:
logger.error(f"Config Warning: Skipping duplicate playlist: {_name}")
elif _data is None:
logger.error(f"Config Warning: playlist: {_name} has no data")
elif not isinstance(_data, dict):
logger.error(f"Config Warning: playlist: {_name} must be a dictionary")
else:
self.playlists[str(_name)] = _data
else:
logger.warning(f"Config Warning: playlists must be a dictionary")
else:
logger.warning(f"Config Warning: playlists attribute is blank")
if not self.playlists:
raise Failed("YAML Error: playlists attribute is required")
if data and "templates" in data:
if data["templates"]:
if isinstance(data["templates"], dict):
for _name, _data in data["templates"].items():
self.templates[str(_name)] = _data
else:
logger.warning(f"Config Warning: templates must be a dictionary")
else:
logger.warning(f"Config Warning: templates attribute is blank")
logger.info(f"Playlist File Loaded Successfully")
except yaml.scanner.ScannerError as ye:
raise Failed(f"YAML Error: {util.tab_new_lines(ye)}")
except Exception as e:
util.print_stacktrace()
raise Failed(f"YAML Error: {e}")

View file

@ -5,6 +5,7 @@ from modules.util import Failed, ImageData
from plexapi import utils
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.collection import Collection
from plexapi.playlist import Playlist
from plexapi.server import PlexServer
from retrying import retry
from urllib import parse
@ -264,6 +265,9 @@ class Plex(Library):
self.tmdb_collections = None
logger.error("Config Error: tmdb_collections only work with Movie Libraries.")
def notify(self, text, collection=None, critical=True):
self.config.notify(text, server=self.PlexServer.friendlyName, library=self.name, collection=collection, critical=critical)
def set_server_preroll(self, preroll):
self.PlexServer.settings.get('cinemaTrailersPrerollID').set(preroll)
self.PlexServer.settings.save()
@ -304,10 +308,18 @@ class Plex(Library):
logger.info(util.adjust_space(f"Loaded {self.Plex._totalViewSize} {self.type}s"))
return results
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def create_playlist(self, name, items):
return self.PlexServer.createPlaylist(name, items=items)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def fetchItems(self, key, container_start, container_size):
return self.Plex.fetchItems(key, container_start=container_start, container_size=container_size)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def moveItem(self, obj, item, after):
obj.moveItem(item, after=after)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def query(self, method):
return method()
@ -475,6 +487,12 @@ class Plex(Library):
key += f"&promotedToSharedHome={1 if (shared is None and visibility['shared']) or shared else 0}"
self._query(key, post=True)
def get_playlist(self, title):
try:
return self.PlexServer.playlist(title)
except NotFound:
raise Failed(f"Plex Error: Playlist {title} not found")
def get_collection(self, data):
if isinstance(data, int):
return self.fetchItem(data)
@ -553,7 +571,7 @@ class Plex(Library):
def get_collection_items(self, collection, smart_label_collection):
if smart_label_collection:
return self.get_labeled_items(collection.title if isinstance(collection, Collection) else str(collection))
elif isinstance(collection, Collection):
elif isinstance(collection, (Collection, Playlist)):
if collection.smart:
return self.get_filter_items(self.smart_filter(collection))
else:
@ -566,7 +584,7 @@ class Plex(Library):
return self.Plex._search(key, None, 0, plexapi.X_PLEX_CONTAINER_SIZE)
def get_collection_name_and_items(self, collection, smart_label_collection):
name = collection.title if isinstance(collection, Collection) else str(collection)
name = collection.title if isinstance(collection, (Collection, Playlist)) else str(collection)
return name, self.get_collection_items(collection, smart_label_collection)
def get_tmdb_from_map(self, item):

View file

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from logging.handlers import RotatingFileHandler
from pathvalidate import is_valid_filename, sanitize_filename
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.video import Season, Episode, Movie
try:
import msvcrt
@ -241,6 +242,23 @@ def validate_filename(filename):
mapping_name = sanitize_filename(filename)
return mapping_name, f"Log Folder Name: {filename} is invalid using {mapping_name}"
def item_title(item):
if isinstance(item, Season):
if f"Season {item.index}" == item.title:
return f"{item.parentTitle} {item.title}"
else:
return f"{item.parentTitle} Season {item.index}: {item.title}"
elif isinstance(item, Episode):
text = f"{item.grandparentTitle} S{add_zero(item.parentIndex)}E{add_zero(item.index)}"
if f"Season {item.parentIndex}" == item.parentTitle:
return f"{text}: {item.title}"
else:
return f"{text}: {item.parentTitle}: {item.title}"
elif isinstance(item, Movie) and item.year:
return f"{item.title} ({item.year})"
else:
return item.title
def is_locked(filepath):
locked = None
file_object = None

View file

@ -60,17 +60,20 @@ class Webhooks:
"added_to_sonarr": stats["sonarr"],
})
def error_hooks(self, text, library=None, collection=None, critical=True):
def error_hooks(self, text, server=None, library=None, collection=None, playlist=None, critical=True):
if self.error_webhooks:
json = {"error": str(text), "critical": critical}
if server:
json["server_name"] = str(server)
if library:
json["server_name"] = library.PlexServer.friendlyName
json["library_name"] = library.name
json["library_name"] = str(library)
if collection:
json["collection"] = str(collection)
if playlist:
json["playlist"] = str(playlist)
self._request(self.error_webhooks, json)
def collection_hooks(self, webhooks, collection, poster_url=None, background_url=None, created=False, deleted=False, additions=None, removals=None):
def collection_hooks(self, webhooks, collection, poster_url=None, background_url=None, created=False, deleted=False, additions=None, removals=None, playlist=False):
if self.library:
thumb = None
if not poster_url and collection.thumb and next((f for f in collection.fields if f.name == "thumb"), None):
@ -82,7 +85,7 @@ class Webhooks:
"server_name": self.library.PlexServer.friendlyName,
"library_name": self.library.name,
"type": "movie" if self.library.is_movie else "show",
"collection": collection.title,
"playlist" if playlist else "collection": collection.title,
"created": created,
"deleted": deleted,
"poster": thumb,

View file

@ -1,6 +1,10 @@
import argparse, logging, os, sys, time
from datetime import datetime
from logging.handlers import RotatingFileHandler
from plexapi.exceptions import NotFound
from plexapi.video import Show, Season
try:
import plexapi, schedule
from modules import util
@ -279,6 +283,290 @@ def update_libraries(config):
util.print_stacktrace()
util.print_multiline(e, critical=True)
if config.playlist_files:
library_map = {_l.original_mapping_name: _l for _l in config.libraries}
os.makedirs(os.path.join(default_dir, "logs", "playlists"), exist_ok=True)
pf_file_logger = os.path.join(default_dir, "logs", "playlists", "playlists.log")
should_roll_over = os.path.isfile(pf_file_logger)
playlists_handler = RotatingFileHandler(pf_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(playlists_handler)
if should_roll_over:
playlists_handler.doRollover()
logger.addHandler(playlists_handler)
logger.info("")
util.separator("Playlists")
logger.info("")
for playlist_file in config.playlist_files:
for mapping_name, playlist_attrs in playlist_file.playlists.items():
playlist_start = datetime.now()
if config.test_mode and ("test" not in playlist_attrs or playlist_attrs["test"] is not True):
no_template_test = True
if "template" in playlist_attrs and playlist_attrs["template"]:
for data_template in util.get_list(playlist_attrs["template"], split=False):
if "name" in data_template \
and data_template["name"] \
and playlist_file.templates \
and data_template["name"] in playlist_file.templates \
and playlist_file.templates[data_template["name"]] \
and "test" in playlist_file.templates[data_template["name"]] \
and playlist_file.templates[data_template["name"]]["test"] is True:
no_template_test = False
if no_template_test:
continue
if "name_mapping" in playlist_attrs and playlist_attrs["name_mapping"]:
playlist_log_name, output_str = util.validate_filename(playlist_attrs["name_mapping"])
else:
playlist_log_name, output_str = util.validate_filename(mapping_name)
playlist_log_folder = os.path.join(default_dir, "logs", "playlists", playlist_log_name)
os.makedirs(playlist_log_folder, exist_ok=True)
ply_file_logger = os.path.join(playlist_log_folder, "playlist.log")
should_roll_over = os.path.isfile(ply_file_logger)
playlist_handler = RotatingFileHandler(ply_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(playlist_handler)
if should_roll_over:
playlist_handler.doRollover()
logger.addHandler(playlist_handler)
server_name = None
library_names = None
try:
util.separator(f"{mapping_name} Playlist")
logger.info("")
if output_str:
logger.info(output_str)
logger.info("")
if "libraries" not in playlist_attrs or not playlist_attrs["libraries"]:
raise Failed("Playlist Error: libraries attribute is required and cannot be blank")
pl_libraries = []
for pl_library in util.get_list(playlist_attrs["libraries"]):
if str(pl_library) in library_map:
pl_libraries.append(library_map[pl_library])
else:
raise Failed(f"Playlist Error: Library: {pl_library} not defined")
server_check = None
for pl_library in pl_libraries:
if server_check:
if pl_library.PlexServer.machineIdentifier != server_check:
raise Failed("Playlist Error: All defined libraries must be on the same server")
else:
server_check = pl_library.PlexServer.machineIdentifier
util.separator(f"Validating {mapping_name} Attributes", space=False, border=False)
builder = CollectionBuilder(config, pl_libraries[0], playlist_file, mapping_name, no_missing, playlist_attrs, playlist=True)
logger.info("")
util.separator(f"Running {mapping_name} Playlist", space=False, border=False)
if len(builder.schedule) > 0:
util.print_multiline(builder.schedule, info=True)
items_added = 0
items_removed = 0
logger.info("")
logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}")
if builder.filters or builder.tmdb_filters:
logger.info("")
for filter_key, filter_value in builder.filters:
logger.info(f"Playlist Filter {filter_key}: {filter_value}")
for filter_key, filter_value in builder.tmdb_filters:
logger.info(f"Playlist Filter {filter_key}: {filter_value}")
method, value = builder.builders[0]
logger.debug("")
logger.debug(f"Builder: {method}: {value}")
logger.info("")
items = []
ids = builder.gather_ids(method, value)
if len(ids) > 0:
total_ids = len(ids)
logger.debug("")
logger.debug(f"{total_ids} IDs Found: {ids}")
for i, input_data in enumerate(ids, 1):
input_id, id_type = input_data
util.print_return(f"Parsing ID {i}/{total_ids}")
if id_type == "tvdb_season":
show_id, season_num = input_id.split("_")
show_id = int(show_id)
found = False
for pl_library in pl_libraries:
if show_id in pl_library.show_map:
found = True
show_item = pl_library.fetchItem(pl_library.show_map[show_id][0])
try:
items.extend(show_item.season(season=int(season_num)).episodes())
except NotFound:
builder.missing_parts.append(f"{show_item.title} Season: {season_num} Missing")
break
if not found and show_id not in builder.missing_shows:
builder.missing_shows.append(show_id)
elif id_type == "tvdb_episode":
show_id, season_num, episode_num = input_id.split("_")
show_id = int(show_id)
found = False
for pl_library in pl_libraries:
if show_id in pl_library.show_map:
found = True
show_item = pl_library.fetchItem(pl_library.show_map[show_id][0])
try:
items.append(show_item.episode(season=int(season_num), episode=int(episode_num)))
except NotFound:
builder.missing_parts.append(f"{show_item.title} Season: {season_num} Episode: {episode_num} Missing")
break
if not found and show_id not in builder.missing_shows:
builder.missing_shows.append(show_id)
else:
rating_keys = []
if id_type == "ratingKey":
rating_keys = input_id
elif id_type == "tmdb":
if input_id not in builder.ignore_ids:
found = False
for pl_library in pl_libraries:
if input_id in pl_library.movie_map:
found = True
rating_keys = pl_library.movie_map[input_id]
break
if not found and input_id not in builder.missing_movies:
builder.missing_movies.append(input_id)
elif id_type in ["tvdb", "tmdb_show"]:
if id_type == "tmdb_show":
try:
input_id = config.Convert.tmdb_to_tvdb(input_id, fail=True)
except Failed as e:
logger.error(e)
continue
if input_id not in builder.ignore_ids:
found = False
for pl_library in pl_libraries:
if input_id in pl_library.show_map:
found = True
rating_keys = pl_library.show_map[input_id]
break
if not found and input_id not in builder.missing_shows:
builder.missing_shows.append(input_id)
elif id_type == "imdb":
if input_id not in builder.ignore_imdb_ids:
found = False
for pl_library in pl_libraries:
if input_id in pl_library.imdb_map:
found = True
rating_keys = pl_library.imdb_map[input_id]
break
if not found and builder.do_missing:
try:
tmdb_id, tmdb_type = config.Convert.imdb_to_tmdb(input_id, fail=True)
if tmdb_type == "movie":
if tmdb_id not in builder.missing_movies:
builder.missing_movies.append(tmdb_id)
else:
tvdb_id = config.Convert.tmdb_to_tvdb(tmdb_id, fail=True)
if tvdb_id not in builder.missing_shows:
builder.missing_shows.append(tvdb_id)
except Failed as e:
logger.error(e)
continue
if not isinstance(rating_keys, list):
rating_keys = [rating_keys]
for rk in rating_keys:
try:
item = builder.fetch_item(rk)
if isinstance(item, (Show, Season)):
items.extend(item.episodes())
else:
items.append(item)
except Failed as e:
logger.error(e)
util.print_end()
if len(items) > 0:
builder.filter_and_save_items(items)
if len(builder.added_items) >= builder.minimum:
logger.info("")
util.separator(f"Adding to {mapping_name} Playlist", space=False, border=False)
logger.info("")
items_added = builder.add_to_collection()
stats["added"] += items_added
items_removed = 0
if builder.sync:
items_removed = builder.sync_collection()
stats["removed"] += items_removed
elif len(builder.added_items) < builder.minimum:
logger.info("")
logger.info(f"Playlist Minimum: {builder.minimum} not met for {mapping_name} Playlist")
if builder.details["delete_below_minimum"] and builder.obj:
builder.delete_collection()
builder.deleted = True
logger.info("")
logger.info(f"Playlist {builder.obj.title} deleted")
if builder.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0):
if builder.details["show_missing"] is True:
logger.info("")
util.separator(f"Missing from Library", space=False, border=False)
logger.info("")
radarr_add, sonarr_add = builder.run_missing()
stats["radarr"] += radarr_add
stats["sonarr"] += sonarr_add
run_item_details = True
try:
builder.load_collection()
if builder.created:
stats["created"] += 1
elif items_added > 0 or items_removed > 0:
stats["modified"] += 1
except Failed:
util.print_stacktrace()
run_item_details = False
logger.info("")
util.separator("No Playlist to Update", space=False, border=False)
else:
builder.update_details()
if builder.deleted:
stats["deleted"] += 1
if (builder.item_details or builder.custom_sort) and run_item_details and builder.builders:
try:
builder.load_collection_items()
except Failed:
logger.info("")
util.separator("No Items Found", space=False, border=False)
else:
if builder.item_details:
builder.update_item_details()
if builder.custom_sort:
builder.sort_collection()
builder.send_notifications()
except NotScheduled as e:
util.print_multiline(e, info=True)
except Failed as e:
config.notify(e, server=server_name, library=library_names, playlist=mapping_name)
util.print_stacktrace()
util.print_multiline(e, error=True)
except Exception as e:
config.notify(f"Unknown Error: {e}", server=server_name, library=library_names, playlist=mapping_name)
util.print_stacktrace()
logger.error(f"Unknown Error: {e}")
logger.info("")
util.separator(f"Finished {mapping_name} Playlist\nPlaylist Run Time: {str(datetime.now() - playlist_start).split('.')[0]}")
logger.removeHandler(playlist_handler)
logger.removeHandler(playlists_handler)
has_run_again = False
for library in config.libraries:
if library.run_again:
@ -675,7 +963,7 @@ def run_collection(config, library, metadata, requested_collections):
builder.find_rating_keys()
if len(builder.rating_keys) >= builder.minimum and builder.build_collection:
if len(builder.added_items) >= builder.minimum and builder.build_collection:
logger.info("")
util.separator(f"Adding to {mapping_name} Collection", space=False, border=False)
logger.info("")
@ -685,7 +973,7 @@ def run_collection(config, library, metadata, requested_collections):
if builder.sync:
items_removed = builder.sync_collection()
stats["removed"] += items_removed
elif len(builder.rating_keys) < builder.minimum and builder.build_collection:
elif len(builder.added_items) < builder.minimum and builder.build_collection:
logger.info("")
logger.info(f"Collection Minimum: {builder.minimum} not met for {mapping_name} Collection")
if builder.details["delete_below_minimum"] and builder.obj:
@ -718,9 +1006,6 @@ def run_collection(config, library, metadata, requested_collections):
util.separator("No Collection to Update", space=False, border=False)
else:
builder.update_details()
if builder.custom_sort or builder.sort_by:
library.run_sort.append(builder)
# builder.sort_collection()
if builder.deleted:
stats["deleted"] += 1
@ -730,14 +1015,18 @@ def run_collection(config, library, metadata, requested_collections):
logger.info("")
logger.info(f"Plex Server Movie pre-roll video updated to {builder.server_preroll}")
if builder.item_details and run_item_details and builder.builders:
if (builder.item_details or builder.custom_sort or builder.sort_by) and run_item_details and builder.builders:
try:
builder.load_collection_items()
except Failed:
logger.info("")
util.separator("No Items Found", space=False, border=False)
else:
builder.update_item_details()
if builder.item_details:
builder.update_item_details()
if builder.custom_sort or builder.sort_by:
library.run_sort.append(builder)
# builder.sort_collection()
builder.send_notifications()

View file

@ -1,7 +1,7 @@
PlexAPI==4.8.0
tmdbv3api==1.7.6
arrapi==1.2.8
lxml==4.6.4
lxml==4.7.1
requests==2.26.0
ruamel.yaml==0.17.17
schedule==1.1.0