Merge pull request #264 from meisnate12/develop

v1.9.2
pull/288/head v1.9.2
meisnate12 4 years ago committed by GitHub
commit 2770d1733a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,5 +1,5 @@
# Plex Meta Manager # Plex Meta Manager
#### Version 1.9.1 #### Version 1.9.2
The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YAML configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services. The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YAML configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services.
@ -19,7 +19,7 @@ The script is designed to work with most Metadata agents including the new Plex
## Support ## Support
* Before posting on Github about an enhancement, error, or configuration question please visit the [Plex Meta Manager Discord Server](https://discord.gg/NfH6mGFuAB). * Before posting on Github about an enhancement, error, or configuration question please visit the [Plex Meta Manager Discord Server](https://discord.gg/TsdpsFYqqm).
* If you're getting an Error or have an Enhancement post in the [Issues](https://github.com/meisnate12/Plex-Meta-Manager/issues). * If you're getting an Error or have an Enhancement post in the [Issues](https://github.com/meisnate12/Plex-Meta-Manager/issues).
* If you have a configuration question post in the [Discussions](https://github.com/meisnate12/Plex-Meta-Manager/discussions). * If you have a configuration question post in the [Discussions](https://github.com/meisnate12/Plex-Meta-Manager/discussions).
* To see user submitted Metadata configuration files, and you to even add your own, go to the [Plex Meta Manager Configs](https://github.com/meisnate12/Plex-Meta-Manager-Configs). * To see user submitted Metadata configuration files, and you to even add your own, go to the [Plex Meta Manager Configs](https://github.com/meisnate12/Plex-Meta-Manager-Configs).

@ -1,4 +1,4 @@
import glob, logging, os, re import logging, os, re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from modules import anidb, anilist, imdb, letterboxd, mal, plex, radarr, sonarr, tautulli, tmdb, trakttv, tvdb, util from modules import anidb, anilist, imdb, letterboxd, mal, plex, radarr, sonarr, tautulli, tmdb, trakttv, tvdb, util
from modules.util import Failed from modules.util import Failed
@ -102,8 +102,6 @@ numbered_builders = [
] ]
smart_collection_invalid = ["collection_mode", "collection_order"] smart_collection_invalid = ["collection_mode", "collection_order"]
smart_url_collection_invalid = [ smart_url_collection_invalid = [
"item_label", "item_label.sync", "item_episode_sorting", "item_keep_episodes", "item_delete_episodes",
"item_season_display", "item_episode_ordering", "item_metadata_language", "item_use_original_title",
"run_again", "sync_mode", "show_filtered", "show_missing", "save_missing", "smart_label", "run_again", "sync_mode", "show_filtered", "show_missing", "save_missing", "smart_label",
"radarr_add", "radarr_folder", "radarr_monitor", "radarr_availability", "radarr_add", "radarr_folder", "radarr_monitor", "radarr_availability",
"radarr_quality", "radarr_tag", "radarr_search", "radarr_quality", "radarr_tag", "radarr_search",
@ -494,17 +492,19 @@ class CollectionBuilder:
for smart_key, smart_data in filter_dict.items(): for smart_key, smart_data in filter_dict.items():
smart, smart_mod, smart_final = _split(smart_key) smart, smart_mod, smart_final = _split(smart_key)
def build_url_arg(arg, mod=None, arg_s=None, mod_s=None): def build_url_arg(arg, mod=None, arg_s=None, mod_s=None, param_s=None):
arg_key = plex.search_translation[smart] if smart in plex.search_translation else smart arg_key = plex.search_translation[smart] if smart in plex.search_translation else smart
if mod is None: if mod is None:
mod = plex.modifier_translation[smart_mod] if smart_mod in plex.search_translation else smart_mod mod = plex.modifier_translation[smart_mod] if smart_mod in plex.modifier_translation else smart_mod
if arg_s is None: if arg_s is None:
arg_s = arg arg_s = arg
if smart in string_filters and smart_mod in ["", ".not"]: if smart in string_filters and smart_mod in ["", ".not"]:
mod_s = "does not contain" if smart_mod == ".not" else "contains" mod_s = "does not contain" if smart_mod == ".not" else "contains"
elif mod_s is None: elif mod_s is None:
mod_s = plex.mod_displays[smart_mod] mod_s = plex.mod_displays[smart_mod]
display_line = f"{indent}{smart.title().replace('_', ' ')} {mod_s} {arg_s}" if param_s is None:
param_s = smart.title().replace('_', ' ')
display_line = f"{indent}{param_s} {mod_s} {arg_s}"
return f"{arg_key}{mod}={arg}&", display_line return f"{arg_key}{mod}={arg}&", display_line
if smart_final in plex.movie_only_smart_searches and self.library.is_show: if smart_final in plex.movie_only_smart_searches and self.library.is_show:
@ -540,8 +540,15 @@ class CollectionBuilder:
results, display_add = build_url_arg(util.check_number(smart_data, smart_final, minimum=1)) results, display_add = build_url_arg(util.check_number(smart_data, smart_final, minimum=1))
elif smart in ["user_rating", "episode_user_rating", "critic_rating", "audience_rating"] and smart_mod in [".gt", ".gte", ".lt", ".lte"]: elif smart in ["user_rating", "episode_user_rating", "critic_rating", "audience_rating"] and smart_mod in [".gt", ".gte", ".lt", ".lte"]:
results, display_add = build_url_arg(util.check_number(smart_data, smart_final, number_type="float", minimum=0, maximum=10)) results, display_add = build_url_arg(util.check_number(smart_data, smart_final, number_type="float", minimum=0, maximum=10))
elif smart == "hdr":
if isinstance(smart_data, bool):
hdr_mod = "" if smart_data else "!"
hdr_arg = "true" if smart_data else "false"
results, display_add = build_url_arg(1, mod=hdr_mod, arg_s=hdr_arg, mod_s="is", param_s="HDR")
else:
raise Failed("Collection Error: HDR must be true or false")
else: else:
if smart in ["title", "episode_title"] and smart_mod in ["", ".not", ".begins", ".ends"]: if smart in ["title", "episode_title", "studio"] and smart_mod in ["", ".not", ".begins", ".ends"]:
results_list = [(t, t) for t in util.get_list(smart_data, split=False)] results_list = [(t, t) for t in util.get_list(smart_data, split=False)]
elif smart in plex.tags and smart_mod in ["", ".not", ".begins", ".ends"]: elif smart in plex.tags and smart_mod in ["", ".not", ".begins", ".ends"]:
if smart_final in plex.tmdb_searches: if smart_final in plex.tmdb_searches:
@ -552,8 +559,6 @@ class CollectionBuilder:
smart_values.append(tmdb_name) smart_values.append(tmdb_name)
else: else:
smart_values.append(tmdb_value) smart_values.append(tmdb_value)
elif smart == "studio":
smart_values = util.get_list(smart_data, split=False)
else: else:
smart_values = util.get_list(smart_data) smart_values = util.get_list(smart_data)
results_list = [] results_list = []
@ -640,10 +645,8 @@ class CollectionBuilder:
raise Failed(f"Collection Error: {method_name} attribute only works with normal collections") raise Failed(f"Collection Error: {method_name} attribute only works with normal collections")
elif method_name not in collectionless_details and self.collectionless: elif method_name not in collectionless_details and self.collectionless:
raise Failed(f"Collection Error: {method_name} attribute does not work for Collectionless collection") raise Failed(f"Collection Error: {method_name} attribute does not work for Collectionless collection")
elif self.smart_url and method_name in all_builders: elif self.smart_url and (method_name in all_builders or method_name in smart_url_collection_invalid):
raise Failed(f"Collection Error: {method_name} builder not allowed when using smart_url") raise Failed(f"Collection Error: {method_name} builder not allowed when using smart_filter")
elif self.smart_url and method_name in smart_url_collection_invalid:
raise Failed(f"Collection Error: {method_name} detail not allowed when using smart_url")
elif method_name == "summary": elif method_name == "summary":
self.summaries[method_name] = method_data self.summaries[method_name] = method_data
elif method_name == "tmdb_summary": elif method_name == "tmdb_summary":
@ -703,16 +706,20 @@ class CollectionBuilder:
elif method_name == "sync_mode": elif method_name == "sync_mode":
if str(method_data).lower() in ["append", "sync"]: self.details[method_name] = method_data.lower() if str(method_data).lower() in ["append", "sync"]: self.details[method_name] = method_data.lower()
else: raise Failed("Collection Error: sync_mode attribute must be either 'append' or 'sync'") else: raise Failed("Collection Error: sync_mode attribute must be either 'append' or 'sync'")
elif method_name in ["label", "label.sync"]: elif method_name in ["label", "label.remove", "label.sync"]:
if "label" in self.data and "label.sync" in self.data: if "label" in self.data and "label.sync" in self.data:
raise Failed(f"Collection Error: Cannot use label and label.sync together") raise Failed(f"Collection Error: Cannot use label and label.sync together")
if "label.remove" in self.data and "label.sync" in self.data:
raise Failed(f"Collection Error: Cannot use label.remove and label.sync together")
if method_name == "label" and "label_sync_mode" in self.data and self.data["label_sync_mode"] == "sync": if method_name == "label" and "label_sync_mode" in self.data and self.data["label_sync_mode"] == "sync":
self.details["label.sync"] = util.get_list(method_data) self.details["label.sync"] = util.get_list(method_data)
else: else:
self.details[method_name] = util.get_list(method_data) self.details[method_name] = util.get_list(method_data)
elif method_name in ["item_label", "item_label.sync"]: elif method_name in ["item_label", "item_label.remove", "item_label.sync"]:
if "item_label" in self.data and "item_label.sync" in self.data: if "item_label" in self.data and "item_label.sync" in self.data:
raise Failed(f"Collection Error: Cannot use item_label and item_label.sync together") raise Failed(f"Collection Error: Cannot use item_label and item_label.sync together")
if "item_label.remove" in self.data and "item_label.sync" in self.data:
raise Failed(f"Collection Error: Cannot use item_label.remove and item_label.sync together")
self.item_details[method_name] = util.get_list(method_data) self.item_details[method_name] = util.get_list(method_data)
elif method_name in plex.item_advance_keys: elif method_name in plex.item_advance_keys:
key, options = plex.item_advance_keys[method_name] key, options = plex.item_advance_keys[method_name]
@ -768,7 +775,7 @@ class CollectionBuilder:
self.sonarr_options[method_name[7:]] = util.get_bool(method_name, method_data) self.sonarr_options[method_name[7:]] = util.get_bool(method_name, method_data)
elif method_name == "sonarr_tag": elif method_name == "sonarr_tag":
self.sonarr_options["tag"] = util.get_list(method_data) self.sonarr_options["tag"] = util.get_list(method_data)
elif method_name in ["title", "title.and", "title.not", "title.begins", "title.ends"]: elif method_name in ["title", "title.and", "title.not", "title.begins", "studio.ends", "studio", "studio.and", "studio.not", "studio.begins", "studio.ends"]:
self.methods.append(("plex_search", [{method_name: util.get_list(method_data, split=False)}])) self.methods.append(("plex_search", [{method_name: util.get_list(method_data, split=False)}]))
elif method_name in ["year.gt", "year.gte", "year.lt", "year.lte"]: elif method_name in ["year.gt", "year.gte", "year.lt", "year.lte"]:
self.methods.append(("plex_search", [{method_name: util.check_year(method_data, current_year, method_name)}])) self.methods.append(("plex_search", [{method_name: util.check_year(method_data, current_year, method_name)}]))
@ -928,8 +935,6 @@ class CollectionBuilder:
raise Failed(f"Collection Error: {search_final} plex search attribute only works for movie libraries") raise Failed(f"Collection Error: {search_final} plex search attribute only works for movie libraries")
elif search_final in plex.show_only_searches and self.library.is_movie: elif search_final in plex.show_only_searches and self.library.is_movie:
raise Failed(f"Collection Error: {search_final} plex search attribute only works for show libraries") raise Failed(f"Collection Error: {search_final} plex search attribute only works for show libraries")
elif search_final not in plex.searches:
raise Failed(f"Collection Error: {search_final} is not a valid plex search attribute")
elif search_data is None: elif search_data is None:
raise Failed(f"Collection Error: {search_final} plex search attribute is blank") raise Failed(f"Collection Error: {search_final} plex search attribute is blank")
elif search == "sort_by": elif search == "sort_by":
@ -944,7 +949,9 @@ class CollectionBuilder:
raise Failed(f"Collection Warning: plex search limit attribute: {search_data} must be an integer greater then 0") raise Failed(f"Collection Warning: plex search limit attribute: {search_data} must be an integer greater then 0")
else: else:
searches[search] = search_data searches[search] = search_data
elif search == "title" and modifier in ["", ".and", ".not", ".begins", ".ends"]: elif search_final not in plex.searches:
raise Failed(f"Collection Error: {search_final} is not a valid plex search attribute")
elif search in ["title", "studio"] and modifier in ["", ".and", ".not", ".begins", ".ends"]:
searches[search_final] = util.get_list(search_data, split=False) searches[search_final] = util.get_list(search_data, split=False)
elif search in plex.tags and modifier in ["", ".and", ".not", ".begins", ".ends"]: elif search in plex.tags and modifier in ["", ".and", ".not", ".begins", ".ends"]:
if search_final in plex.tmdb_searches: if search_final in plex.tmdb_searches:
@ -1244,21 +1251,32 @@ class CollectionBuilder:
self.details["collection_mode"] = "hide" self.details["collection_mode"] = "hide"
self.sync = True self.sync = True
try: self.build_collection = True
self.obj = library.get_collection(self.name) if "build_collection" in methods:
collection_smart = library.smart(self.obj) if not self.data[methods["build_collection"]]:
if (self.smart and not collection_smart) or (not self.smart and collection_smart): logger.warning(f"Collection Warning: build_collection attribute is blank defaulting to true")
logger.info("") else:
logger.error(f"Collection Error: Converting {self.obj.title} to a {'smart' if self.smart else 'normal'} collection") self.build_collection = util.get_bool("build_collection", self.data[methods["build_collection"]])
library.query(self.obj.delete)
if self.build_collection:
try:
self.obj = library.get_collection(self.name)
collection_smart = library.smart(self.obj)
if (self.smart and not collection_smart) or (not self.smart and collection_smart):
logger.info("")
logger.error(f"Collection Error: Converting {self.obj.title} to a {'smart' if self.smart else 'normal'} collection")
library.query(self.obj.delete)
self.obj = None
except Failed:
self.obj = None self.obj = None
except Failed:
self.obj = None
self.plex_map = {} self.plex_map = {}
if self.sync and self.obj: if self.sync and self.obj:
for item in library.get_collection_items(self.obj, self.smart_label_collection): for item in library.get_collection_items(self.obj, self.smart_label_collection):
self.plex_map[item.ratingKey] = item self.plex_map[item.ratingKey] = item
else:
self.sync = False
self.run_again = False
def collect_rating_keys(self, movie_map, show_map): def collect_rating_keys(self, movie_map, show_map):
def add_rating_keys(keys): def add_rating_keys(keys):
@ -1583,21 +1601,27 @@ class CollectionBuilder:
self.library.collection_order_query(self.obj, self.details["collection_order"]) self.library.collection_order_query(self.obj, self.details["collection_order"])
logger.info(f"Detail: collection_order updated Collection Order to {self.details['collection_order']}") logger.info(f"Detail: collection_order updated Collection Order to {self.details['collection_order']}")
if "label" in self.details or "label.sync" in self.details: if "label" in self.details or "label.remove" in self.details or "label.sync" in self.details:
item_labels = [label.tag for label in self.obj.labels] item_labels = [label.tag for label in self.obj.labels]
labels = util.get_list(self.details["label" if "label" in self.details else "label.sync"]) labels = self.details["label" if "label" in self.details else "label.sync"]
if "label.sync" in self.details: if "label.sync" in self.details:
for label in (la for la in item_labels if la not in labels): for label in (la for la in item_labels if la not in labels):
self.library.query_data(self.obj.removeLabel, label) self.library.query_data(self.obj.removeLabel, label)
logger.info(f"Detail: Label {label} removed") logger.info(f"Detail: Label {label} removed")
for label in (la for la in labels if la not in item_labels): if "label" in self.details or "label.sync" in self.details:
self.library.query_data(self.obj.addLabel, label) for label in (la for la in labels if la not in item_labels):
logger.info(f"Detail: Label {label} added") self.library.query_data(self.obj.addLabel, label)
logger.info(f"Detail: Label {label} added")
if "label.remove" in self.details:
for label in self.details["label.remove"]:
if label in item_labels:
self.library.query_data(self.obj.removeLabel, label)
logger.info(f"Detail: Label {label} removed")
if len(self.item_details) > 0: if len(self.item_details) > 0:
labels = None labels = None
if "item_label" in self.item_details or "item_label.sync" in self.item_details: if "item_label" in self.item_details or "item_label.remove" in self.item_details or "item_label.sync" in self.item_details:
labels = util.get_list(self.item_details["item_label" if "item_label" in self.item_details else "item_label.sync"]) labels = self.item_details["item_label" if "item_label" in self.item_details else "item_label.sync"]
for item in self.library.get_collection_items(self.obj, self.smart_label_collection): for item in self.library.get_collection_items(self.obj, self.smart_label_collection):
if labels is not None: if labels is not None:
item_labels = [label.tag for label in item.labels] item_labels = [label.tag for label in item.labels]
@ -1605,9 +1629,15 @@ class CollectionBuilder:
for label in (la for la in item_labels if la not in labels): for label in (la for la in item_labels if la not in labels):
self.library.query_data(item.removeLabel, label) self.library.query_data(item.removeLabel, label)
logger.info(f"Detail: Label {label} removed from {item.title}") logger.info(f"Detail: Label {label} removed from {item.title}")
for label in (la for la in labels if la not in item_labels): if "item_label" in self.item_details or "item_label.sync" in self.item_details:
self.library.query_data(item.addLabel, label) for label in (la for la in labels if la not in item_labels):
logger.info(f"Detail: Label {label} added to {item.title}") self.library.query_data(item.addLabel, label)
logger.info(f"Detail: Label {label} added to {item.title}")
if "item_label.remove" in self.item_details:
for label in self.item_details["item_label.remove"]:
if label in item_labels:
self.library.query_data(self.obj.removeLabel, label)
logger.info(f"Detail: Label {label} removed from {item.title}")
advance_edits = {} advance_edits = {}
for method_name, method_data in self.item_details.items(): for method_name, method_data in self.item_details.items():
if method_name in plex.item_advance_keys: if method_name in plex.item_advance_keys:
@ -1626,24 +1656,11 @@ class CollectionBuilder:
if "name_mapping" in self.details: if "name_mapping" in self.details:
if self.details["name_mapping"]: name_mapping = self.details["name_mapping"] if self.details["name_mapping"]: name_mapping = self.details["name_mapping"]
else: logger.error("Collection Error: name_mapping attribute is blank") else: logger.error("Collection Error: name_mapping attribute is blank")
for ad in self.library.asset_directory: poster_image, background_image = self.library.update_item_from_assets(self.obj, collection_mode=True, upload=False, name=name_mapping)
path = os.path.join(ad, f"{name_mapping}") if poster_image:
if self.library.asset_folders: self.posters["asset_directory"] = poster_image
if not os.path.isdir(path): if background_image:
continue self.backgrounds["asset_directory"] = background_image
poster_filter = os.path.join(ad, name_mapping, "poster.*")
background_filter = os.path.join(ad, name_mapping, "background.*")
else:
poster_filter = os.path.join(ad, f"{name_mapping}.*")
background_filter = os.path.join(ad, f"{name_mapping}_background.*")
matches = glob.glob(poster_filter)
if len(matches) > 0:
self.posters["asset_directory"] = os.path.abspath(matches[0])
matches = glob.glob(background_filter)
if len(matches) > 0:
self.backgrounds["asset_directory"] = os.path.abspath(matches[0])
for item in self.library.query(self.obj.items):
self.library.update_item_from_assets(item, dirs=[path])
def set_image(image_method, images, is_background=False): def set_image(image_method, images, is_background=False):
message = f"{'background' if is_background else 'poster'} to [{'File' if image_method in image_file_details else 'URL'}] {images[image_method]}" message = f"{'background' if is_background else 'poster'} to [{'File' if image_method in image_file_details else 'URL'}] {images[image_method]}"

@ -325,6 +325,7 @@ class Config:
else: else:
params["name"] = str(library_name) params["name"] = str(library_name)
logger.info(f"Connecting to {params['name']} Library...") logger.info(f"Connecting to {params['name']} Library...")
params["mapping_name"] = str(library_name)
params["asset_directory"] = check_for_attribute(lib, "asset_directory", parent="settings", var_type="list_path", default=self.general["asset_directory"], default_is_none=True, save=False) params["asset_directory"] = check_for_attribute(lib, "asset_directory", parent="settings", var_type="list_path", default=self.general["asset_directory"], default_is_none=True, save=False)
if params["asset_directory"] is None: if params["asset_directory"] is None:
@ -389,6 +390,16 @@ class Config:
else: else:
params["mass_critic_rating_update"] = None params["mass_critic_rating_update"] = None
if lib and "radarr_add_all" in lib and lib["radarr_add_all"]:
params["radarr_add_all"] = check_for_attribute(lib, "radarr_add_all", var_type="bool", default=False, save=False)
else:
params["radarr_add_all"] = None
if lib and "sonarr_add_all" in lib and lib["sonarr_add_all"]:
params["sonarr_add_all"] = check_for_attribute(lib, "sonarr_add_all", var_type="bool", default=False, save=False)
else:
params["sonarr_add_all"] = None
try: try:
if lib and "metadata_path" in lib: if lib and "metadata_path" in lib:
params["metadata_path"] = [] params["metadata_path"] = []

@ -2,6 +2,7 @@ import logging, re, requests
from lxml import html from lxml import html
from modules import util from modules import util
from modules.util import Failed from modules.util import Failed
from plexapi.exceptions import BadRequest
from retrying import retry from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
@ -276,6 +277,8 @@ class Convert:
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
util.print_stacktrace() util.print_stacktrace()
raise Failed("No External GUIDs found") raise Failed("No External GUIDs found")
if not tvdb_id and not imdb_id and not tmdb_id:
raise Failed("Refresh Metadata")
elif item_type == "imdb": imdb_id = check_id elif item_type == "imdb": imdb_id = check_id
elif item_type == "thetvdb": tvdb_id = int(check_id) elif item_type == "thetvdb": tvdb_id = int(check_id)
elif item_type == "themoviedb": tmdb_id = int(check_id) elif item_type == "themoviedb": tmdb_id = int(check_id)
@ -354,7 +357,9 @@ class Convert:
return "movie", tmdb_id return "movie", tmdb_id
else: else:
raise Failed(f"No ID to convert") raise Failed(f"No ID to convert")
except Failed as e: except Failed as e:
util.print_end(length, f"Mapping Error | {item.guid:<46} | {e} for {item.title}") util.print_end(length, f"Mapping Error | {item.guid:<46} | {e} for {item.title}")
return None, None except BadRequest:
util.print_stacktrace()
util.print_end(length, f"Mapping Error: | {item.guid} for {item.title} not found")
return None, None

@ -121,30 +121,44 @@ class Metadata:
def edit_tags(attr, obj, group, alias, key=None, extra=None, movie_library=False): def edit_tags(attr, obj, group, alias, key=None, extra=None, movie_library=False):
if key is None: if key is None:
key = f"{attr}s" key = f"{attr}s"
if attr in alias and f"{attr}.sync" in alias: if movie_library and not self.library.is_movie:
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") logger.error(f"Metadata Error: Cannot use {attr} and {attr}.sync together")
elif attr in alias or f"{attr}.sync" in alias: 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:
attr_key = attr if attr in alias else f"{attr}.sync" attr_key = attr if attr in alias else f"{attr}.sync"
if movie_library and not self.library.is_movie: item_tags = [item_tag.tag for item_tag in getattr(obj, key)]
logger.error(f"Metadata Error: {attr_key} attribute only works for movie libraries") input_tags = []
elif group[alias[attr_key]] or extra: if group[alias[attr_key]]:
item_tags = [item_tag.tag for item_tag in getattr(obj, key)] input_tags.extend(util.get_list(group[alias[attr_key]]))
input_tags = [] if extra:
if group[alias[attr_key]]: input_tags.extend(extra)
input_tags.extend(util.get_list(group[alias[attr_key]])) if f"{attr}.sync" in alias:
if extra: remove_method = getattr(obj, f"remove{attr.capitalize()}")
input_tags.extend(extra) for tag in (t for t in item_tags if t not in input_tags):
if f"{attr}.sync" in alias: updated = True
remove_method = getattr(obj, f"remove{attr.capitalize()}") self.library.query_data(remove_method, tag)
for tag in (t for t in item_tags if t not in input_tags): logger.info(f"Detail: {attr.capitalize()} {tag} removed")
updated = True if attr in alias or f"{attr}.sync" in alias:
remove_method(tag)
logger.info(f"Detail: {attr.capitalize()} {tag} removed")
add_method = getattr(obj, f"add{attr.capitalize()}") add_method = getattr(obj, f"add{attr.capitalize()}")
for tag in (t for t in input_tags if t not in item_tags): for tag in (t for t in input_tags if t not in item_tags):
updated = True updated = True
add_method(tag) self.library.query_data(add_method, tag)
logger.info(f"Detail: {attr.capitalize()} {tag} added") logger.info(f"Detail: {attr.capitalize()} {tag} added")
if f"{attr}.remove" in alias:
remove_method = getattr(obj, f"remove{attr.capitalize()}")
for tag in util.get_list(group[alias[f"{attr}.remove"]]):
if tag in item_tags:
self.library.query_data(remove_method, tag)
logger.info(f"Detail: {attr.capitalize()} {tag} removed")
else: else:
logger.error(f"Metadata Error: {attr} attribute is blank") logger.error(f"Metadata Error: {attr} attribute is blank")

@ -2,6 +2,7 @@ import glob, logging, os, requests
from modules import util from modules import util
from modules.meta import Metadata from modules.meta import Metadata
from modules.util import Failed from modules.util import Failed
import plexapi
from plexapi import utils from plexapi import utils
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.collection import Collections from plexapi.collection import Collections
@ -182,7 +183,7 @@ smart_searches = [
"producer", "producer.not", "producer", "producer.not",
"subtitle_language", "subtitle_language.not", "subtitle_language", "subtitle_language.not",
"writer", "writer.not", "writer", "writer.not",
"decade", "resolution", "decade", "resolution", "hdr",
"added", "added.not", "added.before", "added.after", "added", "added.not", "added.before", "added.after",
"originally_available", "originally_available.not", "originally_available", "originally_available.not",
"originally_available.before", "originally_available.after", "originally_available.before", "originally_available.after",
@ -323,6 +324,7 @@ class PlexAPI:
self.Sonarr = None self.Sonarr = None
self.Tautulli = None self.Tautulli = None
self.name = params["name"] self.name = params["name"]
self.mapping_name = util.validate_filename(params["mapping_name"])
self.missing_path = os.path.join(params["default_dir"], f"{self.name}_missing.yml") self.missing_path = os.path.join(params["default_dir"], f"{self.name}_missing.yml")
self.metadata_path = params["metadata_path"] self.metadata_path = params["metadata_path"]
self.asset_directory = params["asset_directory"] self.asset_directory = params["asset_directory"]
@ -336,7 +338,9 @@ class PlexAPI:
self.mass_genre_update = params["mass_genre_update"] self.mass_genre_update = params["mass_genre_update"]
self.mass_audience_rating_update = params["mass_audience_rating_update"] self.mass_audience_rating_update = params["mass_audience_rating_update"]
self.mass_critic_rating_update = params["mass_critic_rating_update"] self.mass_critic_rating_update = params["mass_critic_rating_update"]
self.mass_update = self.mass_genre_update or self.mass_audience_rating_update or self.mass_critic_rating_update self.radarr_add_all = params["radarr_add_all"]
self.sonarr_add_all = params["sonarr_add_all"]
self.mass_update = self.mass_genre_update or self.mass_audience_rating_update or self.mass_critic_rating_update or self.radarr_add_all or self.sonarr_add_all
self.plex = params["plex"] self.plex = params["plex"]
self.url = params["plex"]["url"] self.url = params["plex"]["url"]
self.token = params["plex"]["token"] self.token = params["plex"]["token"]
@ -396,6 +400,11 @@ class PlexAPI:
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def get_guids(self, item): def get_guids(self, item):
item.reload(checkFiles=False, includeAllConcerts=False, includeBandwidths=False, includeChapters=False,
includeChildren=False, includeConcerts=False, includeExternalMedia=False, inclueExtras=False,
includeFields='', includeGeolocation=False, includeLoudnessRamps=False, includeMarkers=False,
includeOnDeck=False, includePopularLeaves=False, includePreferences=False, includeRelated=False,
includeRelatedCount=0, includeReviews=False, includeStations=False)
return item.guids return item.guids
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
@ -560,16 +569,16 @@ class PlexAPI:
or_des = conjunction if o > 0 else f"{search_method}(" or_des = conjunction if o > 0 else f"{search_method}("
ors += f"{or_des}{param}" ors += f"{or_des}{param}"
if has_processed: if has_processed:
logger.info(f"\t\t AND {ors})") logger.info(f" AND {ors})")
else: else:
logger.info(f"Processing {pretty}: {ors})") logger.info(f"Processing {pretty}: {ors})")
has_processed = True has_processed = True
if search_sort: if search_sort:
logger.info(f"\t\t SORT BY {search_sort})") logger.info(f" SORT BY {search_sort}")
if search_limit: if search_limit:
logger.info(f"\t\t LIMIT {search_limit})") logger.info(f" LIMIT {search_limit}")
logger.debug(f"Search: {search_terms}") logger.debug(f"Search: {search_terms}")
return self.search(sort=sorts[search_sort], maxresults=search_limit, **search_terms) items = self.search(sort=sorts[search_sort], maxresults=search_limit, **search_terms)
elif method == "plex_collectionless": elif method == "plex_collectionless":
good_collections = [] good_collections = []
logger.info("Collections Excluded") logger.info("Collections Excluded")
@ -610,7 +619,7 @@ class PlexAPI:
else: else:
raise Failed(f"Plex Error: Method {method} not supported") raise Failed(f"Plex Error: Method {method} not supported")
if len(items) > 0: if len(items) > 0:
return items return [item.ratingKey for item in items]
else: else:
raise Failed("Plex Error: No Items found in Plex") raise Failed("Plex Error: No Items found in Plex")
@ -633,7 +642,11 @@ class PlexAPI:
if smart_label_collection: if smart_label_collection:
return self.get_labeled_items(collection.title if isinstance(collection, Collections) else str(collection)) return self.get_labeled_items(collection.title if isinstance(collection, Collections) else str(collection))
elif isinstance(collection, Collections): elif isinstance(collection, Collections):
return self.query(collection.items) if self.smart(collection):
key = f"/library/sections/{self.Plex.key}/all{self.smart_filter(collection)}"
return self.Plex._search(key, None, 0, plexapi.X_PLEX_CONTAINER_SIZE)
else:
return self.query(collection.items)
else: else:
return [] return []
@ -659,11 +672,16 @@ class PlexAPI:
util.print_stacktrace() util.print_stacktrace()
logger.error(f"{item_type}: {name}{' Advanced' if advanced else ''} Details Update Failed") logger.error(f"{item_type}: {name}{' Advanced' if advanced else ''} Details Update Failed")
def update_item_from_assets(self, item, dirs=None): def update_item_from_assets(self, item, collection_mode=False, upload=True, dirs=None, name=None):
if dirs is None: if dirs is None:
dirs = self.asset_directory dirs = self.asset_directory
name = os.path.basename(os.path.dirname(item.locations[0]) if self.is_movie else item.locations[0]) if not name and collection_mode:
name = item.title
elif not name:
name = os.path.basename(os.path.dirname(item.locations[0]) if self.is_movie else item.locations[0])
for ad in dirs: for ad in dirs:
poster_image = None
background_image = None
if self.asset_folders: if self.asset_folders:
if not os.path.isdir(os.path.join(ad, name)): if not os.path.isdir(os.path.join(ad, name)):
continue continue
@ -674,13 +692,22 @@ class PlexAPI:
background_filter = os.path.join(ad, f"{name}_background.*") background_filter = os.path.join(ad, f"{name}_background.*")
matches = glob.glob(poster_filter) matches = glob.glob(poster_filter)
if len(matches) > 0: if len(matches) > 0:
self.upload_image(item, os.path.abspath(matches[0]), url=False) poster_image = os.path.abspath(matches[0])
logger.info(f"Detail: asset_directory updated {item.title}'s poster to [file] {os.path.abspath(matches[0])}") if upload:
self.upload_image(item, poster_image, url=False)
logger.info(f"Detail: asset_directory updated {item.title}'s poster to [file] {poster_image}")
matches = glob.glob(background_filter) matches = glob.glob(background_filter)
if len(matches) > 0: if len(matches) > 0:
self.upload_image(item, os.path.abspath(matches[0]), poster=False, url=False) background_image = os.path.abspath(matches[0])
logger.info(f"Detail: asset_directory updated {item.title}'s background to [file] {os.path.abspath(matches[0])}") if upload:
if self.is_show: self.upload_image(item, background_image, poster=False, url=False)
logger.info(f"Detail: asset_directory updated {item.title}'s background to [file] {background_image}")
if collection_mode:
for ite in self.query(item.items):
self.update_item_from_assets(ite, dirs=[os.path.join(ad, name)])
if not upload:
return poster_image, background_image
if self.is_show and not collection_mode:
for season in self.query(item.seasons): for season in self.query(item.seasons):
if self.asset_folders: if self.asset_folders:
season_filter = os.path.join(ad, name, f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}.*") season_filter = os.path.join(ad, name, f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}.*")
@ -701,3 +728,4 @@ class PlexAPI:
episode_path = os.path.abspath(matches[0]) episode_path = os.path.abspath(matches[0])
self.upload_image(episode, episode_path, url=False) self.upload_image(episode, episode_path, url=False)
logger.info(f"Detail: asset_directory updated {item.title} {episode.seasonEpisode.upper()}'s poster to [file] {episode_path}") logger.info(f"Detail: asset_directory updated {item.title} {episode.seasonEpisode.upper()}'s poster to [file] {episode_path}")
return None, None

@ -1,5 +1,6 @@
import logging, re, signal, sys, time, traceback import logging, re, signal, sys, time, traceback
from datetime import datetime from datetime import datetime
from pathvalidate import is_valid_filename, sanitize_filename
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
try: try:
@ -220,7 +221,6 @@ def compile_list(data):
else: else:
return data return data
def get_list(data, lower=False, split=True, int_list=False): def get_list(data, lower=False, split=True, int_list=False):
if isinstance(data, list): return data if isinstance(data, list): return data
elif isinstance(data, dict): return [data] elif isinstance(data, dict): return [data]
@ -366,16 +366,22 @@ def centered(text, do_print=True):
return final_text return final_text
def separator(text=None): def separator(text=None):
logger.handlers[0].setFormatter(logging.Formatter(f"%(message)-{screen_width - 2}s")) for handler in logger.handlers:
logger.handlers[1].setFormatter(logging.Formatter(f"[%(asctime)s] %(filename)-27s %(levelname)-10s %(message)-{screen_width - 2}s")) apply_formatter(handler, border=False)
logger.info(f"|{separating_character * screen_width}|") logger.info(f"|{separating_character * screen_width}|")
if text: if text:
text_list = text.split("\n") text_list = text.split("\n")
for t in text_list: for t in text_list:
logger.info(f"| {centered(t, do_print=False)} |") logger.info(f"| {centered(t, do_print=False)} |")
logger.info(f"|{separating_character * screen_width}|") logger.info(f"|{separating_character * screen_width}|")
logger.handlers[0].setFormatter(logging.Formatter(f"| %(message)-{screen_width - 2}s |")) for handler in logger.handlers:
logger.handlers[1].setFormatter(logging.Formatter(f"[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)-{screen_width - 2}s |")) apply_formatter(handler)
def apply_formatter(handler, border=True):
text = f"| %(message)-{screen_width - 2}s |" if border else f"%(message)-{screen_width - 2}s"
if isinstance(handler, logging.handlers.RotatingFileHandler):
text = f"[%(asctime)s] %(filename)-27s %(levelname)-10s {text}"
handler.setFormatter(logging.Formatter(text))
def print_return(length, text): def print_return(length, text):
print(adjust_space(length, f"| {text}"), end="\r") print(adjust_space(length, f"| {text}"), end="\r")
@ -384,3 +390,11 @@ def print_return(length, text):
def print_end(length, text=None): def print_end(length, text=None):
if text: logger.info(adjust_space(length, text)) if text: logger.info(adjust_space(length, text))
else: print(adjust_space(length, " "), end="\r") else: print(adjust_space(length, " "), end="\r")
def validate_filename(filename):
if is_valid_filename(filename):
return filename
else:
mapping_name = sanitize_filename(filename)
logger.info(f"Folder Name: {filename} is invalid using {mapping_name}")
return mapping_name

@ -6,7 +6,6 @@ try:
from modules.builder import CollectionBuilder from modules.builder import CollectionBuilder
from modules.config import Config from modules.config import Config
from modules.util import Failed from modules.util import Failed
from plexapi.exceptions import BadRequest
except ModuleNotFoundError: except ModuleNotFoundError:
print("Error: Requirements are not installed") print("Error: Requirements are not installed")
sys.exit(0) sys.exit(0)
@ -18,8 +17,10 @@ parser.add_argument("-t", "--time", dest="time", help="Time to update each day u
parser.add_argument("-re", "--resume", dest="resume", help="Resume collection run from a specific collection", type=str) parser.add_argument("-re", "--resume", dest="resume", help="Resume collection run from a specific collection", type=str)
parser.add_argument("-r", "--run", dest="run", help="Run without the scheduler", action="store_true", default=False) parser.add_argument("-r", "--run", dest="run", help="Run without the scheduler", action="store_true", default=False)
parser.add_argument("-rt", "--test", "--tests", "--run-test", "--run-tests", dest="test", help="Run in debug mode with only collections that have test: true", action="store_true", default=False) parser.add_argument("-rt", "--test", "--tests", "--run-test", "--run-tests", dest="test", help="Run in debug mode with only collections that have test: true", action="store_true", default=False)
parser.add_argument("-cl", "--collection", "--collections", dest="collections", help="Process only specified collections (comma-separated list)", type=str) parser.add_argument("-co", "--collection-only", "--collections-only", dest="collection_only", help="Run only collection operations", action="store_true", default=False)
parser.add_argument("-l", "--library", "--libraries", dest="libraries", help="Process only specified libraries (comma-separated list)", type=str) parser.add_argument("-lo", "--library-only", "--libraries-only", dest="library_only", help="Run only library operations", action="store_true", default=False)
parser.add_argument("-rc", "-cl", "--collection", "--collections", "--run-collection", "--run-collections", dest="collections", help="Process only specified collections (comma-separated list)", type=str)
parser.add_argument("-rl", "-l", "--library", "--libraries", "--run-library", "--run-libraries", dest="libraries", help="Process only specified libraries (comma-separated list)", type=str)
parser.add_argument("-d", "--divider", dest="divider", help="Character that divides the sections (Default: '=')", default="=", type=str) parser.add_argument("-d", "--divider", dest="divider", help="Character that divides the sections (Default: '=')", default="=", type=str)
parser.add_argument("-w", "--width", dest="width", help="Screen Width (Default: 100)", default=100, type=int) parser.add_argument("-w", "--width", dest="width", help="Screen Width (Default: 100)", default=100, type=int)
args = parser.parse_args() args = parser.parse_args()
@ -39,6 +40,8 @@ def check_bool(env_str, default):
test = check_bool("PMM_TEST", args.test) test = check_bool("PMM_TEST", args.test)
debug = check_bool("PMM_DEBUG", args.debug) debug = check_bool("PMM_DEBUG", args.debug)
run = check_bool("PMM_RUN", args.run) run = check_bool("PMM_RUN", args.run)
library_only = check_bool("PMM_LIBRARIES_ONLY", args.library_only)
collection_only = check_bool("PMM_COLLECTIONS_ONLY", args.collection_only)
collections = os.environ.get("PMM_COLLECTIONS") if os.environ.get("PMM_COLLECTIONS") else args.collections collections = os.environ.get("PMM_COLLECTIONS") if os.environ.get("PMM_COLLECTIONS") else args.collections
libraries = os.environ.get("PMM_LIBRARIES") if os.environ.get("PMM_LIBRARIES") else args.libraries libraries = os.environ.get("PMM_LIBRARIES") if os.environ.get("PMM_LIBRARIES") else args.libraries
resume = os.environ.get("PMM_RESUME") if os.environ.get("PMM_RESUME") else args.resume resume = os.environ.get("PMM_RESUME") if os.environ.get("PMM_RESUME") else args.resume
@ -71,31 +74,32 @@ def fmt_filter(record):
record.filename = f"[{record.filename}:{record.lineno}]" record.filename = f"[{record.filename}:{record.lineno}]"
return True return True
file_handler = logging.handlers.TimedRotatingFileHandler(os.path.join(default_dir, "logs", "meta.log"), when="midnight", backupCount=10, encoding="utf-8")
file_handler.addFilter(fmt_filter)
file_handler.setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)-100s |"))
cmd_handler = logging.StreamHandler() cmd_handler = logging.StreamHandler()
cmd_handler.setFormatter(logging.Formatter("| %(message)-100s |"))
cmd_handler.setLevel(logging.DEBUG if test or debug else logging.INFO) cmd_handler.setLevel(logging.DEBUG if test or debug else logging.INFO)
logger.addHandler(cmd_handler) logger.addHandler(cmd_handler)
logger.addHandler(file_handler)
sys.excepthook = util.my_except_hook sys.excepthook = util.my_except_hook
util.separator()
util.centered(" ")
util.centered(" ____ _ __ __ _ __ __ ")
util.centered("| _ \\| | _____ __ | \\/ | ___| |_ __ _ | \\/ | __ _ _ __ __ _ __ _ ___ _ __ ")
util.centered("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_ \\ / _` |/ _` |/ _ \\ '__|")
util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")
util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")
util.centered(" |___/ ")
util.centered(" Version: 1.9.1 ")
util.separator()
def start(config_path, is_test, daily, requested_collections, requested_libraries, resume_from): def start(config_path, is_test, daily, requested_collections, requested_libraries, resume_from):
file_logger = os.path.join(default_dir, "logs", "meta.log")
should_roll_over = os.path.isfile(file_logger)
file_handler = logging.handlers.RotatingFileHandler(file_logger, delay=True, mode="w", backupCount=10, encoding="utf-8")
util.apply_formatter(file_handler)
file_handler.addFilter(fmt_filter)
if should_roll_over:
file_handler.doRollover()
logger.addHandler(file_handler)
util.separator()
util.centered(" ")
util.centered(" ____ _ __ __ _ __ __ ")
util.centered("| _ \\| | _____ __ | \\/ | ___| |_ __ _ | \\/ | __ _ _ __ __ _ __ _ ___ _ __ ")
util.centered("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_ \\ / _` |/ _` |/ _ \\ '__|")
util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")
util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")
util.centered(" |___/ ")
util.centered(" Version: 1.9.2 ")
util.separator()
if daily: start_type = "Daily " if daily: start_type = "Daily "
elif is_test: start_type = "Test " elif is_test: start_type = "Test "
elif requested_collections: start_type = "Collections " elif requested_collections: start_type = "Collections "
@ -111,9 +115,19 @@ def start(config_path, is_test, daily, requested_collections, requested_librarie
logger.critical(e) logger.critical(e)
logger.info("") logger.info("")
util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}") util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}")
logger.addHandler(file_handler)
def update_libraries(config, is_test, requested_collections, resume_from): def update_libraries(config, is_test, requested_collections, resume_from):
for library in config.libraries: for library in config.libraries:
os.makedirs(os.path.join(default_dir, "logs", library.mapping_name, "collections"), exist_ok=True)
col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, "library.log")
should_roll_over = os.path.isfile(col_file_logger)
library_handler = logging.handlers.RotatingFileHandler(col_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(library_handler)
if should_roll_over:
library_handler.doRollover()
logger.addHandler(library_handler)
os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout)
logger.info("") logger.info("")
util.separator(f"{library.name} Library") util.separator(f"{library.name} Library")
@ -121,12 +135,12 @@ def update_libraries(config, is_test, requested_collections, resume_from):
util.separator(f"Mapping {library.name} Library") util.separator(f"Mapping {library.name} Library")
logger.info("") logger.info("")
movie_map, show_map = map_guids(config, library) movie_map, show_map = map_guids(config, library)
if not is_test and not resume_from and library.mass_update: if not is_test and not resume_from and not collection_only and library.mass_update:
mass_metadata(config, library, movie_map, show_map) mass_metadata(config, library, movie_map, show_map)
for metadata in library.metadata_files: for metadata in library.metadata_files:
logger.info("") logger.info("")
util.separator(f"Running Metadata File\n{metadata.path}") util.separator(f"Running Metadata File\n{metadata.path}")
if not is_test and not resume_from: if not is_test and not resume_from and not collection_only:
try: try:
metadata.update_metadata(config.TMDb, is_test) metadata.update_metadata(config.TMDb, is_test)
except Failed as e: except Failed as e:
@ -137,34 +151,43 @@ def update_libraries(config, is_test, requested_collections, resume_from):
if resume_from and resume_from not in collections_to_run: if resume_from and resume_from not in collections_to_run:
logger.warning(f"Collection: {resume_from} not in Metadata File: {metadata.path}") logger.warning(f"Collection: {resume_from} not in Metadata File: {metadata.path}")
continue continue
if collections_to_run: if collections_to_run and not library_only:
logger.removeHandler(library_handler)
resume_from = run_collection(config, library, metadata, collections_to_run, is_test, resume_from, movie_map, show_map) resume_from = run_collection(config, library, metadata, collections_to_run, is_test, resume_from, movie_map, show_map)
logger.addHandler(library_handler)
if library.show_unmanaged is True and not is_test and not requested_collections: if not is_test and not requested_collections:
logger.info("") unmanaged_collections = []
util.separator(f"Unmanaged Collections in {library.name} Library")
logger.info("")
unmanaged_count = 0
collections_in_plex = [str(plex_col) for plex_col in library.collections]
for col in library.get_all_collections(): for col in library.get_all_collections():
if col.title not in collections_in_plex: if col.title not in library.collections:
unmanaged_collections.append(col)
if library.show_unmanaged and not library_only:
logger.info("")
util.separator(f"Unmanaged Collections in {library.name} Library")
logger.info("")
for col in unmanaged_collections:
logger.info(col.title) logger.info(col.title)
unmanaged_count += 1 logger.info(f"{len(unmanaged_collections)} Unmanaged Collections")
logger.info("{} Unmanaged Collections".format(unmanaged_count))
if library.assets_for_all and not collection_only:
logger.info("")
util.separator(f"All {'Movies' if library.is_movie else 'Shows'} Assets Check for {library.name} Library")
logger.info("")
for col in unmanaged_collections:
library.update_item_from_assets(col, collection_mode=True)
for item in library.get_all():
library.update_item_from_assets(item)
logger.removeHandler(library_handler)
if library.assets_for_all is True and not is_test and not requested_collections:
logger.info("")
util.separator(f"All {'Movies' if library.is_movie else 'Shows'} Assets Check for {library.name} Library")
logger.info("")
for item in library.get_all():
library.update_item_from_assets(item)
has_run_again = False has_run_again = False
for library in config.libraries: for library in config.libraries:
if library.run_again: if library.run_again:
has_run_again = True has_run_again = True
break break
if has_run_again: if has_run_again and not library_only:
logger.info("") logger.info("")
util.separator("Run Again") util.separator("Run Again")
logger.info("") logger.info("")
@ -176,6 +199,11 @@ def update_libraries(config, is_test, requested_collections, resume_from):
util.print_end(length) util.print_end(length)
for library in config.libraries: for library in config.libraries:
if library.run_again: if library.run_again:
col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, f"library.log")
library_handler = logging.handlers.RotatingFileHandler(col_file_logger, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(library_handler)
logger.addHandler(library_handler)
library_handler.addFilter(fmt_filter)
os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout)
logger.info("") logger.info("")
util.separator(f"{library.name} Library Run Again") util.separator(f"{library.name} Library Run Again")
@ -190,6 +218,7 @@ def update_libraries(config, is_test, requested_collections, resume_from):
except Failed as e: except Failed as e:
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.removeHandler(library_handler)
used_url = [] used_url = []
for library in config.libraries: for library in config.libraries:
@ -210,22 +239,18 @@ def map_guids(config, library):
items = library.Plex.all() items = library.Plex.all()
for i, item in enumerate(items, 1): for i, item in enumerate(items, 1):
length = util.print_return(length, f"Processing: {i}/{len(items)} {item.title}") length = util.print_return(length, f"Processing: {i}/{len(items)} {item.title}")
try: id_type, main_id = config.Convert.get_id(item, library, length)
id_type, main_id = config.Convert.get_id(item, library, length) if main_id:
except BadRequest: if not isinstance(main_id, list):
util.print_stacktrace() main_id = [main_id]
util.print_end(length, f"Mapping Error: | {item.guid} for {item.title} not found") if id_type == "movie":
continue for m in main_id:
if not isinstance(main_id, list): if m in movie_map: movie_map[m].append(item.ratingKey)
main_id = [main_id] else: movie_map[m] = [item.ratingKey]
if id_type == "movie": elif id_type == "show":
for m in main_id: for m in main_id:
if m in movie_map: movie_map[m].append(item.ratingKey) if m in show_map: show_map[m].append(item.ratingKey)
else: movie_map[m] = [item.ratingKey] else: show_map[m] = [item.ratingKey]
elif id_type == "show":
for m in main_id:
if m in show_map: show_map[m].append(item.ratingKey)
else: show_map[m] = [item.ratingKey]
util.print_end(length, f"Processed {len(items)} {'Movies' if library.is_movie else 'Shows'}") util.print_end(length, f"Processed {len(items)} {'Movies' if library.is_movie else 'Shows'}")
return movie_map, show_map return movie_map, show_map
@ -234,6 +259,8 @@ def mass_metadata(config, library, movie_map, show_map):
logger.info("") logger.info("")
util.separator(f"Mass Editing {'Movie' if library.is_movie else 'Show'} Library: {library.name}") util.separator(f"Mass Editing {'Movie' if library.is_movie else 'Show'} Library: {library.name}")
logger.info("") logger.info("")
radarr_adds = []
sonarr_adds = []
items = library.Plex.all() items = library.Plex.all()
for i, item in enumerate(items, 1): for i, item in enumerate(items, 1):
length = util.print_return(length, f"Processing: {i}/{len(items)} {item.title}") length = util.print_return(length, f"Processing: {i}/{len(items)} {item.title}")
@ -257,14 +284,16 @@ def mass_metadata(config, library, movie_map, show_map):
if item.ratingKey in rating_keys: if item.ratingKey in rating_keys:
tvdb_id = tvdb tvdb_id = tvdb
break break
if tmdb_id:
imdb_id = config.Convert.tmdb_to_imdb(tmdb_id) if library.Radarr and library.radarr_add_all and tmdb_id:
elif tvdb_id: radarr_adds.append(tmdb_id)
tmdb_id = config.Convert.tvdb_to_tmdb(tvdb_id) if library.Sonarr and library.sonarr_add_all and tvdb_id:
imdb_id = config.Convert.tvdb_to_imdb(tvdb_id) sonarr_adds.append(tvdb_id)
tmdb_item = None tmdb_item = None
if library.mass_genre_update == "tmdb" or library.mass_audience_rating_update == "tmdb" or library.mass_critic_rating_update == "tmdb": if library.mass_genre_update == "tmdb" or library.mass_audience_rating_update == "tmdb" or library.mass_critic_rating_update == "tmdb":
if tvdb_id and not tmdb_id:
tmdb_id = config.Convert.tvdb_to_tmdb(tvdb_id)
if tmdb_id: if tmdb_id:
try: try:
tmdb_item = config.TMDb.get_movie(tmdb_id) if library.is_movie else config.TMDb.get_show(tmdb_id) tmdb_item = config.TMDb.get_movie(tmdb_id) if library.is_movie else config.TMDb.get_show(tmdb_id)
@ -276,6 +305,10 @@ def mass_metadata(config, library, movie_map, show_map):
omdb_item = None omdb_item = None
if library.mass_genre_update in ["omdb", "imdb"] or library.mass_audience_rating_update in ["omdb", "imdb"] or library.mass_critic_rating_update in ["omdb", "imdb"]: if library.mass_genre_update in ["omdb", "imdb"] or library.mass_audience_rating_update in ["omdb", "imdb"] or library.mass_critic_rating_update in ["omdb", "imdb"]:
if config.OMDb.limit is False: if config.OMDb.limit is False:
if tmdb_id and not imdb_id:
imdb_id = config.Convert.tmdb_to_imdb(tmdb_id)
elif tvdb_id and not imdb_id:
imdb_id = config.Convert.tvdb_to_imdb(tvdb_id)
if imdb_id: if imdb_id:
try: try:
omdb_item = config.OMDb.get_omdb(imdb_id) omdb_item = config.OMDb.get_omdb(imdb_id)
@ -327,7 +360,21 @@ def mass_metadata(config, library, movie_map, show_map):
except Failed: except Failed:
pass pass
if library.Radarr and library.radarr_add_all:
try:
library.Radarr.add_tmdb(radarr_adds)
except Failed as e:
logger.error(e)
if library.Sonarr and library.sonarr_add_all:
try:
library.Sonarr.add_tvdb(sonarr_adds)
except Failed as e:
logger.error(e)
def run_collection(config, library, metadata, requested_collections, is_test, resume_from, movie_map, show_map): def run_collection(config, library, metadata, requested_collections, is_test, resume_from, movie_map, show_map):
logger.info("")
for mapping_name, collection_attrs in requested_collections.items(): for mapping_name, collection_attrs in requested_collections.items():
if is_test and ("test" not in collection_attrs or collection_attrs["test"] is not True): if is_test and ("test" not in collection_attrs or collection_attrs["test"] is not True):
no_template_test = True no_template_test = True
@ -343,15 +390,29 @@ def run_collection(config, library, metadata, requested_collections, is_test, re
no_template_test = False no_template_test = False
if no_template_test: if no_template_test:
continue continue
try:
if resume_from and resume_from != mapping_name:
continue
elif resume_from == mapping_name:
resume_from = None
logger.info("")
util.separator(f"Resuming Collections")
if resume_from and resume_from != mapping_name:
continue
elif resume_from == mapping_name:
resume_from = None
logger.info("") logger.info("")
util.separator(f"Resuming Collections")
if "name_mapping" in collection_attrs and collection_attrs["name_mapping"]:
collection_log_name = util.validate_filename(collection_attrs["name_mapping"])
else:
collection_log_name = util.validate_filename(mapping_name)
collection_log_folder = os.path.join(default_dir, "logs", library.mapping_name, "collections", collection_log_name)
os.makedirs(collection_log_folder, exist_ok=True)
col_file_logger = os.path.join(collection_log_folder, f"collection.log")
should_roll_over = os.path.isfile(col_file_logger)
collection_handler = logging.handlers.RotatingFileHandler(col_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(collection_handler)
if should_roll_over:
collection_handler.doRollover()
logger.addHandler(collection_handler)
try:
util.separator(f"{mapping_name} Collection") util.separator(f"{mapping_name} Collection")
logger.info("") logger.info("")
@ -360,26 +421,27 @@ def run_collection(config, library, metadata, requested_collections, is_test, re
if len(builder.schedule) > 0: if len(builder.schedule) > 0:
util.print_multiline(builder.schedule, info=True) util.print_multiline(builder.schedule, info=True)
logger.info("") if not builder.smart_url:
logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}")
if len(builder.filters) > 0:
logger.info("") logger.info("")
for filter_key, filter_value in builder.filters: logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}")
logger.info(f"Collection Filter {filter_key}: {filter_value}")
if len(builder.filters) > 0:
logger.info("")
for filter_key, filter_value in builder.filters:
logger.info(f"Collection Filter {filter_key}: {filter_value}")
if not builder.smart_url:
builder.collect_rating_keys(movie_map, show_map) builder.collect_rating_keys(movie_map, show_map)
logger.info("") logger.info("")
if len(builder.rating_keys) > 0: if len(builder.rating_keys) > 0 and builder.build_collection:
builder.add_to_collection(movie_map) builder.add_to_collection(movie_map)
if len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0: if len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0:
builder.run_missing() builder.run_missing()
if builder.sync and len(builder.rating_keys) > 0: if builder.sync and len(builder.rating_keys) > 0 and builder.build_collection:
builder.sync_collection() builder.sync_collection()
logger.info("") logger.info("")
builder.update_details() if builder.build_collection:
builder.update_details()
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)
@ -390,6 +452,8 @@ def run_collection(config, library, metadata, requested_collections, is_test, re
except Exception as e: except Exception as e:
util.print_stacktrace() util.print_stacktrace()
logger.error(f"Unknown Error: {e}") logger.error(f"Unknown Error: {e}")
logger.info("")
logger.removeHandler(collection_handler)
return resume_from return resume_from
try: try:

@ -9,3 +9,4 @@ requests>=2.4.2
ruamel.yaml ruamel.yaml
schedule schedule
retrying retrying
pathvalidate

Loading…
Cancel
Save