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
2 years ago
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)"},
}
4 years ago
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()
4 years ago
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
3 years ago
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)
4 years ago
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"])
4 years ago
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
4 years ago
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))
4 years ago
sys.excepthook = my_except_hook
4 years ago
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
3 years ago
version = ("Unknown", "Unknown", 0)
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION")) as handle:
3 years ago
for line in handle.readlines():
line = line.strip()
if len(line) > 0:
version = util.parse_version(line)
3 years ago
break
branch = util.guess_branch(version, env_version, git_branch)
version = (version[0].replace("develop", branch), version[1].replace("develop", branch), version[2])
3 years ago
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()
3 years ago
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
3 years ago
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")
3 years ago
if "time" in attrs and attrs["time"]: start_type = f"{attrs['time']} "
elif run_args["tests"]: start_type = "Test "
3 years ago
elif "collections" in attrs and attrs["collections"]: start_type = "Collections "
elif "libraries" in attrs and attrs["libraries"]: start_type = "Libraries "
else: start_type = ""
4 years ago
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")
3 years ago
config = None
3 years ago
stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0, "names": []}
4 years ago
try:
config = ConfigFile(default_dir, attrs, secret_args)
4 years ago
except Exception as e:
logger.stacktrace()
logger.critical(e)
else:
try:
3 years ago
stats = run_config(config, stats)
except Exception as e:
config.notify(e)
logger.stacktrace()
logger.critical(e)
4 years ago
logger.info("")
end_time = datetime.now()
run_time = str(end_time - start_time).split(".")[0]
3 years ago
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}")
3 years ago
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()
4 years ago
3 years ago
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("")
2 years ago
logger.separator(f"Skipping {library.original_mapping_name} Library")
continue
library_status[library.name] = {}
try:
logger.add_library_handler(library.mapping_name)
3 years ago
plexapi.server.TIMEOUT = library.timeout
2 years ago
os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout)
logger.info("")
2 years ago
logger.separator(f"{library.original_mapping_name} Library")
logger.debug("")
2 years ago
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}")
3 years ago
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}")
3 years ago
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
2 years ago
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()
2 years ago
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("")
2 years ago
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):
4 years ago
logger.info("")
4 years ago
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:
4 years ago
continue
elif run_args["resume"] == mapping_name:
run_args["resume"] = None
logger.info("")
logger.separator(f"Resuming Collections")
4 years ago
4 years ago
if "name_mapping" in collection_attrs and collection_attrs["name_mapping"]:
collection_log_name, output_str = util.validate_filename(collection_attrs["name_mapping"])
4 years ago
else:
collection_log_name, output_str = util.validate_filename(mapping_name)
logger.add_collection_handler(library.mapping_name, collection_log_name)
3 years ago
library.status[str(mapping_name)] = {"status": "Unchanged", "errors": [], "added": 0, "unchanged": 0, "removed": 0, "radarr": 0, "sonarr": 0}
4 years ago
4 years ago
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}")
3 years ago
items_added = 0
items_removed = 0
if not builder.smart_url and builder.builders and not builder.blank_collection:
logger.info("")
4 years ago
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)
2 years ago
builder.display_filters()
2 years ago
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
3 years ago
library.status[str(mapping_name)]["added"] = items_added
library.stats["unchanged"] += items_unchanged
3 years ago
library.status[str(mapping_name)]["unchanged"] = items_unchanged
3 years ago
items_removed = 0
if builder.sync:
3 years ago
items_removed = builder.sync_collection()
library.stats["removed"] += items_removed
3 years ago
library.status[str(mapping_name)]["removed"] = items_removed
if builder.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0):
3 years ago
radarr_add, sonarr_add = builder.run_missing()
library.stats["radarr"] += radarr_add
3 years ago
library.status[str(mapping_name)]["radarr"] += radarr_add
library.stats["sonarr"] += sonarr_add
3 years ago
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}"
3 years ago
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()
3 years ago
if builder.created:
library.stats["created"] += 1
3 years ago
library.status[str(mapping_name)]["status"] = "Created"
3 years ago
elif items_added > 0 or items_removed > 0:
library.stats["modified"] += 1
3 years ago
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 = ""
3 years ago
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
3 years ago
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:
3 years ago
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)
3 years ago
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}")
3 years ago
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]
3 years ago
library.status[str(mapping_name)]["run_time"] = collection_run_time
4 years ago
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)
2 years ago
builder.display_filters()
builder.filter_and_save_items(ids)
2 years ago
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
2 years ago
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:
3 years ago
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:
3 years ago
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")