import argparse, logging, os, sys, time from datetime import datetime from logging.handlers import RotatingFileHandler try: import plexapi, schedule from modules import util from modules.builder import CollectionBuilder from modules.config import ConfigFile from modules.meta import MetadataFile from modules.util import Failed, NotScheduled from plexapi.exceptions import NotFound from plexapi.video import Show, Season from ruamel import yaml except ModuleNotFoundError: print("Requirements Error: Requirements are not installed") sys.exit(0) if sys.version_info[0] != 3 or sys.version_info[1] < 6: print("Version Error: Version: %s.%s.%s incompatible please use Python 3.6+" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) sys.exit(0) parser = argparse.ArgumentParser() parser.add_argument("-db", "--debug", dest="debug", help=argparse.SUPPRESS, action="store_true", default=False) parser.add_argument("-tr", "--trace", dest="trace", help=argparse.SUPPRESS, action="store_true", default=False) parser.add_argument("-c", "--config", dest="config", help="Run with desired *.yml file", type=str) parser.add_argument("-t", "--time", "--times", dest="times", help="Times to update each day use format HH:MM (Default: 03:00) (comma-separated list)", default="03:00", 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("-is", "--ignore-schedules", dest="ignore_schedules", help="Run ignoring collection schedules", 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("-co", "--collection-only", "--collections-only", dest="collection_only", help="Run only collection operations", action="store_true", default=False) parser.add_argument("-lo", "--library-only", "--libraries-only", dest="library_only", help="Run only library operations", action="store_true", default=False) parser.add_argument("-lf", "--library-first", "--libraries-first", dest="library_first", help="Run library operations before collections", 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("-rm", "-m", "--metadata", "--metadata-files", "--run-metadata-files", dest="metadata", help="Process only specified Metadata files (comma-separated list)", type=str) parser.add_argument("-dc", "--delete", "--delete-collections", dest="delete", help="Deletes all Collections in the Plex Library before running", action="store_true", default=False) parser.add_argument("-nc", "--no-countdown", dest="no_countdown", help="Run without displaying the countdown", action="store_true", default=False) parser.add_argument("-nm", "--no-missing", dest="no_missing", help="Run without running the missing section", action="store_true", default=False) parser.add_argument("-ro", "--read-only-config", dest="read_only_config", help="Run without writing to the config", action="store_true", default=False) 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) args = parser.parse_args() def get_arg(env_str, default, arg_bool=False, arg_int=False): env_var = os.environ.get(env_str) if env_var: if arg_bool: if env_var is True or env_var is False: return env_var elif env_var.lower() in ["t", "true"]: return True else: return False elif arg_int: return int(env_var) else: return str(env_var) else: return default config_file = get_arg("PMM_CONFIG", args.config) times = get_arg("PMM_TIME", args.times) run = get_arg("PMM_RUN", args.run, arg_bool=True) test = get_arg("PMM_TEST", args.test, arg_bool=True) ignore_schedules = get_arg("PMM_IGNORE_SCHEDULES", args.ignore_schedules, arg_bool=True) collection_only = get_arg("PMM_COLLECTIONS_ONLY", args.collection_only, arg_bool=True) library_only = get_arg("PMM_LIBRARIES_ONLY", args.library_only, arg_bool=True) library_first = get_arg("PMM_LIBRARIES_FIRST", args.library_first, arg_bool=True) collections = get_arg("PMM_COLLECTIONS", args.collections) libraries = get_arg("PMM_LIBRARIES", args.libraries) metadata_files = get_arg("PMM_METADATA_FILES", args.metadata) delete = get_arg("PMM_DELETE_COLLECTIONS", args.delete, arg_bool=True) resume = get_arg("PMM_RESUME", args.resume) no_countdown = get_arg("PMM_NO_COUNTDOWN", args.no_countdown, arg_bool=True) no_missing = get_arg("PMM_NO_MISSING", args.no_missing, arg_bool=True) read_only_config = get_arg("PMM_READ_ONLY_CONFIG", args.read_only_config, arg_bool=True) divider = get_arg("PMM_DIVIDER", args.divider) screen_width = get_arg("PMM_WIDTH", args.width, arg_int=True) debug = get_arg("PMM_DEBUG", args.debug, arg_bool=True) trace = get_arg("PMM_TRACE", args.trace, arg_bool=True) util.separating_character = divider[0] if screen_width < 90 or screen_width > 300: print(f"Argument Error: width argument invalid: {screen_width} must be an integer between 90 and 300 using the default 100") screen_width = 100 util.screen_width = screen_width default_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config") if config_file and os.path.exists(config_file): default_dir = os.path.join(os.path.dirname(os.path.abspath(config_file))) elif config_file and not os.path.exists(config_file): print(f"Config Error: config not found at {os.path.abspath(config_file)}") 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) os.makedirs(os.path.join(default_dir, "logs"), exist_ok=True) logger = logging.getLogger("Plex Meta Manager") logger.setLevel(logging.DEBUG) def fmt_filter(record): record.levelname = f"[{record.levelname}]" record.filename = f"[{record.filename}:{record.lineno}]" return True cmd_handler = logging.StreamHandler() cmd_handler.setLevel(logging.DEBUG if test or debug or trace else logging.INFO) logger.addHandler(cmd_handler) sys.excepthook = util.my_except_hook version = "Unknown" 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 = line break def start(attrs): file_logger = os.path.join(default_dir, "logs", "meta.log") should_roll_over = os.path.isfile(file_logger) file_handler = 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() logger.info("") logger.info(util.centered(" ____ _ __ __ _ __ __ ")) logger.info(util.centered("| _ \\| | _____ __ | \\/ | ___| |_ __ _ | \\/ | __ _ _ __ __ _ __ _ ___ _ __ ")) logger.info(util.centered("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_ \\ / _` |/ _` |/ _ \\ '__|")) logger.info(util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")) logger.info(util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")) logger.info(util.centered(" |___/ ")) logger.info(f" Version: {version}") if "time" in attrs and attrs["time"]: start_type = f"{attrs['time']} " elif "test" in attrs and attrs["test"]: 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 util.separator(debug=True) logger.debug(f"--config (PMM_CONFIG): {config_file}") logger.debug(f"--time (PMM_TIME): {times}") logger.debug(f"--run (PMM_RUN): {run}") logger.debug(f"--run-tests (PMM_TEST): {test}") logger.debug(f"--collections-only (PMM_COLLECTIONS_ONLY): {collection_only}") logger.debug(f"--libraries-only (PMM_LIBRARIES_ONLY): {library_only}") logger.debug(f"--libraries-first (PMM_LIBRARIES_FIRST): {library_first}") logger.debug(f"--run-collections (PMM_COLLECTIONS): {collections}") logger.debug(f"--run-libraries (PMM_LIBRARIES): {libraries}") logger.debug(f"--run-metadata-files (PMM_METADATA_FILES): {metadata_files}") logger.debug(f"--ignore-schedules (PMM_IGNORE_SCHEDULES): {ignore_schedules}") logger.debug(f"--delete-collections (PMM_DELETE_COLLECTIONS): {delete}") logger.debug(f"--resume (PMM_RESUME): {resume}") logger.debug(f"--no-countdown (PMM_NO_COUNTDOWN): {no_countdown}") logger.debug(f"--no-missing (PMM_NO_MISSING): {no_missing}") logger.debug(f"--read-only-config (PMM_READ_ONLY_CONFIG): {read_only_config}") logger.debug(f"--divider (PMM_DIVIDER): {divider}") logger.debug(f"--width (PMM_WIDTH): {screen_width}") logger.debug(f"--debug (PMM_DEBUG): {debug}") logger.debug(f"--trace (PMM_TRACE): {trace}") logger.debug("") util.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} try: config = ConfigFile(default_dir, attrs, read_only_config) except Exception as e: util.print_stacktrace() util.print_multiline(e, critical=True) else: try: stats = update_libraries(config) except Exception as e: config.notify(e) util.print_stacktrace() util.print_multiline(e, critical=True) 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: util.print_stacktrace() logger.error(f"Webhooks Error: {e}") util.separator(f"Finished {start_type}Run\nFinished: {end_time.strftime('%H:%M:%S %Y-%m-%d')} Run Time: {run_time}") logger.removeHandler(file_handler) def update_libraries(config): for library in config.libraries: if library.skip_library: logger.info("") util.separator(f"Skipping {library.name} Library") continue try: 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 = 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) plexapi.server.TIMEOUT = library.timeout logger.info("") util.separator(f"{library.name} Library") if config.library_first and library.library_operation and not config.test_mode and not collection_only: library_operations(config, library) logger.debug("") logger.debug(f"Mapping Name: {library.original_mapping_name}") logger.debug(f"Folder Name: {library.mapping_name}") logger.debug(f"Missing Path: {library.missing_path}") 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"Collection Minimum: {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 Missing: {library.save_missing}") 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 config.delete_collections: logger.info("") util.separator(f"Deleting all Collections from the {library.name} Library", space=False, border=False) logger.info("") for collection in library.get_all_collections(): logger.info(f"Collection {collection.title} Deleted") library.query(collection.delete) if not library.is_other and not library.is_music: logger.info("") util.separator(f"Mapping {library.name} Library", space=False, border=False) logger.info("") library.map_guids() 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: continue logger.info("") util.separator(f"Running {metadata_name} Metadata File\n{metadata.path}") if not config.test_mode and not config.resume_from and not collection_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 config.resume_from and config.resume_from not in collections_to_run: logger.info("") logger.warning(f"Collection: {config.resume_from} not in Metadata File: {metadata.path}") continue if collections_to_run and not library_only: logger.info("") util.separator(f"{'Test ' if config.test_mode else ''}Collections") logger.removeHandler(library_handler) run_collection(config, library, metadata, collections_to_run) logger.addHandler(library_handler) if library.run_sort: logger.info("") util.separator(f"Sorting {library.name} Library's Collections", space=False, border=False) logger.info("") for builder in library.run_sort: logger.info("") util.separator(f"Sorting {builder.name} Collection", space=False, border=False) logger.info("") builder.sort_collection() if not config.library_first and library.library_operation and not config.test_mode and not collection_only: library_operations(config, library) logger.removeHandler(library_handler) except Exception as e: library.notify(e) util.print_stacktrace() util.print_multiline(e, critical=True) playlist_status = {} playlist_stats = {} if config.playlist_files: os.makedirs(os.path.join(default_dir, "logs", "playlists"), exist_ok=True) pf_file_logger = os.path.join(default_dir, "logs", "playlists", "playlists.log") should_roll_over = os.path.isfile(pf_file_logger) playlists_handler = RotatingFileHandler(pf_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8") util.apply_formatter(playlists_handler) if should_roll_over: playlists_handler.doRollover() logger.addHandler(playlists_handler) playlist_status, playlist_stats = run_playlists(config) logger.removeHandler(playlists_handler) has_run_again = False for library in config.libraries: if library.run_again: has_run_again = True break amount_added = 0 if has_run_again and not library_only: logger.info("") util.separator("Run Again") logger.info("") for x in range(1, config.general["run_again_delay"] + 1): util.print_return(f"Waiting to run again in {config.general['run_again_delay'] - x + 1} minutes") for y in range(60): time.sleep(1) util.print_end() for library in config.libraries: if library.run_again: try: col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, f"library.log") library_handler = 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) logger.info("") util.separator(f"{library.name} Library Run Again") logger.info("") library.map_guids() for builder in library.run_again: logger.info("") util.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) util.print_stacktrace() util.print_multiline(e, error=True) logger.removeHandler(library_handler) except Exception as e: library.notify(e) util.print_stacktrace() util.print_multiline(e, critical=True) 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(section, status): logger.info("") util.separator(f"{section} Summary", space=False, border=False) logger.info("") logger.info(f"{'Title':^{longest}} | + | = | - | {'Status':^13}") breaker = f"{util.separating_character * longest}|{util.separating_character * 5}|{util.separating_character * 5}|{util.separating_character * 5}|" util.separator(breaker, space=False, border=False, side_space=False, left=True) for name, data in status.items(): logger.info(f"{name:<{longest}} | {data['added']:^3} | {data['unchanged']:^3} | {data['removed']:^3} | {data['status']}") if data["errors"]: for error in data["errors"]: util.print_multiline(error, info=True) logger.info("") util.separator("Summary") for library in config.libraries: print_status(library.name, library.status) if playlist_status: print_status("Playlists", playlist_status) stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0} 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"] 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"] return stats def library_operations(config, library): logger.info("") util.separator(f"{library.name} Library Operations") logger.info("") logger.debug(f"Assets For All: {library.assets_for_all}") logger.debug(f"Delete Collections With Less: {library.delete_collections_with_less}") logger.debug(f"Delete Unmanaged Collections: {library.delete_unmanaged_collections}") logger.debug(f"Mass Genre Update: {library.mass_genre_update}") logger.debug(f"Mass Audience Rating Update: {library.mass_audience_rating_update}") logger.debug(f"Mass Critic Rating Update: {library.mass_critic_rating_update}") logger.debug(f"Mass Trakt Rating Update: {library.mass_trakt_rating_update}") logger.debug(f"Mass Collection Mode Update: {library.mass_collection_mode}") logger.debug(f"Split Duplicates: {library.split_duplicates}") logger.debug(f"Radarr Add All Existing: {library.radarr_add_all_existing}") logger.debug(f"Radarr Remove by Tag: {library.radarr_remove_by_tag}") logger.debug(f"Sonarr Add All Existing: {library.sonarr_add_all_existing}") logger.debug(f"Sonarr Remove by Tag: {library.sonarr_remove_by_tag}") logger.debug(f"TMDb Collections: {library.tmdb_collections}") logger.debug(f"Genre Collections: {library.genre_collections}") logger.debug(f"Genre Mapper: {library.genre_mapper}") logger.debug(f"Metadata Backup: {library.metadata_backup}") logger.debug(f"Item Operation: {library.items_library_operation}") logger.debug("") if library.split_duplicates: items = library.search(**{"duplicate": True}) for item in items: item.split() logger.info(util.adjust_space(f"{item.title[:25]:<25} | Splitting")) tmdb_collections = {} if library.items_library_operation: items = library.get_all(load=True) radarr_adds = [] sonarr_adds = [] trakt_ratings = config.Trakt.user_ratings(library.is_movie) if library.mass_trakt_rating_update else [] for i, item in enumerate(items, 1): try: library.reload(item) except Failed as e: logger.error(e) continue util.print_return(f"Processing: {i}/{len(items)} {item.title}") if library.assets_for_all: library.find_assets(item) tmdb_id, tvdb_id, imdb_id = library.get_ids(item) if library.mass_trakt_rating_update: try: if library.is_movie and tmdb_id in trakt_ratings: new_rating = trakt_ratings[tmdb_id] elif library.is_show and tvdb_id in trakt_ratings: new_rating = trakt_ratings[tvdb_id] else: raise Failed if str(item.userRating) != str(new_rating): library.edit_query(item, {"userRating.value": new_rating, "userRating.locked": 1}) logger.info(util.adjust_space(f"{item.title[:25]:<25} | User Rating | {new_rating}")) except Failed: pass path = os.path.dirname(str(item.locations[0])) if library.is_movie else str(item.locations[0]) if library.Radarr and library.radarr_add_all_existing and tmdb_id: path = path.replace(library.Radarr.plex_path, library.Radarr.radarr_path) path = path[:-1] if path.endswith(('/', '\\')) else path radarr_adds.append((tmdb_id, path)) if library.Sonarr and library.sonarr_add_all_existing and tvdb_id: path = path.replace(library.Sonarr.plex_path, library.Sonarr.sonarr_path) path = path[:-1] if path.endswith(('/', '\\')) else path sonarr_adds.append((tvdb_id, path)) tmdb_item = None if library.tmdb_collections or library.mass_genre_update == "tmdb" or library.mass_audience_rating_update == "tmdb" or library.mass_critic_rating_update == "tmdb": tmdb_item = config.TMDb.get_item(item, tmdb_id, tvdb_id, imdb_id, is_movie=library.is_movie) 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 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: try: omdb_item = config.OMDb.get_omdb(imdb_id) except Failed as e: logger.error(util.adjust_space(str(e))) except Exception: logger.error(f"IMDb ID: {imdb_id}") raise else: logger.info(util.adjust_space(f"{item.title[:25]:<25} | No IMDb ID for Guid: {item.guid}")) tvdb_item = None if library.mass_genre_update == "tvdb": if tvdb_id: try: tvdb_item = config.TVDb.get_item(tvdb_id, library.is_movie) except Failed as e: logger.error(util.adjust_space(str(e))) else: logger.info(util.adjust_space(f"{item.title[:25]:<25} | No TVDb ID for Guid: {item.guid}")) if library.tmdb_collections and tmdb_item and tmdb_item.collection: tmdb_collections[tmdb_item.collection.id] = tmdb_item.collection.name if library.mass_genre_update: try: if tmdb_item and library.mass_genre_update == "tmdb": new_genres = [genre.name for genre in tmdb_item.genres] elif omdb_item and library.mass_genre_update in ["omdb", "imdb"]: new_genres = omdb_item.genres elif tvdb_item and library.mass_genre_update == "tvdb": new_genres = tvdb_item.genres else: raise Failed library.edit_tags("genre", item, sync_tags=new_genres) except Failed: pass if library.mass_audience_rating_update: try: if tmdb_item and library.mass_audience_rating_update == "tmdb": new_rating = tmdb_item.vote_average elif omdb_item and library.mass_audience_rating_update in ["omdb", "imdb"]: new_rating = omdb_item.imdb_rating else: raise Failed if new_rating is None: logger.info(util.adjust_space(f"{item.title[:25]:<25} | No Rating Found")) else: if library.mass_audience_rating_update and str(item.audienceRating) != str(new_rating): library.edit_query(item, {"audienceRating.value": new_rating, "audienceRating.locked": 1}) logger.info(util.adjust_space(f"{item.title[:25]:<25} | Audience Rating | {new_rating}")) except Failed: pass if library.mass_critic_rating_update: try: if tmdb_item and library.mass_critic_rating_update == "tmdb": new_rating = tmdb_item.vote_average elif omdb_item and library.mass_critic_rating_update in ["omdb", "imdb"]: new_rating = omdb_item.imdb_rating else: raise Failed if new_rating is None: logger.info(util.adjust_space(f"{item.title[:25]:<25} | No Rating Found")) else: if library.mass_critic_rating_update and str(item.rating) != str(new_rating): library.edit_query(item, {"rating.value": new_rating, "rating.locked": 1}) logger.info(util.adjust_space(f"{item.title[:25]:<25} | Critic Rating | {new_rating}")) except Failed: pass if library.genre_mapper: try: adds = [] deletes = [] library.reload(item) for genre in item.genres: if genre.tag in library.genre_mapper: deletes.append(genre.tag) if library.genre_mapper[genre.tag]: adds.append(library.genre_mapper[genre.tag]) library.edit_tags("genre", item, add_tags=adds, remove_tags=deletes) except Failed: pass if library.Radarr and library.radarr_add_all_existing: try: library.Radarr.add_tmdb(radarr_adds) except Failed as e: logger.error(e) if library.Sonarr and library.sonarr_add_all_existing: try: library.Sonarr.add_tvdb(sonarr_adds) except Failed as e: logger.error(e) if tmdb_collections or library.genre_collections: logger.info("") util.separator(f"Starting Automated Collections") logger.info("") new_collections = {} templates = {} if tmdb_collections: templates["TMDb Collection"] = library.tmdb_collections["template"] for _i, _n in tmdb_collections.items(): if int(_i) not in library.tmdb_collections["exclude_ids"]: template = {"name": "TMDb Collection", "collection_id": _i} for k, v in library.tmdb_collections["dictionary_variables"].items(): if int(_i) in v: template[k] = v[int(_i)] for suffix in library.tmdb_collections["remove_suffix"]: if _n.endswith(suffix): _n = _n[:-len(suffix)] new_collections[_n.strip()] = {"template": template} if library.genre_collections: templates["Genre Collection"] = library.genre_collections["template"] for genre in library.get_tags("genre"): if genre.title not in library.genre_collections["exclude_genres"]: template = {"name": "Genre Collection", "genre": genre.title} for k, v in library.genre_collections["dictionary_variables"].items(): if genre.title in v: template[k] = v[genre.title] title = library.genre_collections["title_format"] title = title.replace("<>", genre.title) if "<>" in title: title = title.replace("<>", library.type) new_collections[title] = {"template": template} metadata = MetadataFile(config, library, "Data", {"collections": new_collections, "templates": templates}) if metadata.collections: library.collections.extend([c for c in metadata.collections]) run_collection(config, library, metadata, metadata.get_collections(None)) if library.radarr_remove_by_tag: library.Radarr.remove_all_with_tags(library.radarr_remove_by_tag) if library.sonarr_remove_by_tag: library.Sonarr.remove_all_with_tags(library.sonarr_remove_by_tag) if library.delete_collections_with_less is not None or library.delete_unmanaged_collections: logger.info("") print_suffix = "" unmanaged = "" if library.delete_collections_with_less is not None and library.delete_collections_with_less > 0: print_suffix = f" with less then {library.delete_collections_with_less} item{'s' if library.delete_collections_with_less > 1 else ''}" if library.delete_unmanaged_collections: if library.delete_collections_with_less is None: unmanaged = "Unmanaged Collections " elif library.delete_collections_with_less > 0: unmanaged = "Unmanaged Collections and " util.separator(f"Deleting All {unmanaged}Collections{print_suffix}", space=False, border=False) logger.info("") unmanaged_collections = [] for col in library.get_all_collections(): if (library.delete_collections_with_less is not None and (library.delete_collections_with_less == 0 or col.childCount < library.delete_collections_with_less)) \ or (col.title not in library.collections and library.delete_unmanaged_collections): library.query(col.delete) logger.info(f"{col.title} Deleted") elif col.title not in library.collections: unmanaged_collections.append(col) if library.mass_collection_mode: library.collection_mode_query(col, library.mass_collection_mode) if library.show_unmanaged and len(unmanaged_collections) > 0: logger.info("") util.separator(f"Unmanaged Collections in {library.name} Library", space=False, border=False) logger.info("") for col in unmanaged_collections: logger.info(col.title) logger.info("") logger.info(f"{len(unmanaged_collections)} Unmanaged Collection{'s' if len(unmanaged_collections) > 1 else ''}") elif library.show_unmanaged: logger.info("") util.separator(f"No Unmanaged Collections in {library.name} Library", space=False, border=False) logger.info("") if library.assets_for_all and len(unmanaged_collections) > 0: logger.info("") util.separator(f"Unmanaged Collection Assets Check for {library.name} Library", space=False, border=False) logger.info("") for col in unmanaged_collections: library.find_assets(col) if library.metadata_backup: logger.info("") util.separator(f"Metadata Backup for {library.name} Library", space=False, border=False) logger.info("") logger.info(f"Metadata Backup Path: {library.metadata_backup['path']}") logger.info("") meta = {} items = library.get_all(load=True) titles = [i.title for i in items] for i, item in enumerate(items, 1): util.print_return(f"Processing: {i}/{len(items)} {item.title}") map_key, attrs = library.get_locked_attributes(item, titles) if attrs or library.metadata_backup["add_blank_entries"]: meta[map_key] = attrs util.print_end() with open(library.metadata_backup["path"], "w"): pass try: yaml.round_trip_dump({"metadata": meta}, open(library.metadata_backup["path"], "w", encoding="utf-8")) logger.info(f"{len(meta)} {library.type.capitalize()}{'s' if len(meta) > 1 else ''} Backed Up") except yaml.scanner.ScannerError as e: util.print_multiline(f"YAML Error: {util.tab_new_lines(e)}", error=True) def run_collection(config, library, metadata, requested_collections): logger.info("") for mapping_name, collection_attrs in requested_collections.items(): collection_start = datetime.now() if config.test_mode 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"]] \ and "test" in metadata.templates[data_template["name"]] \ and metadata.templates[data_template["name"]]["test"] is True: no_template_test = False if no_template_test: continue if config.resume_from and config.resume_from != mapping_name: continue elif config.resume_from == mapping_name: config.resume_from = None logger.info("") util.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) 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, "collection.log") should_roll_over = os.path.isfile(col_file_logger) collection_handler = 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) library.status[mapping_name] = {"status": "", "errors": [], "created": False, "modified": False, "deleted": False, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0} try: util.separator(f"{mapping_name} Collection in {library.name}") logger.info("") if output_str: logger.info(output_str) logger.info("") util.separator(f"Validating {mapping_name} Attributes", space=False, border=False) builder = CollectionBuilder(config, library, metadata, mapping_name, no_missing, collection_attrs) logger.info("") util.separator(f"Running {mapping_name} Collection", space=False, border=False) if len(builder.schedule) > 0: util.print_multiline(builder.schedule, info=True) if len(builder.smart_filter_details) > 0: logger.info("") util.print_multiline(builder.smart_filter_details, info=True) items_added = 0 items_removed = 0 valid = True 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'}") if builder.filters or builder.tmdb_filters: logger.info("") for filter_key, filter_value in builder.filters: logger.info(f"Collection Filter {filter_key}: {filter_value}") for filter_key, filter_value in builder.tmdb_filters: logger.info(f"Collection Filter {filter_key}: {filter_value}") builder.find_rating_keys() if len(builder.added_items) >= builder.minimum and builder.build_collection: items_added, items_unchanged = builder.add_to_collection() library.stats["added"] += items_added library.status[mapping_name]["added"] = items_added library.stats["unchanged"] += items_unchanged library.status[mapping_name]["unchanged"] = items_unchanged items_removed = 0 if builder.sync: items_removed = builder.sync_collection() library.stats["removed"] += items_removed library.status[mapping_name]["removed"] = items_removed elif len(builder.added_items) < builder.minimum and builder.build_collection: logger.info("") logger.info(f"Collection Minimum: {builder.minimum} not met for {mapping_name} Collection") valid = False if builder.details["delete_below_minimum"] and builder.obj: logger.info("") util.print_multiline(builder.delete(), info=True) builder.deleted = True 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[mapping_name]["radarr"] += radarr_add library.stats["sonarr"] += sonarr_add library.status[mapping_name]["sonarr"] += sonarr_add 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[mapping_name]["created"] = True elif items_added > 0 or items_removed > 0: library.stats["modified"] += 1 library.status[mapping_name]["modified"] = True except Failed: util.print_stacktrace() run_item_details = False logger.info("") util.separator("No Collection to Update", space=False, border=False) else: builder.update_details() if builder.deleted: library.stats["deleted"] += 1 library.status[mapping_name]["deleted"] = True 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 (builder.item_details or builder.custom_sort) and run_item_details and builder.builders: try: builder.load_collection_items() except Failed: logger.info("") util.separator("No Items Found", space=False, border=False) else: if builder.item_details: builder.update_item_details() if builder.custom_sort: library.run_sort.append(builder) # builder.sort_collection() 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) if library.status[mapping_name]["created"]: library.status[mapping_name]["status"] = "Created" elif library.status[mapping_name]["deleted"]: library.status[mapping_name]["status"] = "Deleted" elif library.status[mapping_name]["modified"]: library.status[mapping_name]["status"] = "Modified" else: library.status[mapping_name]["status"] = "Unchanged" except NotScheduled as e: util.print_multiline(e, info=True) library.status[mapping_name]["status"] = "Not Scheduled" except Failed as e: library.notify(e, collection=mapping_name) util.print_stacktrace() util.print_multiline(e, error=True) library.status[mapping_name]["status"] = "PMM Failure" library.status[mapping_name]["errors"].append(e) except Exception as e: library.notify(f"Unknown Error: {e}", collection=mapping_name) util.print_stacktrace() logger.error(f"Unknown Error: {e}") library.status[mapping_name]["status"] = "Unknown Error" library.status[mapping_name]["errors"].append(e) logger.info("") util.separator(f"Finished {mapping_name} Collection\nCollection Run Time: {str(datetime.now() - collection_start).split('.')[0]}") logger.removeHandler(collection_handler) def run_playlists(config): stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0} status = {} logger.info("") util.separator("Playlists") logger.info("") library_map = {_l.original_mapping_name: _l for _l in config.libraries} for playlist_file in config.playlist_files: for mapping_name, playlist_attrs in playlist_file.playlists.items(): playlist_start = datetime.now() if config.test_mode and ("test" not in playlist_attrs or playlist_attrs["test"] is not True): no_template_test = True if "template" in playlist_attrs and playlist_attrs["template"]: for data_template in util.get_list(playlist_attrs["template"], split=False): if "name" in data_template \ and data_template["name"] \ and playlist_file.templates \ and data_template["name"] in playlist_file.templates \ and playlist_file.templates[data_template["name"]] \ and "test" in playlist_file.templates[data_template["name"]] \ and playlist_file.templates[data_template["name"]]["test"] is True: no_template_test = False if no_template_test: continue if "name_mapping" in playlist_attrs and playlist_attrs["name_mapping"]: playlist_log_name, output_str = util.validate_filename(playlist_attrs["name_mapping"]) else: playlist_log_name, output_str = util.validate_filename(mapping_name) playlist_log_folder = os.path.join(default_dir, "logs", "playlists", playlist_log_name) os.makedirs(playlist_log_folder, exist_ok=True) ply_file_logger = os.path.join(playlist_log_folder, "playlist.log") should_roll_over = os.path.isfile(ply_file_logger) playlist_handler = RotatingFileHandler(ply_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8") util.apply_formatter(playlist_handler) if should_roll_over: playlist_handler.doRollover() logger.addHandler(playlist_handler) status[mapping_name] = {"status": "", "errors": [], "created": False, "modified": False, "deleted": False, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0} server_name = None library_names = None try: util.separator(f"{mapping_name} Playlist") logger.info("") if output_str: logger.info(output_str) logger.info("") if "libraries" not in playlist_attrs or not playlist_attrs["libraries"]: raise Failed("Playlist Error: libraries attribute is required and cannot be blank") pl_libraries = [] for pl_library in util.get_list(playlist_attrs["libraries"]): if str(pl_library) in library_map: pl_libraries.append(library_map[pl_library]) else: raise Failed(f"Playlist Error: Library: {pl_library} not defined") server_check = None for pl_library in pl_libraries: if server_check: if pl_library.PlexServer.machineIdentifier != server_check: raise Failed("Playlist Error: All defined libraries must be on the same server") else: server_check = pl_library.PlexServer.machineIdentifier sync_to_users = config.general["playlist_sync_to_users"] if "sync_to_users" in playlist_attrs: sync_to_users = playlist_attrs["sync_to_users"] elif "sync_to_user" in playlist_attrs: sync_to_users = playlist_attrs["sync_to_user"] else: logger.warning(f"Playlist Error: sync_to_users attribute not found defaulting to playlist_sync_to_user: {sync_to_users}") valid_users = [] plex_users = pl_libraries[0].users if sync_to_users: if str(sync_to_users) == "all": valid_users = plex_users else: for user in util.get_list(sync_to_users): if user in plex_users: valid_users.append(user) else: raise Failed(f"Playlist Error: User: {user} not found in plex\nOptions: {plex_users}") util.separator(f"Validating {mapping_name} Attributes", space=False, border=False) builder = CollectionBuilder(config, pl_libraries[0], playlist_file, mapping_name, no_missing, playlist_attrs, playlist=True, valid_users=valid_users) logger.info("") util.separator(f"Running {mapping_name} Playlist", space=False, border=False) if len(builder.schedule) > 0: util.print_multiline(builder.schedule, info=True) items_added = 0 items_removed = 0 valid = True logger.info("") logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}") if builder.filters or builder.tmdb_filters: logger.info("") for filter_key, filter_value in builder.filters: logger.info(f"Playlist Filter {filter_key}: {filter_value}") for filter_key, filter_value in builder.tmdb_filters: logger.info(f"Playlist Filter {filter_key}: {filter_value}") method, value = builder.builders[0] logger.debug("") logger.debug(f"Builder: {method}: {value}") logger.info("") items = [] if "plex" in method: ids = [] for pl_library in pl_libraries: ids.extend(pl_library.get_rating_keys(method, value)) elif "tautulli" in method: ids = [] for pl_library in pl_libraries: ids.extend(pl_library.Tautulli.get_rating_keys(pl_library, value, True)) else: ids = builder.gather_ids(method, value) if len(ids) > 0: total_ids = len(ids) logger.debug("") logger.debug(f"{total_ids} IDs Found: {ids}") for i, input_data in enumerate(ids, 1): input_id, id_type = input_data util.print_return(f"Parsing ID {i}/{total_ids}") if id_type == "tvdb_season": show_id, season_num = input_id.split("_") show_id = int(show_id) found = False for pl_library in pl_libraries: if show_id in pl_library.show_map: found = True show_item = pl_library.fetchItem(pl_library.show_map[show_id][0]) try: items.extend(show_item.season(season=int(season_num)).episodes()) except NotFound: builder.missing_parts.append(f"{show_item.title} Season: {season_num} Missing") break if not found and show_id not in builder.missing_shows: builder.missing_shows.append(show_id) elif id_type == "tvdb_episode": show_id, season_num, episode_num = input_id.split("_") show_id = int(show_id) found = False for pl_library in pl_libraries: if show_id in pl_library.show_map: found = True show_item = pl_library.fetchItem(pl_library.show_map[show_id][0]) try: items.append( show_item.episode(season=int(season_num), episode=int(episode_num))) except NotFound: builder.missing_parts.append( f"{show_item.title} Season: {season_num} Episode: {episode_num} Missing") break if not found and show_id not in builder.missing_shows: builder.missing_shows.append(show_id) else: rating_keys = [] if id_type == "ratingKey": rating_keys = input_id elif id_type == "tmdb": if input_id not in builder.ignore_ids: found = False for pl_library in pl_libraries: if input_id in pl_library.movie_map: found = True rating_keys = pl_library.movie_map[input_id] break if not found and input_id not in builder.missing_movies: builder.missing_movies.append(input_id) elif id_type in ["tvdb", "tmdb_show"]: if id_type == "tmdb_show": try: input_id = config.Convert.tmdb_to_tvdb(input_id, fail=True) except Failed as e: logger.warning(e) continue if input_id not in builder.ignore_ids: found = False for pl_library in pl_libraries: if input_id in pl_library.show_map: found = True rating_keys = pl_library.show_map[input_id] break if not found and input_id not in builder.missing_shows: builder.missing_shows.append(input_id) elif id_type == "imdb": if input_id not in builder.ignore_imdb_ids: found = False for pl_library in pl_libraries: if input_id in pl_library.imdb_map: found = True rating_keys = pl_library.imdb_map[input_id] break if not found: try: _id, tmdb_type = config.Convert.imdb_to_tmdb(input_id, fail=True) if tmdb_type == "episode": tmdb_id, season_num, episode_num = _id.split("_") show_id = config.Convert.tmdb_to_tvdb(tmdb_id, fail=True) show_id = int(show_id) found = False for pl_library in pl_libraries: if show_id in pl_library.show_map: found = True show_item = pl_library.fetchItem( pl_library.show_map[show_id][0]) try: items.append(show_item.episode(season=int(season_num), episode=int(episode_num))) except NotFound: builder.missing_parts.append( f"{show_item.title} Season: {season_num} Episode: {episode_num} Missing") break if not found and show_id not in builder.missing_shows: builder.missing_shows.append(show_id) elif tmdb_type == "movie" and builder.do_missing: if _id not in builder.missing_movies: builder.missing_movies.append(_id) elif tmdb_type == "show" and builder.do_missing: tvdb_id = config.Convert.tmdb_to_tvdb(_id, fail=True) if tvdb_id not in builder.missing_shows: builder.missing_shows.append(tvdb_id) except Failed as e: logger.warning(e) continue if not isinstance(rating_keys, list): rating_keys = [rating_keys] for rk in rating_keys: try: item = builder.fetch_item(rk) if isinstance(item, (Show, Season)): items.extend(item.episodes()) else: items.append(item) except Failed as e: logger.error(e) util.print_end() if len(items) > 0: builder.filter_and_save_items(items) if len(builder.added_items) >= builder.minimum: 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.added_items) < builder.minimum: logger.info("") logger.info(f"Playlist Minimum: {builder.minimum} not met for {mapping_name} Playlist") valid = False if builder.details["delete_below_minimum"] and builder.obj: logger.info("") util.print_multiline(builder.delete(), info=True) builder.deleted = True 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 try: builder.load_collection() if builder.created: stats["created"] += 1 status[mapping_name]["created"] = True elif items_added > 0 or items_removed > 0: stats["modified"] += 1 status[mapping_name]["modified"] = True except Failed: util.print_stacktrace() run_item_details = False logger.info("") util.separator("No Playlist to Update", space=False, border=False) else: builder.update_details() if builder.deleted: stats["deleted"] += 1 status[mapping_name]["deleted"] = True 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("") util.separator("No Items Found", space=False, border=False) else: if builder.item_details: builder.update_item_details() if builder.custom_sort: builder.sort_collection() if valid: builder.sync_playlist() builder.send_notifications(playlist=True) except NotScheduled as e: util.print_multiline(e, info=True) status[mapping_name]["status"] = "Not Scheduled" except Failed as e: config.notify(e, server=server_name, library=library_names, playlist=mapping_name) util.print_stacktrace() util.print_multiline(e, error=True) 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) util.print_stacktrace() logger.error(f"Unknown Error: {e}") status[mapping_name]["status"] = "Unknown Error" status[mapping_name]["errors"].append(e) logger.info("") util.separator(f"Finished {mapping_name} Playlist\nPlaylist Run Time: {str(datetime.now() - playlist_start).split('.')[0]}") logger.removeHandler(playlist_handler) return status, stats try: if run or test or collections or libraries or metadata_files or resume: start({ "config_file": config_file, "test": test, "delete": delete, "ignore_schedules": ignore_schedules, "collections": collections, "libraries": libraries, "metadata_files": metadata_files, "library_first": library_first, "resume": resume, "trace": trace }) else: times_to_run = util.get_list(times) valid_times = [] for time_to_run in times_to_run: try: valid_times.append(datetime.strftime(datetime.strptime(time_to_run, "%H:%M"), "%H:%M")) except ValueError: if time_to_run: raise Failed(f"Argument Error: time argument invalid: {time_to_run} must be in the HH:MM format") else: raise Failed(f"Argument Error: blank time argument") for time_to_run in valid_times: schedule.every().day.at(time_to_run).do(start, {"config_file": config_file, "time": time_to_run, "delete": delete, "library_first": library_first, "trace": trace}) while True: schedule.run_pending() if not 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 ''}" util.print_return(f"Current Time: {current_time} | {time_str} until the next run at {og_time_str} | Runs: {', '.join(times_to_run)}") else: logger.error(f"Time Error: {valid_times}") time.sleep(60) except KeyboardInterrupt: util.separator("Exiting Plex Meta Manager")