import argparse, os, platform, sys, time, uuid from collections import Counter from concurrent.futures import ProcessPoolExecutor from datetime import datetime from modules.logs import MyLogger if sys.version_info[0] != 3 or sys.version_info[1] < 8: print("Version Error: Version: %s.%s.%s incompatible please use Python 3.8+" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) sys.exit(0) try: import plexapi, psutil, requests, schedule from dotenv import load_dotenv from PIL import ImageFile from plexapi import server from plexapi.exceptions import NotFound from plexapi.video import Show, Season except (ModuleNotFoundError, ImportError): print("Requirements Error: Requirements are not installed") sys.exit(0) default_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config") load_dotenv(os.path.join(default_dir, ".env")) arguments = { "config": {"args": "c", "type": "str", "help": "Run with desired *.yml file"}, "times": {"args": ["t", "time"], "type": "str", "default": "05:00", "help": "Times to update each day use format HH:MM (Default: 05:00) (comma-separated list)"}, "run": {"args": "r", "type": "bool", "help": "Run without the scheduler"}, "tests": {"args": ["rt", "test", "run-test", "run-tests"], "type": "bool", "help": "Run in debug mode with only collections that have test: true"}, "debug": {"args": "db", "type": "bool", "help": "Run with Debug Logs Reporting to the Command Window"}, "trace": {"args": "tr", "type": "bool", "help": "Run with extra Trace Debug Logs"}, "log-requests": {"args": ["lr", "log-request"], "type": "bool", "help": "Run with all Requests printed"}, "timeout": {"args": "ti", "type": "int", "default": 180, "help": "PMM Global Timeout (Default: 180)"}, "collections-only": {"args": ["co", "collection-only"], "type": "bool", "help": "Run only collection operations"}, "playlists-only": {"args": ["po", "playlist-only"], "type": "bool", "help": "Run only playlist operations"}, "operations-only": {"args": ["op", "operation", "operations", "lo", "library-only", "libraries-only", "operation-only"], "type": "bool", "help": "Run only operations"}, "overlays-only": {"args": ["ov", "overlay", "overlays", "overlay-only"], "type": "bool", "help": "Run only overlays"}, "run-collections": {"args": ["rc", "cl", "collection", "collections", "run-collection"], "type": "str", "help": "Process only specified collections (pipe-separated list '|')"}, "run-libraries": {"args": ["rl", "l", "library", "libraries", "run-library"], "type": "str", "help": "Process only specified libraries (pipe-separated list '|')"}, "run-metadata-files": {"args": ["rm", "m", "metadata", "metadata-files"], "type": "str", "help": "Process only specified Metadata files (pipe-separated list '|')"}, "libraries-first": {"args": ["lf", "library-first"], "type": "bool", "help": "Run library operations before collections"}, "ignore-schedules": {"args": "is", "type": "bool", "help": "Run ignoring collection schedules"}, "ignore-ghost": {"args": "ig", "type": "bool", "help": "Run ignoring ghost logging"}, "delete-collections": {"args": ["dc", "delete", "delete-collection"], "type": "bool", "help": "Deletes all Collections in the Plex Library before running"}, "delete-labels": {"args": ["dl", "delete-label"], "type": "bool", "help": "Deletes all Labels in the Plex Library before running"}, "resume": {"args": "re", "type": "str", "help": "Resume collection run from a specific collection"}, "no-countdown": {"args": "nc", "type": "bool", "help": "Run without displaying the countdown"}, "no-missing": {"args": "nm", "type": "bool", "help": "Run without running the missing section"}, "no-report": {"args": "nr", "type": "bool", "help": "Run without saving a report"}, "read-only-config": {"args": "ro", "type": "bool", "help": "Run without writing to the config"}, "divider": {"args": "d", "type": "str", "default": "=", "help": "Character that divides the sections (Default: '=')"}, "width": {"args": "w", "type": "int", "default": 100, "help": "Screen Width (Default: 100)"}, } parser = argparse.ArgumentParser() for arg_key, arg_data in arguments.items(): temp_args = arg_data["args"] if isinstance(arg_data["args"], list) else [arg_data["args"]] args = [f"--{arg_key}"] + [f"--{a}" if len(a) > 2 else f"-{a}" for a in temp_args] kwargs = {"dest": arg_key.replace("-", "_"), "help": arg_data["help"]} if arg_data["type"] == "bool": kwargs["action"] = "store_true" kwargs["default"] = False else: kwargs["type"] = int if arg_data["type"] == "int" else str if "default" in arg_data: kwargs["default"] = arg_data["default"] parser.add_argument(*args, **kwargs) args, unknown = parser.parse_known_args() def get_env(env_str, default, arg_bool=False, arg_int=False): env_vars = [env_str] if not isinstance(env_str, list) else env_str final_value = None static_envs.extend(env_vars) for env_var in env_vars: env_value = os.environ.get(env_var) if env_value is not None: final_value = env_value break if final_value or (arg_int and final_value == 0): if arg_bool: if final_value is True or final_value is False: return final_value elif final_value.lower() in ["t", "true"]: return True else: return False elif arg_int: try: return int(final_value) except ValueError: return default else: return str(final_value) else: return default static_envs = [] run_args = {} for arg_key, arg_data in arguments.items(): temp_args = arg_data["args"] if isinstance(arg_data["args"], list) else [arg_data["args"]] final_vars = [f"PMM_{arg_key.replace('-', '_').upper()}"] + [f"PMM_{a.replace('-', '_').upper()}" for a in temp_args if len(a) > 2] run_args[arg_key] = get_env(final_vars, getattr(args, arg_key.replace("-", "_")), arg_bool=arg_data["type"] == "bool", arg_int=arg_data["type"] == "int") env_version = get_env("BRANCH_NAME", "master") is_docker = get_env("PMM_DOCKER", False, arg_bool=True) is_linuxserver = get_env("PMM_LINUXSERVER", False, arg_bool=True) secret_args = {} plex_url = None plex_token = None i = 0 while i < len(unknown): test_var = str(unknown[i]).lower().replace("_", "-") if test_var.startswith("--pmm-") or test_var in ["-pu", "--plex-url", "-pt", "--plex-token"]: if test_var in ["-pu", "--plex-url"]: plex_url = str(unknown[i + 1]) elif test_var in ["-pt", "--plex-token"]: plex_token = str(unknown[i + 1]) else: secret_args[test_var[6:]] = str(unknown[i + 1]) i += 1 i += 1 plex_url = get_env("PMM_PLEX_URL", plex_url) plex_token = get_env("PMM_PLEX_TOKEN", plex_token) env_secrets = [] for env_name, env_data in os.environ.items(): if str(env_name).upper().startswith("PMM_") and str(env_name).upper() not in static_envs: secret_args[str(env_name).lower()[4:].replace("_", "-")] = env_data run_arg = " ".join([f'"{s}"' if " " in s else s for s in sys.argv[:]]) for _, v in secret_args.items(): if v in run_arg: run_arg = run_arg.replace(v, "(redacted)") try: from git import Repo, InvalidGitRepositoryError try: git_branch = Repo(path=".").head.ref.name # noqa except InvalidGitRepositoryError: git_branch = None except ImportError: git_branch = None if run_args["run-collections"]: run_args["collections-only"] = True if run_args["width"] < 90 or run_args["width"] > 300: print(f"Argument Error: width argument invalid: {run_args['width']} must be an integer between 90 and 300 using the default 100") run_args["width"] = 100 if run_args["config"] and os.path.exists(run_args["config"]): default_dir = os.path.join(os.path.dirname(os.path.abspath(run_args["config"]))) elif run_args["config"] and not os.path.exists(run_args["config"]): print(f"Config Error: config not found at {os.path.abspath(run_args['config'])}") sys.exit(0) elif not os.path.exists(os.path.join(default_dir, "config.yml")): print(f"Config Error: config not found at {os.path.abspath(default_dir)}") sys.exit(0) logger = MyLogger("Plex Meta Manager", default_dir, run_args["width"], run_args["divider"][0], run_args["ignore-ghost"], run_args["tests"] or run_args["debug"], run_args["trace"], run_args["log-requests"]) from modules import util util.logger = logger from modules.builder import CollectionBuilder from modules.config import ConfigFile from modules.util import Failed, FilterFailed, NonExisting, NotScheduled, Deleted def my_except_hook(exctype, value, tb): if issubclass(exctype, KeyboardInterrupt): sys.__excepthook__(exctype, value, tb) else: logger.critical("Uncaught Exception", exc_info=(exctype, value, tb)) sys.excepthook = my_except_hook old_send = requests.Session.send def new_send(*send_args, **kwargs): if kwargs.get("timeout", None) is None: kwargs["timeout"] = run_args["timeout"] return old_send(*send_args, **kwargs) requests.Session.send = new_send version = ("Unknown", "Unknown", 0) with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION")) as handle: for line in handle.readlines(): line = line.strip() if len(line) > 0: version = util.parse_version(line) break branch = util.guess_branch(version, env_version, git_branch) version = (version[0].replace("develop", branch), version[1].replace("develop", branch), version[2]) uuid_file = os.path.join(default_dir, "UUID") uuid_num = None if os.path.exists(uuid_file): with open(uuid_file) as handle: for line in handle.readlines(): line = line.strip() if len(line) > 0: uuid_num = line break if not uuid_num: uuid_num = uuid.uuid4() with open(uuid_file, "w") as handle: handle.write(str(uuid_num)) plexapi.BASE_HEADERS["X-Plex-Client-Identifier"] = str(uuid_num) ImageFile.LOAD_TRUNCATED_IMAGES = True def process(attrs): with ProcessPoolExecutor(max_workers=1) as executor: executor.submit(start, *[attrs]) def start(attrs): logger.add_main_handler() logger.separator() logger.info("") logger.info_center(" ____ _ __ __ _ __ __ ") logger.info_center("| _ \\| | _____ __ | \\/ | ___| |_ __ _ | \\/ | __ _ _ __ __ _ __ _ ___ _ __ ") logger.info_center("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_ \\ / _` |/ _` |/ _ \\ '__|") logger.info_center("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ") logger.info_center("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ") logger.info_center(" |___/ ") system_ver = "Docker" if is_docker else "Linuxserver" if is_linuxserver else f"Python {platform.python_version()}" logger.info(f" Version: {version[0]} ({system_ver}){f' (Git: {git_branch})' if git_branch else ''}") latest_version = util.current_version(version, branch=branch) new_version = latest_version[0] if latest_version and (version[1] != latest_version[1] or (version[2] and version[2] < latest_version[2])) else None if new_version: logger.info(f" Newest Version: {new_version}") logger.info(f" PlexAPI library version: {plexapi.VERSION}") logger.info(f" Platform: {platform.platform()}") logger.info(f" Memory: {round(psutil.virtual_memory().total / (1024.0 ** 3))} GB") if "time" in attrs and attrs["time"]: start_type = f"{attrs['time']} " elif run_args["tests"]: start_type = "Test " elif "collections" in attrs and attrs["collections"]: start_type = "Collections " elif "libraries" in attrs and attrs["libraries"]: start_type = "Libraries " else: start_type = "" start_time = datetime.now() if "time" not in attrs: attrs["time"] = start_time.strftime("%H:%M") attrs["time_obj"] = start_time attrs["version"] = version attrs["branch"] = branch attrs["config_file"] = run_args["config"] attrs["ignore_schedules"] = run_args["ignore-schedules"] attrs["read_only"] = run_args["read-only-config"] attrs["no_missing"] = run_args["no-missing"] attrs["no_report"] = run_args["no-report"] attrs["collection_only"] = run_args["collections-only"] attrs["playlist_only"] = run_args["playlists-only"] attrs["operations_only"] = run_args["operations-only"] attrs["overlays_only"] = run_args["overlays-only"] attrs["plex_url"] = plex_url attrs["plex_token"] = plex_token logger.separator(debug=True) logger.debug(f"Run Command: {run_arg}") for akey, adata in arguments.items(): ext = '"' if adata["type"] == "str" and run_args[akey] not in [None, "None"] else "" logger.debug(f"--{akey} (PMM_{akey.upper()}): {ext}{run_args[akey]}{ext}") logger.debug("") if secret_args: logger.debug("PMM Secrets Read:") for sec in secret_args: logger.debug(f"--pmm-{sec} (PMM_{sec.upper().replace('-', '_')}): (redacted)") logger.debug("") logger.separator(f"Starting {start_type}Run") config = None stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0, "names": []} try: config = ConfigFile(default_dir, attrs, secret_args) except Exception as e: logger.stacktrace() logger.critical(e) else: try: stats = run_config(config, stats) except Exception as e: config.notify(e) logger.stacktrace() logger.critical(e) logger.info("") end_time = datetime.now() run_time = str(end_time - start_time).split(".")[0] if config: try: config.Webhooks.end_time_hooks(start_time, end_time, run_time, stats) except Failed as e: logger.stacktrace() logger.error(f"Webhooks Error: {e}") version_line = f"Version: {version[0]}" if new_version: version_line = f"{version_line} Newest Version: {new_version}" try: log_data = {} with open(logger.main_log, encoding="utf-8") as f: for log_line in f: for err_type in ["WARNING", "ERROR", "CRITICAL"]: if f"[{err_type}]" in log_line: log_line = log_line.split("|")[1].strip() if err_type not in log_data: log_data[err_type] = [] log_data[err_type].append(log_line) for err_type in ["WARNING", "ERROR", "CRITICAL"]: if err_type not in log_data: continue logger.separator(f"{err_type.lower().capitalize()} Summary", space=False, border=False) logger.info("") logger.info("Count | Message") logger.separator(f"{logger.separating_character * 5}|", space=False, border=False, side_space=False, left=True) for k, v in Counter(log_data[err_type]).most_common(): logger.info(f"{v:>5} | {k}") logger.info("") except Failed as e: logger.stacktrace() logger.error(f"Report Error: {e}") logger.separator(f"Finished {start_type}Run\n{version_line}\nFinished: {end_time.strftime('%H:%M:%S %Y-%m-%d')} Run Time: {run_time}") logger.remove_main_handler() def run_config(config, stats): library_status = run_libraries(config) playlist_status = {} playlist_stats = {} if (config.playlist_files or config.general["playlist_report"]) and not run_args["overlays-only"] and not run_args["operations-only"] and not run_args["collections-only"] and not config.requested_metadata_files: logger.add_playlists_handler() if config.playlist_files: playlist_status, playlist_stats = run_playlists(config) if config.general["playlist_report"]: ran = [] for library in config.libraries: if library.PlexServer.machineIdentifier in ran: continue ran.append(library.PlexServer.machineIdentifier) logger.info("") logger.separator(f"{library.PlexServer.friendlyName} Playlist Report") logger.info("") report = library.playlist_report() max_length = 0 for playlist_name in report: if len(playlist_name) > max_length: max_length = len(playlist_name) logger.info(f"{'Playlist Title':<{max_length}} | Users") logger.separator(f"{logger.separating_character * max_length}|", space=False, border=False, side_space=False, left=True) for playlist_name, users in report.items(): logger.info(f"{playlist_name:<{max_length}} | {'all' if len(users) == len(library.users) + 1 else ', '.join(users)}") logger.remove_playlists_handler() amount_added = 0 if not run_args["operations-only"] and not run_args["overlays-only"] and not run_args["playlists-only"]: has_run_again = False for library in config.libraries: if library.run_again: has_run_again = True break if has_run_again: logger.info("") logger.separator("Run Again") logger.info("") for x in range(1, config.general["run_again_delay"] + 1): logger.ghost(f"Waiting to run again in {config.general['run_again_delay'] - x + 1} minutes") for y in range(60): time.sleep(1) logger.exorcise() for library in config.libraries: if library.run_again: try: logger.re_add_library_handler(library.mapping_name) os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) logger.info("") logger.separator(f"{library.name} Library Run Again") logger.info("") library.map_guids(library.cache_items()) for builder in library.run_again: logger.info("") logger.separator(f"{builder.name} Collection in {library.name}") logger.info("") try: amount_added += builder.run_collections_again() except Failed as e: library.notify(e, collection=builder.name, critical=False) logger.stacktrace() logger.error(e) logger.remove_library_handler(library.mapping_name) except Exception as e: library.notify(e) logger.stacktrace() logger.critical(e) if not run_args["collections-only"] and not run_args["overlays-only"] and not run_args["playlists-only"]: used_url = [] for library in config.libraries: if library.url not in used_url: used_url.append(library.url) if library.empty_trash: library.query(library.PlexServer.library.emptyTrash) if library.clean_bundles: library.query(library.PlexServer.library.cleanBundles) if library.optimize: library.query(library.PlexServer.library.optimize) longest = 20 for library in config.libraries: for title in library.status: if len(title) > longest: longest = len(title) if playlist_status: for title in playlist_status: if len(title) > longest: longest = len(title) def print_status(status): logger.info(f"{'Title':^{longest}} | + | = | - | Run Time | {'Status'}") breaker = f"{logger.separating_character * longest}|{logger.separating_character * 7}|{logger.separating_character * 7}|{logger.separating_character * 7}|{logger.separating_character * 10}|" logger.separator(breaker, space=False, border=False, side_space=False, left=True) for name, data in status.items(): logger.info(f"{name:<{longest}} | {data['added']:>5} | {data['unchanged']:>5} | {data['removed']:>5} | {data['run_time']:>8} | {data['status']}") if data["errors"]: for error in data["errors"]: logger.info(error) logger.info("") logger.info("") logger.separator("Summary") for library in config.libraries: logger.info("") logger.separator(f"{library.name} Summary", space=False, border=False) logger.info("") logger.info(f"{'Title':<27} | Run Time |") logger.info(f"{logger.separating_character * 27} | {logger.separating_character * 8} |") if library.name in library_status: for text, value in library_status[library.name].items(): logger.info(f"{text:<27} | {value:>8} |") logger.info("") print_status(library.status) if playlist_status: logger.info("") logger.separator(f"Playlists Summary", space=False, border=False) logger.info("") print_status(playlist_status) stats["added"] += amount_added for library in config.libraries: stats["created"] += library.stats["created"] stats["modified"] += library.stats["modified"] stats["deleted"] += library.stats["deleted"] stats["added"] += library.stats["added"] stats["unchanged"] += library.stats["unchanged"] stats["removed"] += library.stats["removed"] stats["radarr"] += library.stats["radarr"] stats["sonarr"] += library.stats["sonarr"] stats["names"].extend([{"name": n, "library": library.name} for n in library.stats["names"]]) if playlist_stats: stats["created"] += playlist_stats["created"] stats["modified"] += playlist_stats["modified"] stats["deleted"] += playlist_stats["deleted"] stats["added"] += playlist_stats["added"] stats["unchanged"] += playlist_stats["unchanged"] stats["removed"] += playlist_stats["removed"] stats["radarr"] += playlist_stats["radarr"] stats["sonarr"] += playlist_stats["sonarr"] stats["names"].extend([{"name": n, "library": "PLAYLIST"} for n in playlist_stats["names"]]) return stats def run_libraries(config): library_status = {} for library in config.libraries: if library.skip_library: logger.info("") logger.separator(f"Skipping {library.original_mapping_name} Library") continue library_status[library.name] = {} try: logger.add_library_handler(library.mapping_name) plexapi.server.TIMEOUT = library.timeout os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) logger.info("") logger.separator(f"{library.original_mapping_name} Library") logger.debug("") logger.debug(f"Library Name: {library.name}") logger.debug(f"Folder Name: {library.mapping_name}") for ad in library.asset_directory: logger.debug(f"Asset Directory: {ad}") logger.debug(f"Asset Folders: {library.asset_folders}") logger.debug(f"Create Asset Folders: {library.create_asset_folders}") logger.debug(f"Download URL Assets: {library.download_url_assets}") logger.debug(f"Sync Mode: {library.sync_mode}") logger.debug(f"Minimum Items: {library.minimum_items}") logger.debug(f"Delete Below Minimum: {library.delete_below_minimum}") logger.debug(f"Delete Not Scheduled: {library.delete_not_scheduled}") logger.debug(f"Default Collection Order: {library.default_collection_order}") logger.debug(f"Missing Only Released: {library.missing_only_released}") logger.debug(f"Only Filter Missing: {library.only_filter_missing}") logger.debug(f"Show Unmanaged: {library.show_unmanaged}") logger.debug(f"Show Filtered: {library.show_filtered}") logger.debug(f"Show Missing: {library.show_missing}") logger.debug(f"Show Missing Assets: {library.show_missing_assets}") logger.debug(f"Save Report: {library.save_report}") logger.debug(f"Report Path: {library.report_path}") logger.debug(f"Clean Bundles: {library.clean_bundles}") logger.debug(f"Empty Trash: {library.empty_trash}") logger.debug(f"Optimize: {library.optimize}") logger.debug(f"Timeout: {library.timeout}") if run_args["delete-collections"] and not run_args["playlists-only"]: time_start = datetime.now() logger.info("") logger.separator(f"Deleting all Collections from the {library.name} Library", space=False, border=False) logger.info("") for collection in library.get_all_collections(): try: library.delete(collection) logger.info(f"Collection {collection.title} Deleted") except Failed as e: logger.error(e) library_status[library.name]["All Collections Deleted"] = str(datetime.now() - time_start).split('.')[0] if run_args["delete-labels"] and not run_args["playlists-only"]: time_start = datetime.now() logger.info("") logger.separator(f"Deleting all Labels from All items in the {library.name} Library", space=False, border=False) logger.info("") if library.is_show: library_types = ["show", "season", "episode"] elif library.is_music: library_types = ["artist", "album", "track"] else: library_types = ["movie"] for library_type in library_types: for item in library.get_all(builder_level=library_type): try: sync = ["Overlay"] if "Overlay" in [i.tag for i in item.labels] else [] library.edit_tags("label", item, sync_tags=sync) except NotFound: logger.error(f"{item.title[:25]:<25} | Labels Failed to be Removed") library_status[library.name]["All Labels Deleted"] = str(datetime.now() - time_start).split('.')[0] time_start = datetime.now() temp_items = None list_key = None expired = None if config.Cache: list_key, expired = config.Cache.query_list_cache("library", library.mapping_name, 1) if not temp_items: temp_items = library.cache_items() if config.Cache and list_key: config.Cache.delete_list_ids(list_key) if not library.is_music: logger.info("") logger.separator(f"Mapping {library.original_mapping_name} Library", space=False, border=False) logger.info("") library.map_guids(temp_items) library_status[library.name]["Library Loading and Mapping"] = str(datetime.now() - time_start).split('.')[0] def run_operations_and_overlays(): if not run_args["tests"] and not run_args["collections-only"] and not run_args["playlists-only"] and not config.requested_metadata_files: if not run_args["overlays-only"] and library.library_operation: library_status[library.name]["Library Operations"] = library.Operations.run_operations() if not run_args["operations-only"] and (library.overlay_files or library.remove_overlays): library_status[library.name]["Library Overlays"] = library.Overlays.run_overlays() if run_args["libraries-first"]: run_operations_and_overlays() if not run_args["operations-only"] and not run_args["overlays-only"] and not run_args["playlists-only"]: time_start = datetime.now() for images in library.images_files: images_name = images.get_file_name() if config.requested_metadata_files and images_name not in config.requested_metadata_files: logger.info("") logger.separator(f"Skipping {images_name} Images File") continue logger.info("") logger.separator(f"Running {images_name} Images File\n{images.path}") if not run_args["tests"] and not run_args["resume"] and not run_args["collections-only"]: try: images.update_metadata() except Failed as e: library.notify(e) logger.error(e) library_status[library.name]["Library Images Files"] = str(datetime.now() - time_start).split('.')[0] time_start = datetime.now() for metadata in library.metadata_files: metadata_name = metadata.get_file_name() if config.requested_metadata_files and metadata_name not in config.requested_metadata_files: logger.info("") logger.separator(f"Skipping {metadata_name} Metadata File") continue logger.info("") logger.separator(f"Running {metadata_name} Metadata File\n{metadata.path}") if not run_args["tests"] and not run_args["resume"] and not run_args["collections-only"]: try: metadata.update_metadata() except Failed as e: library.notify(e) logger.error(e) collections_to_run = metadata.get_collections(config.requested_collections) if run_args["resume"] and run_args["resume"] not in collections_to_run: logger.info("") logger.warning(f"Collection: {run_args['resume']} not in Metadata File: {metadata.path}") continue if collections_to_run: logger.info("") logger.separator(f"{'Test ' if run_args['tests'] else ''}Collections") logger.remove_library_handler(library.mapping_name) run_collection(config, library, metadata, collections_to_run) logger.re_add_library_handler(library.mapping_name) library_status[library.name]["Library Metadata Files"] = str(datetime.now() - time_start).split('.')[0] if not run_args["libraries-first"]: run_operations_and_overlays() logger.remove_library_handler(library.mapping_name) except Exception as e: library.notify(e) logger.stacktrace() logger.critical(e) return library_status def run_collection(config, library, metadata, requested_collections): logger.info("") for mapping_name, collection_attrs in requested_collections.items(): collection_start = datetime.now() if run_args["tests"] and ("test" not in collection_attrs or collection_attrs["test"] is not True): no_template_test = True if "template" in collection_attrs and collection_attrs["template"]: for data_template in util.get_list(collection_attrs["template"], split=False): if "name" in data_template \ and data_template["name"] \ and metadata.templates \ and data_template["name"] in metadata.templates \ and metadata.templates[data_template["name"]][0] \ and "test" in metadata.templates[data_template["name"]][0] \ and metadata.templates[data_template["name"]][0]["test"] is True: no_template_test = False if no_template_test: continue if run_args["resume"] and run_args["resume"] != mapping_name: continue elif run_args["resume"] == mapping_name: run_args["resume"] = None logger.info("") logger.separator(f"Resuming Collections") if "name_mapping" in collection_attrs and collection_attrs["name_mapping"]: collection_log_name, output_str = util.validate_filename(collection_attrs["name_mapping"]) else: collection_log_name, output_str = util.validate_filename(mapping_name) logger.add_collection_handler(library.mapping_name, collection_log_name) library.status[str(mapping_name)] = {"status": "Unchanged", "errors": [], "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0} try: builder = CollectionBuilder(config, metadata, mapping_name, collection_attrs, library=library, extra=output_str) library.stats["names"].append(builder.name) if builder.build_collection: library.collection_names.append(builder.name) logger.info("") logger.separator(f"Running {builder.name} Collection", space=False, border=False) if len(builder.schedule) > 0: logger.info(builder.schedule) if len(builder.smart_filter_details) > 0: logger.info("") logger.info(builder.smart_filter_details) logger.info("") logger.info(f"Items Found: {builder.beginning_count}") items_added = 0 items_removed = 0 if not builder.smart_url and builder.builders and not builder.blank_collection: logger.info("") logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}") for method, value in builder.builders: logger.debug("") logger.debug(f"Builder: {method}: {value}") logger.info("") try: builder.filter_and_save_items(builder.gather_ids(method, value)) except Failed as e: if builder.ignore_blank_results: logger.warning(e) else: raise Failed(e) builder.display_filters() if len(builder.found_items) > 0 and len(builder.found_items) + builder.beginning_count >= builder.minimum and builder.build_collection: items_added, items_unchanged = builder.add_to_collection() library.stats["added"] += items_added library.status[str(mapping_name)]["added"] = items_added library.stats["unchanged"] += items_unchanged library.status[str(mapping_name)]["unchanged"] = items_unchanged items_removed = 0 if builder.sync: items_removed = builder.sync_collection() library.stats["removed"] += items_removed library.status[str(mapping_name)]["removed"] = items_removed if builder.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0): radarr_add, sonarr_add = builder.run_missing() library.stats["radarr"] += radarr_add library.status[str(mapping_name)]["radarr"] += radarr_add library.stats["sonarr"] += sonarr_add library.status[str(mapping_name)]["sonarr"] += sonarr_add if not builder.found_items and not builder.ignore_blank_results: raise NonExisting(f"{builder.Type} Warning: No items found") valid = True if builder.build_collection and not builder.blank_collection and items_added + builder.beginning_count < builder.minimum: logger.info("") logger.info(f"{builder.Type} Minimum: {builder.minimum} not met for {mapping_name} Collection") delete_status = f"Minimum {builder.minimum} Not Met" valid = False if builder.details["delete_below_minimum"] and builder.obj: logger.info("") logger.info(builder.delete()) library.stats["deleted"] += 1 delete_status = f"Deleted; {delete_status}" library.status[str(mapping_name)]["status"] = delete_status run_item_details = True if valid and builder.build_collection and (builder.builders or builder.smart_url or builder.blank_collection): try: builder.load_collection() if builder.created: library.stats["created"] += 1 library.status[str(mapping_name)]["status"] = "Created" elif items_added > 0 or items_removed > 0: library.stats["modified"] += 1 library.status[str(mapping_name)]["status"] = "Modified" except Failed: logger.stacktrace() run_item_details = False logger.info("") logger.separator(f"No {builder.Type} to Update", space=False, border=False) else: details_list = builder.update_details() if details_list: pre = "" if library.status[str(mapping_name)]["status"] != "Unchanged": pre = f"{library.status[str(mapping_name)]['status']} and " library.status[str(mapping_name)]["status"] = f"{pre}Updated {', '.join(details_list)}" if builder.server_preroll is not None: library.set_server_preroll(builder.server_preroll) logger.info("") logger.info(f"Plex Server Movie pre-roll video updated to {builder.server_preroll}") if valid and run_item_details and (builder.item_details or builder.custom_sort or builder.sync_to_trakt_list): try: builder.load_collection_items() except Failed: logger.info("") logger.separator("No Items Found", space=False, border=False) else: if builder.item_details: builder.update_item_details() if builder.custom_sort: builder.sort_collection() if builder.sync_to_trakt_list: builder.sync_trakt_list() builder.send_notifications() if builder.run_again and (len(builder.run_again_movies) > 0 or len(builder.run_again_shows) > 0): library.run_again.append(builder) except NonExisting as e: logger.warning(e) library.status[str(mapping_name)]["status"] = "Ignored" except NotScheduled as e: logger.info(e) if str(e).endswith("and was deleted"): library.notify_delete(e) library.stats["deleted"] += 1 library.status[str(mapping_name)]["status"] = "Deleted Not Scheduled" elif str(e).startswith("Skipped because run_definition"): library.status[str(mapping_name)]["status"] = "Skipped Run Definition" else: library.status[str(mapping_name)]["status"] = "Not Scheduled" except FilterFailed: pass except Failed as e: library.notify(e, collection=mapping_name) logger.stacktrace() logger.error(e) library.status[str(mapping_name)]["status"] = "PMM Failure" library.status[str(mapping_name)]["errors"].append(e) except Exception as e: library.notify(f"Unknown Error: {e}", collection=mapping_name) logger.stacktrace() logger.error(f"Unknown Error: {e}") library.status[str(mapping_name)]["status"] = "Unknown Error" library.status[str(mapping_name)]["errors"].append(e) collection_run_time = str(datetime.now() - collection_start).split('.')[0] library.status[str(mapping_name)]["run_time"] = collection_run_time logger.info("") logger.separator(f"Finished {mapping_name} Collection\nCollection Run Time: {collection_run_time}") logger.remove_collection_handler(library.mapping_name, collection_log_name) def run_playlists(config): stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0, "names": []} status = {} logger.info("") logger.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 run_args["tests"] 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"]][0] \ and "test" in playlist_file.templates[data_template["name"]][0] \ and playlist_file.templates[data_template["name"]][0]["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) logger.add_playlist_handler(playlist_log_name) status[mapping_name] = {"status": "Unchanged", "errors": [], "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0} server_name = None library_names = None try: builder = CollectionBuilder(config, playlist_file, mapping_name, playlist_attrs, extra=output_str) stats["names"].append(builder.name) logger.info("") logger.separator(f"Running {mapping_name} Playlist", space=False, border=False) if len(builder.schedule) > 0: logger.info(builder.schedule) items_added = 0 items_removed = 0 valid = True logger.info("") logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}") method, value = builder.builders[0] logger.debug("") logger.debug(f"Builder: {method}: {value}") logger.info("") if method == "plex_watchlist": ids = builder.libraries[0].get_rating_keys(method, value, True) elif "plex" in method: ids = [] for pl_library in builder.libraries: try: ids.extend(pl_library.get_rating_keys(method, value, True)) except Failed as e: if builder.validate_builders: raise else: logger.error(e) elif "tautulli" in method: ids = [] for pl_library in builder.libraries: try: ids.extend(pl_library.Tautulli.get_rating_keys(value, True)) except Failed as e: if builder.validate_builders: raise else: logger.error(e) else: ids = builder.gather_ids(method, value) builder.display_filters() builder.filter_and_save_items(ids) if len(builder.found_items) > 0 and len(builder.found_items) + builder.beginning_count >= builder.minimum: items_added, items_unchanged = builder.add_to_collection() stats["added"] += items_added status[mapping_name]["added"] += items_added stats["unchanged"] += items_unchanged status[mapping_name]["unchanged"] += items_unchanged items_removed = 0 if builder.sync: items_removed = builder.sync_collection() stats["removed"] += items_removed status[mapping_name]["removed"] += items_removed elif len(builder.found_items) < builder.minimum: logger.info("") logger.info(f"Playlist Minimum: {builder.minimum} not met for {mapping_name} Playlist") delete_status = f"Minimum {builder.minimum} Not Met" valid = False if builder.details["delete_below_minimum"] and builder.obj: logger.info("") logger.info(builder.delete()) stats["deleted"] += 1 delete_status = f"Deleted; {delete_status}" status[mapping_name]["status"] = delete_status if builder.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0): radarr_add, sonarr_add = builder.run_missing() stats["radarr"] += radarr_add status[mapping_name]["radarr"] += radarr_add stats["sonarr"] += sonarr_add status[mapping_name]["sonarr"] += sonarr_add run_item_details = True if valid and builder.builders: try: builder.load_collection() if builder.created: stats["created"] += 1 status[mapping_name]["status"] = "Created" elif items_added > 0 or items_removed > 0: stats["modified"] += 1 status[mapping_name]["status"] = "Modified" except Failed: logger.stacktrace() run_item_details = False logger.info("") logger.separator("No Playlist to Update", space=False, border=False) else: details_list = builder.update_details() if details_list: pre = "" if status[mapping_name]["status"] != "Unchanged": pre = f"{status[mapping_name]['status']} and " status[mapping_name]["status"] = f"{pre}Updated {', '.join(details_list)}" if valid and run_item_details and builder.builders and (builder.item_details or builder.custom_sort): try: builder.load_collection_items() except Failed: logger.info("") logger.separator("No Items Found", space=False, border=False) else: if builder.item_details: builder.update_item_details() if builder.custom_sort: builder.sort_collection() if valid: builder.sync_playlist() builder.send_notifications(playlist=True) except Deleted as e: logger.info(e) status[mapping_name]["status"] = "Deleted" config.notify_delete(e) except NotScheduled as e: logger.info(e) if str(e).endswith("and was deleted"): stats["deleted"] += 1 status[mapping_name]["status"] = "Deleted Not Scheduled" config.notify_delete(e) else: status[mapping_name]["status"] = "Not Scheduled" except Failed as e: config.notify(e, server=server_name, library=library_names, playlist=mapping_name) logger.stacktrace() logger.error(e) status[mapping_name]["status"] = "PMM Failure" status[mapping_name]["errors"].append(e) except Exception as e: config.notify(f"Unknown Error: {e}", server=server_name, library=library_names, playlist=mapping_name) logger.stacktrace() logger.error(f"Unknown Error: {e}") status[mapping_name]["status"] = "Unknown Error" status[mapping_name]["errors"].append(e) logger.info("") playlist_run_time = str(datetime.now() - playlist_start).split('.')[0] status[mapping_name]["run_time"] = playlist_run_time logger.info("") logger.separator(f"Finished {mapping_name} Playlist\nPlaylist Run Time: {playlist_run_time}") logger.remove_playlist_handler(playlist_log_name) return status, stats if __name__ == "__main__": try: if run_args["run"] or run_args["tests"] or run_args["run-collections"] or run_args["run-libraries"] or run_args["run-metadata-files"] or run_args["resume"]: process({"collections": run_args["run-collections"], "libraries": run_args["run-libraries"], "metadata_files": run_args["run-metadata-files"]}) else: times_to_run = util.get_list(run_args["times"]) valid_times = [] for time_to_run in times_to_run: try: final_time = datetime.strftime(datetime.strptime(time_to_run, "%H:%M"), "%H:%M") if final_time not in valid_times: valid_times.append(final_time) except ValueError: if time_to_run: raise Failed(f"Argument Error: time argument invalid: {time_to_run} must be in the HH:MM format between 00:00-23:59") else: raise Failed(f"Argument Error: blank time argument") for time_to_run in valid_times: schedule.every().day.at(time_to_run).do(process, {"time": time_to_run}) while True: schedule.run_pending() if not run_args["no-countdown"]: current_time = datetime.now().strftime("%H:%M") seconds = None og_time_str = "" for time_to_run in valid_times: new_seconds = (datetime.strptime(time_to_run, "%H:%M") - datetime.strptime(current_time, "%H:%M")).total_seconds() if new_seconds < 0: new_seconds += 86400 if (seconds is None or new_seconds < seconds) and new_seconds > 0: seconds = new_seconds og_time_str = time_to_run if seconds is not None: hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) time_str = f"{hours} Hour{'s' if hours > 1 else ''} and " if hours > 0 else "" time_str += f"{minutes} Minute{'s' if minutes > 1 else ''}" logger.ghost(f"Current Time: {current_time} | {time_str} until the next run at {og_time_str} | Runs: {', '.join(valid_times)}") else: logger.error(f"Time Error: {valid_times}") time.sleep(60) except KeyboardInterrupt: logger.separator("Exiting Plex Meta Manager")