You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Plex-Meta-Manager/plex_meta_manager.py

1056 lines
53 KiB

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
import importlib.metadata
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"},
"cache-libraries": {"args": ["ca", "cache-library"], "type": "bool", "help": "Cache Library load for 1 day"},
"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)
plexapi_version = importlib.metadata.version('PlexAPI')
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 run_args["cache-libraries"] and list_key and expired is False:
logger.info(f"Library: {library.mapping_name} loaded from Cache")
temp_items = config.Cache.query_list_ids(list_key)
if not temp_items:
temp_items = library.cache_items()
if config.Cache and list_key:
config.Cache.delete_list_ids(list_key)
if config.Cache and run_args["cache-libraries"]:
list_key = config.Cache.update_list_cache("library", library.mapping_name, expired, 1)
config.Cache.update_list_ids(list_key, [(i.ratingKey, i.guid) for i in temp_items])
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")