Merge pull request #514 from meisnate12/develop

v1.14.0
pull/544/head v1.14.0
meisnate12 3 years ago committed by GitHub
commit 0de6e2fe7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -12,7 +12,7 @@ The original concept for Plex Meta Manager is [Plex Auto Collections](https://gi
The script can update many metadata fields for movies, shows, collections, seasons, and episodes and can act as a backup if your plex DB goes down. It can even update metadata the plex UI can't like Season Names. If the time is put into the metadata configuration file you can have a way to recreate your library and all its metadata changes with the click of a button.
The script works with most Metadata agents including the new Plex Movie Agent, New Plex TV Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), and [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle).
The script works with most Metadata agents including the New Plex Movie Agent, New Plex TV Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle), and [XBMC NFO Movie and TV Agents](https://github.com/gboudreau/XBMCnfoMoviesImporter.bundle).
## Getting Started

@ -1 +1 @@
1.13.3
1.14.0

@ -13,25 +13,33 @@ libraries: # Library mappings must have a c
metadata_path:
- file: config/Anime.yml # You have to create this file the other is online
- git: meisnate12/AnimeCharts
playlist_files:
- file: config/playlists.yml
settings: # Can be individually specified per library as well
cache: true
cache_expiration: 60
asset_directory: config/assets
asset_folders: true
asset_depth: 0
create_asset_folders: false
dimensional_asset_rename: false
show_missing_season_assets: false
sync_mode: append
collection_minimum: 1
delete_below_minimum: true
delete_not_scheduled: false
run_again_delay: 2
missing_only_released: false
only_filter_missing: false
show_unmanaged: true
show_filtered: false
show_options: false
show_missing: true
show_missing_assets: true
save_missing: true
run_again_delay: 2
missing_only_released: false
only_filter_missing: false
collection_minimum: 1
delete_below_minimum: true
delete_not_scheduled: false
tvdb_language: eng
ignore_ids:
ignore_imdb_ids:
webhooks: # Can be individually specified per library as well
error:
run_start:

@ -50,15 +50,22 @@ country_codes = [
class AniList:
def __init__(self, config):
self.config = config
self.options = {
self._options = None
@property
def options(self):
if self._options:
return self._options
self._options = {
"Tag": {}, "Tag Category": {},
"Genre": {g.lower().replace(" ", "-"): g for g in self._request(genre_query, {})["data"]["GenreCollection"]},
"Country": {c: c.upper() for c in country_codes},
"Season": media_season, "Format": media_format, "Status": media_status, "Source": media_source,
}
for media_tag in self._request(tag_query, {})["data"]["MediaTagCollection"]:
self.options["Tag"][media_tag["name"].lower().replace(" ", "-")] = media_tag["name"]
self.options["Tag Category"][media_tag["category"].lower().replace(" ", "-")] = media_tag["category"]
self._options["Tag"][media_tag["name"].lower().replace(" ", "-")] = media_tag["name"]
self._options["Tag Category"][media_tag["category"].lower().replace(" ", "-")] = media_tag["category"]
return self._options
def _request(self, query, variables, level=1):
if self.config.trace_mode:

File diff suppressed because it is too large Load Diff

@ -202,7 +202,10 @@ class Cache:
if row and row[to_id]:
datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d")
time_between_insertion = datetime.now() - datetime_object
id_to_return = row[to_id] if to_id == "imdb_id" else int(row[to_id])
try:
id_to_return = int(row[to_id])
except ValueError:
id_to_return = row[to_id]
expired = time_between_insertion.days > self.expiration
out_type = row["media_type"] if return_type else None
if return_type:

@ -11,6 +11,7 @@ from modules.icheckmovies import ICheckMovies
from modules.imdb import IMDb
from modules.letterboxd import Letterboxd
from modules.mal import MyAnimeList
from modules.meta import PlaylistFile
from modules.notifiarr import Notifiarr
from modules.omdb import OMDb
from modules.plex import Plex
@ -21,18 +22,18 @@ from modules.tautulli import Tautulli
from modules.tmdb import TMDb
from modules.trakt import Trakt
from modules.tvdb import TVDb
from modules.util import Failed
from modules.util import Failed, NotScheduled
from modules.webhooks import Webhooks
from retrying import retry
from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager")
sync_modes = {"append": "Only Add Items to the Collection", "sync": "Add & Remove Items from the Collection"}
sync_modes = {"append": "Only Add Items to the Collection or Playlist", "sync": "Add & Remove Items from the Collection or Playlist"}
mass_update_options = {"tmdb": "Use TMDb Metadata", "omdb": "Use IMDb Metadata through OMDb"}
class Config:
def __init__(self, default_dir, attrs):
class ConfigFile:
def __init__(self, default_dir, attrs, read_only):
logger.info("Locating config...")
config_file = attrs["config_file"]
if config_file and os.path.exists(config_file): self.config_path = os.path.abspath(config_file)
@ -42,6 +43,7 @@ class Config:
logger.info(f"Using {self.config_path} as config")
self.default_dir = default_dir
self.read_only = read_only
self.test_mode = attrs["test"] if "test" in attrs else False
self.trace_mode = attrs["trace"] if "trace" in attrs else False
self.start_time = attrs["time_obj"]
@ -92,21 +94,25 @@ class Config:
hooks("collection_creation")
hooks("collection_addition")
hooks("collection_removal")
new_config["libraries"][library]["webhooks"]["collection_changes"] = changes if changes else None
hooks("collection_changes")
new_config["libraries"][library]["webhooks"]["changes"] = changes if changes else None
if "libraries" in new_config: new_config["libraries"] = new_config.pop("libraries")
if "playlists" in new_config: new_config["playlists"] = new_config.pop("playlists")
if "settings" in new_config: new_config["settings"] = new_config.pop("settings")
if "webhooks" in new_config:
temp = new_config.pop("webhooks")
changes = []
def hooks(attr):
if attr in temp:
items = util.get_list(temp.pop(attr), split=False)
if items:
changes.extend([w for w in items if w not in changes])
hooks("collection_creation")
hooks("collection_addition")
hooks("collection_removal")
temp["collection_changes"] = changes if changes else None
if "changes" not in temp:
changes = []
def hooks(attr):
if attr in temp:
items = util.get_list(temp.pop(attr), split=False)
if items:
changes.extend([w for w in items if w not in changes])
hooks("collection_creation")
hooks("collection_addition")
hooks("collection_removal")
hooks("collection_changes")
temp["changes"] = changes if changes else None
new_config["webhooks"] = temp
if "plex" in new_config: new_config["plex"] = new_config.pop("plex")
if "tmdb" in new_config: new_config["tmdb"] = new_config.pop("tmdb")
@ -118,7 +124,8 @@ class Config:
if "sonarr" in new_config: new_config["sonarr"] = new_config.pop("sonarr")
if "trakt" in new_config: new_config["trakt"] = new_config.pop("trakt")
if "mal" in new_config: new_config["mal"] = new_config.pop("mal")
yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2)
if not read_only:
yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2)
self.data = new_config
except yaml.scanner.ScannerError as e:
raise Failed(f"YAML Error: {util.tab_new_lines(e)}")
@ -135,6 +142,8 @@ class Config:
data = None
do_print = False
save = False
if self.read_only:
save = False
text = f"{attribute} attribute" if parent is None else f"{parent} sub-attribute {attribute}"
if data is None or attribute not in data:
message = f"{text} not found"
@ -145,9 +154,9 @@ class Config:
elif attribute not in loaded_config[parent]: loaded_config[parent][attribute] = default
else: endline = ""
yaml.round_trip_dump(loaded_config, open(self.config_path, "w"), indent=None, block_seq_indent=2)
if default_is_none and var_type in ["list", "int_list"]: return []
if default_is_none and var_type in ["list", "int_list", "comma_list"]: return default if default else []
elif data[attribute] is None:
if default_is_none and var_type in ["list", "int_list"]: return []
if default_is_none and var_type in ["list", "int_list", "comma_list"]: return default if default else []
elif default_is_none: return None
else: message = f"{text} is blank"
elif var_type == "url":
@ -163,6 +172,7 @@ class Config:
if os.path.exists(os.path.abspath(data[attribute])): return data[attribute]
else: message = f"Path {os.path.abspath(data[attribute])} does not exist"
elif var_type == "list": return util.get_list(data[attribute], split=False)
elif var_type == "comma_list": return util.get_list(data[attribute])
elif var_type == "int_list": return util.get_list(data[attribute], int_list=True)
elif var_type == "list_path":
temp_list = []
@ -217,7 +227,9 @@ class Config:
"cache_expiration": check_for_attribute(self.data, "cache_expiration", parent="settings", var_type="int", default=60),
"asset_directory": check_for_attribute(self.data, "asset_directory", parent="settings", var_type="list_path", default=[os.path.join(default_dir, "assets")], default_is_none=True),
"asset_folders": check_for_attribute(self.data, "asset_folders", parent="settings", var_type="bool", default=True),
"asset_depth": check_for_attribute(self.data, "asset_depth", parent="settings", var_type="int", default=0),
"create_asset_folders": check_for_attribute(self.data, "create_asset_folders", parent="settings", var_type="bool", default=False),
"dimensional_asset_rename": check_for_attribute(self.data, "dimensional_asset_rename", parent="settings", var_type="bool", default=False),
"show_missing_season_assets": check_for_attribute(self.data, "show_missing_season_assets", parent="settings", var_type="bool", default=False),
"sync_mode": check_for_attribute(self.data, "sync_mode", parent="settings", default="append", test_list=sync_modes),
"collection_minimum": check_for_attribute(self.data, "collection_minimum", parent="settings", var_type="int", default=1),
@ -228,19 +240,21 @@ class Config:
"only_filter_missing": check_for_attribute(self.data, "only_filter_missing", parent="settings", var_type="bool", default=False),
"show_unmanaged": check_for_attribute(self.data, "show_unmanaged", parent="settings", var_type="bool", default=True),
"show_filtered": check_for_attribute(self.data, "show_filtered", parent="settings", var_type="bool", default=False),
"show_options": check_for_attribute(self.data, "show_options", parent="settings", var_type="bool", default=False),
"show_missing": check_for_attribute(self.data, "show_missing", parent="settings", var_type="bool", default=True),
"show_missing_assets": check_for_attribute(self.data, "show_missing_assets", parent="settings", var_type="bool", default=True),
"save_missing": check_for_attribute(self.data, "save_missing", parent="settings", var_type="bool", default=True),
"tvdb_language": check_for_attribute(self.data, "tvdb_language", parent="settings", default="default"),
"ignore_ids": check_for_attribute(self.data, "ignore_ids", parent="settings", var_type="int_list", default_is_none=True),
"ignore_imdb_ids": check_for_attribute(self.data, "ignore_imdb_ids", parent="settings", var_type="list", default_is_none=True),
"playlist_sync_to_user": check_for_attribute(self.data, "playlist_sync_to_user", parent="settings", default="all"),
"assets_for_all": check_for_attribute(self.data, "assets_for_all", parent="settings", var_type="bool", default=False, save=False, do_print=False)
}
self.webhooks = {
"error": check_for_attribute(self.data, "error", parent="webhooks", var_type="list", default_is_none=True),
"run_start": check_for_attribute(self.data, "run_start", parent="webhooks", var_type="list", default_is_none=True),
"run_end": check_for_attribute(self.data, "run_end", parent="webhooks", var_type="list", default_is_none=True),
"collection_changes": check_for_attribute(self.data, "collection_changes", parent="webhooks", var_type="list", default_is_none=True)
"changes": check_for_attribute(self.data, "changes", parent="webhooks", var_type="list", default_is_none=True)
}
if self.general["cache"]:
util.separator()
@ -260,6 +274,7 @@ class Config:
"test": check_for_attribute(self.data, "test", parent="notifiarr", var_type="bool", default=False, do_print=False, save=False)
})
except Failed as e:
util.print_stacktrace()
logger.error(e)
logger.info(f"Notifiarr Connection {'Failed' if self.NotifiarrFactory is None else 'Successful'}")
else:
@ -340,8 +355,6 @@ class Config:
else:
logger.warning("mal attribute not found")
util.separator()
self.AniDB = None
if "anidb" in self.data:
util.separator()
@ -358,6 +371,63 @@ class Config:
if self.AniDB is None:
self.AniDB = AniDB(self, None)
util.separator()
self.playlist_names = []
self.playlist_files = []
playlists_pairs = []
if "playlist_files" in self.data:
logger.info("Reading in Playlist Files")
if self.data["playlist_files"] is None:
raise Failed("Config Error: playlist_files attribute is blank")
paths_to_check = self.data["playlist_files"] if isinstance(self.data["playlist_files"], list) else [self.data["playlist_files"]]
for path in paths_to_check:
if isinstance(path, dict):
def check_dict(attr):
if attr in path:
if path[attr] is None:
err = f"Config Error: playlist_files {attr} is blank"
self.errors.append(err)
logger.error(err)
else:
return path[attr]
url = check_dict("url")
if url:
playlists_pairs.append(("URL", url))
git = check_dict("git")
if git:
playlists_pairs.append(("Git", git))
file = check_dict("file")
if file:
playlists_pairs.append(("File", file))
folder = check_dict("folder")
if folder:
if os.path.isdir(folder):
yml_files = util.glob_filter(os.path.join(folder, "*.yml"))
if yml_files:
playlists_pairs.extend([("File", yml) for yml in yml_files])
else:
logger.error(f"Config Error: No YAML (.yml) files found in {folder}")
else:
logger.error(f"Config Error: Folder not found: {folder}")
else:
playlists_pairs.append(("File", path))
else:
default_playlist_file = os.path.abspath(os.path.join(self.default_dir, "playlists.yml"))
if os.path.exists(default_playlist_file):
playlists_pairs.append(("File", default_playlist_file))
logger.warning(f"playlist_files attribute not found using {default_playlist_file} as default")
else:
logger.warning("playlist_files attribute not found")
for file_type, playlist_file in playlists_pairs:
try:
playlist_obj = PlaylistFile(self, file_type, playlist_file)
self.playlist_names.extend([p for p in playlist_obj.playlists])
self.playlist_files.append(playlist_obj)
except Failed as e:
util.print_multiline(e, error=True)
self.TVDb = TVDb(self, self.general["tvdb_language"])
self.IMDb = IMDb(self)
self.Convert = Convert(self)
@ -418,13 +488,19 @@ class Config:
self.libraries = []
libs = check_for_attribute(self.data, "libraries", throw=True)
current_time = datetime.now()
for library_name, lib in libs.items():
if self.requested_libraries and library_name not in self.requested_libraries:
continue
util.separator()
params = {
"mapping_name": str(library_name),
"name": str(lib["library_name"]) if lib and "library_name" in lib and lib["library_name"] else str(library_name)
"name": str(lib["library_name"]) if lib and "library_name" in lib and lib["library_name"] else str(library_name),
"tmdb_collections": None,
"genre_mapper": None,
"radarr_remove_by_tag": None,
"sonarr_remove_by_tag": None,
"mass_collection_mode": None
}
display_name = f"{params['name']} ({params['mapping_name']})" if lib and "library_name" in lib and lib["library_name"] else params["mapping_name"]
@ -437,15 +513,18 @@ class Config:
logger.warning("Config Warning: Assets will not be used asset_directory attribute must be set under config or under this specific Library")
params["asset_folders"] = check_for_attribute(lib, "asset_folders", parent="settings", var_type="bool", default=self.general["asset_folders"], do_print=False, save=False)
params["asset_depth"] = check_for_attribute(lib, "asset_depth", parent="settings", var_type="int", default=self.general["asset_depth"], do_print=False, save=False)
params["sync_mode"] = check_for_attribute(lib, "sync_mode", parent="settings", test_list=sync_modes, default=self.general["sync_mode"], do_print=False, save=False)
params["show_unmanaged"] = check_for_attribute(lib, "show_unmanaged", parent="settings", var_type="bool", default=self.general["show_unmanaged"], do_print=False, save=False)
params["show_filtered"] = check_for_attribute(lib, "show_filtered", parent="settings", var_type="bool", default=self.general["show_filtered"], do_print=False, save=False)
params["show_options"] = check_for_attribute(lib, "show_options", parent="settings", var_type="bool", default=self.general["show_options"], do_print=False, save=False)
params["show_missing"] = check_for_attribute(lib, "show_missing", parent="settings", var_type="bool", default=self.general["show_missing"], do_print=False, save=False)
params["show_missing_assets"] = check_for_attribute(lib, "show_missing_assets", parent="settings", var_type="bool", default=self.general["show_missing_assets"], do_print=False, save=False)
params["save_missing"] = check_for_attribute(lib, "save_missing", parent="settings", var_type="bool", default=self.general["save_missing"], do_print=False, save=False)
params["missing_only_released"] = check_for_attribute(lib, "missing_only_released", parent="settings", var_type="bool", default=self.general["missing_only_released"], do_print=False, save=False)
params["only_filter_missing"] = check_for_attribute(lib, "only_filter_missing", parent="settings", var_type="bool", default=self.general["only_filter_missing"], do_print=False, save=False)
params["create_asset_folders"] = check_for_attribute(lib, "create_asset_folders", parent="settings", var_type="bool", default=self.general["create_asset_folders"], do_print=False, save=False)
params["dimensional_asset_rename"] = check_for_attribute(lib, "dimensional_asset_rename", parent="settings", var_type="bool", default=self.general["dimensional_asset_rename"], do_print=False, save=False)
params["show_missing_season_assets"] = check_for_attribute(lib, "show_missing_season_assets", parent="settings", var_type="bool", default=self.general["show_missing_season_assets"], do_print=False, save=False)
params["collection_minimum"] = check_for_attribute(lib, "collection_minimum", parent="settings", var_type="int", default=self.general["collection_minimum"], do_print=False, save=False)
params["delete_below_minimum"] = check_for_attribute(lib, "delete_below_minimum", parent="settings", var_type="bool", default=self.general["delete_below_minimum"], do_print=False, save=False)
@ -457,7 +536,7 @@ class Config:
params["ignore_imdb_ids"] = check_for_attribute(lib, "ignore_imdb_ids", parent="settings", var_type="list", default_is_none=True, do_print=False, save=False)
params["ignore_imdb_ids"].extend([i for i in self.general["ignore_imdb_ids"] if i not in params["ignore_imdb_ids"]])
params["error_webhooks"] = check_for_attribute(lib, "error", parent="webhooks", var_type="list", default=self.webhooks["error"], do_print=False, save=False, default_is_none=True)
params["collection_changes_webhooks"] = check_for_attribute(lib, "collection_creation", parent="webhooks", var_type="list", default=self.webhooks["collection_changes"], do_print=False, save=False, default_is_none=True)
params["changes_webhooks"] = check_for_attribute(lib, "changes", parent="webhooks", var_type="list", default=self.webhooks["changes"], do_print=False, save=False, default_is_none=True)
params["assets_for_all"] = check_for_attribute(lib, "assets_for_all", parent="settings", var_type="bool", default=self.general["assets_for_all"], do_print=False, save=False)
params["mass_genre_update"] = check_for_attribute(lib, "mass_genre_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=False)
params["mass_audience_rating_update"] = check_for_attribute(lib, "mass_audience_rating_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=False)
@ -466,8 +545,6 @@ class Config:
params["split_duplicates"] = check_for_attribute(lib, "split_duplicates", var_type="bool", default=False, save=False, do_print=False)
params["radarr_add_all"] = check_for_attribute(lib, "radarr_add_all", var_type="bool", default=False, save=False, do_print=False)
params["sonarr_add_all"] = check_for_attribute(lib, "sonarr_add_all", var_type="bool", default=False, save=False, do_print=False)
params["tmdb_collections"] = None
params["genre_mapper"] = None
if lib and "operations" in lib and lib["operations"]:
if isinstance(lib["operations"], dict):
@ -489,21 +566,40 @@ class Config:
params["split_duplicates"] = check_for_attribute(lib["operations"], "split_duplicates", var_type="bool", default=False, save=False)
if "radarr_add_all" in lib["operations"]:
params["radarr_add_all"] = check_for_attribute(lib["operations"], "radarr_add_all", var_type="bool", default=False, save=False)
if "radarr_remove_by_tag" in lib["operations"]:
params["radarr_remove_by_tag"] = check_for_attribute(lib["operations"], "radarr_remove_by_tag", var_type="comma_list", default=False, save=False)
if "sonarr_add_all" in lib["operations"]:
params["sonarr_add_all"] = check_for_attribute(lib["operations"], "sonarr_add_all", var_type="bool", default=False, save=False)
if "sonarr_remove_by_tag" in lib["operations"]:
params["sonarr_remove_by_tag"] = check_for_attribute(lib["operations"], "sonarr_remove_by_tag", var_type="comma_list", default=False, save=False)
if "mass_collection_mode" in lib["operations"]:
try:
params["mass_collection_mode"] = util.check_collection_mode(lib["operations"]["mass_collection_mode"])
except Failed as e:
logger.error(e)
if "tmdb_collections" in lib["operations"]:
params["tmdb_collections"] = {"exclude_ids": [], "remove_suffix": None, "template": {"tmdb_collection_details": "<<collection_id>>"}}
params["tmdb_collections"] = {
"exclude_ids": [],
"remove_suffix": [],
"dictionary_variables": {},
"template": {"tmdb_collection_details": "<<collection_id>>"}
}
if lib["operations"]["tmdb_collections"] and isinstance(lib["operations"]["tmdb_collections"], dict):
params["tmdb_collections"]["exclude_ids"] = check_for_attribute(lib["operations"]["tmdb_collections"], "exclude_ids", var_type="int_list", default_is_none=True, save=False)
params["tmdb_collections"]["remove_suffix"] = check_for_attribute(lib["operations"]["tmdb_collections"], "remove_suffix", default_is_none=True, save=False)
params["tmdb_collections"]["remove_suffix"] = check_for_attribute(lib["operations"]["tmdb_collections"], "remove_suffix", var_type="comma_list", default_is_none=True, save=False)
if "dictionary_variables" in lib["operations"]["tmdb_collections"] and lib["operations"]["tmdb_collections"]["dictionary_variables"] and isinstance(lib["operations"]["tmdb_collections"]["dictionary_variables"], dict):
for key, value in lib["operations"]["tmdb_collections"]["dictionary_variables"].items():
if isinstance(value, dict):
params["tmdb_collections"]["dictionary_variables"][key] = value
else:
logger.warning(f"Config Warning: tmdb_collections dictionary_variables {key} must be a dictionary")
if "template" in lib["operations"]["tmdb_collections"] and lib["operations"]["tmdb_collections"]["template"] and isinstance(lib["operations"]["tmdb_collections"]["template"], dict):
params["tmdb_collections"]["template"] = lib["operations"]["tmdb_collections"]["template"]
else:
logger.warning("Config Warning: Using default template for tmdb_collections")
else:
logger.error("Config Error: tmdb_collections blank using default settings")
if params["tmdb_collections"]["remove_suffix"]:
params["tmdb_collections"]["remove_suffix"] = params["tmdb_collections"]["remove_suffix"].strip()
if "genre_mapper" in lib["operations"]:
if lib["operations"]["genre_mapper"] and isinstance(lib["operations"]["genre_mapper"], dict):
params["genre_mapper"] = {}
@ -555,6 +651,18 @@ class Config:
else:
params["metadata_path"] = [("File", os.path.join(default_dir, f"{library_name}.yml"))]
params["default_dir"] = default_dir
params["skip_library"] = False
if lib and "schedule" in lib:
if not lib["schedule"]:
raise Failed(f"Config Error: schedule attribute is blank")
else:
logger.debug(f"Value: {lib['schedule']}")
try:
util.schedule_check("schedule", lib["schedule"], current_time, self.run_hour)
except NotScheduled:
params["skip_library"] = True
params["plex"] = {
"url": check_for_attribute(lib, "url", parent="plex", var_type="url", default=self.general["plex"]["url"], req_default=True, save=False),
"token": check_for_attribute(lib, "token", parent="plex", default=self.general["plex"]["token"], req_default=True, save=False),
@ -670,10 +778,10 @@ class Config:
self.notify(e)
raise
def notify(self, text, library=None, collection=None, critical=True):
def notify(self, text, server=None, library=None, collection=None, playlist=None, critical=True):
for error in util.get_list(text, split=False):
try:
self.Webhooks.error_hooks(error, library=library, collection=collection, critical=critical)
self.Webhooks.error_hooks(error, server=server, library=library, collection=collection, playlist=playlist, critical=critical)
except Failed as e:
util.print_stacktrace()
logger.error(f"Webhooks Error: {e}")

@ -10,22 +10,52 @@ anime_lists_url = "https://raw.githubusercontent.com/Fribb/anime-lists/master/an
class Convert:
def __init__(self, config):
self.config = config
self.anidb_ids = {}
self.mal_to_anidb = {}
self.anilist_to_anidb = {}
self.anidb_to_imdb = {}
self.anidb_to_tvdb = {}
for anime_id in self.config.get_json(anime_lists_url):
if "anidb_id" in anime_id:
self.anidb_ids[anime_id["anidb_id"]] = anime_id
if "mal_id" in anime_id:
self.mal_to_anidb[int(anime_id["mal_id"])] = int(anime_id["anidb_id"])
if "anilist_id" in anime_id:
self.anilist_to_anidb[int(anime_id["anilist_id"])] = int(anime_id["anidb_id"])
if "imdb_id" in anime_id and str(anime_id["imdb_id"]).startswith("tt"):
self.anidb_to_imdb[int(anime_id["anidb_id"])] = util.get_list(anime_id["imdb_id"])
if "thetvdb_id" in anime_id:
self.anidb_to_tvdb[int(anime_id["anidb_id"])] = int(anime_id["thetvdb_id"])
self._loaded = False
self._anidb_ids = {}
self._mal_to_anidb = {}
self._anilist_to_anidb = {}
self._anidb_to_imdb = {}
self._anidb_to_tvdb = {}
@property
def anidb_ids(self):
self._load_anime_conversion()
return self._anidb_ids
@property
def mal_to_anidb(self):
self._load_anime_conversion()
return self._mal_to_anidb
@property
def anilist_to_anidb(self):
self._load_anime_conversion()
return self._anilist_to_anidb
@property
def anidb_to_imdb(self):
self._load_anime_conversion()
return self._anidb_to_imdb
@property
def anidb_to_tvdb(self):
self._load_anime_conversion()
return self._anidb_to_tvdb
def _load_anime_conversion(self):
if not self._loaded:
for anime_id in self.config.get_json(anime_lists_url):
if "anidb_id" in anime_id:
self._anidb_ids[anime_id["anidb_id"]] = anime_id
if "mal_id" in anime_id:
self._mal_to_anidb[int(anime_id["mal_id"])] = int(anime_id["anidb_id"])
if "anilist_id" in anime_id:
self._anilist_to_anidb[int(anime_id["anilist_id"])] = int(anime_id["anidb_id"])
if "imdb_id" in anime_id and str(anime_id["imdb_id"]).startswith("tt"):
self._anidb_to_imdb[int(anime_id["anidb_id"])] = util.get_list(anime_id["imdb_id"])
if "thetvdb_id" in anime_id:
self._anidb_to_tvdb[int(anime_id["anidb_id"])] = int(anime_id["thetvdb_id"])
self._loaded = True
def anidb_to_ids(self, anidb_ids, library):
ids = []
@ -224,6 +254,16 @@ class Convert:
elif item_type == "imdb": imdb_id.append(check_id)
elif item_type == "thetvdb": tvdb_id.append(int(check_id))
elif item_type == "themoviedb": tmdb_id.append(int(check_id))
elif item_type in ["xbmcnfo", "xbmcnfotv"]:
if len(check_id) > 10:
raise Failed(f"XMBC NFO Local ID: {check_id}")
try:
if item_type == "xbmcnfo":
tmdb_id.append(int(check_id))
else:
tvdb_id.append(int(check_id))
except ValueError:
imdb_id.append(check_id)
elif item_type == "hama":
if check_id.startswith("tvdb"):
tvdb_id.append(int(re.search("-(.*)", check_id).group(1)))

@ -50,10 +50,9 @@ class FlixPatrol:
if len(ids) > 0 and ids[0]:
if "https://www.themoviedb.org" in ids[0]:
return util.regex_first_int(ids[0].split("https://www.themoviedb.org")[1], "TMDB Movie ID")
raise Failed(f"FlixPatrol Error: TMDb Movie ID not found in {ids[0]}")
raise Failed(f"FlixPatrol Error: TMDb Movie ID not found at {flixpatrol_url}")
def _parse_list(self, list_url, language, is_movie):
def _parse_list(self, list_url, language, is_movie, limit=0):
flixpatrol_urls = []
if list_url.startswith(urls["top10"]):
platform = list_url[len(urls["top10"]):].split("/")[0]
@ -73,7 +72,7 @@ class FlixPatrol:
list_url, language,
f"//a[@class='flex group' and .//span[.='{'Movie' if is_movie else 'TV Show'}']]/@href"
)
return flixpatrol_urls
return flixpatrol_urls if limit == 0 else flixpatrol_urls[:limit]
def validate_flixpatrol_lists(self, flixpatrol_lists, language, is_movie):
valid_lists = []
@ -81,7 +80,7 @@ class FlixPatrol:
list_url = flixpatrol_list.strip()
if not list_url.startswith(tuple([v for k, v in urls.items()])):
fails = "\n".join([f"{v} (For {k.replace('_', ' ').title()})" for k, v in urls.items()])
raise Failed(f"FlixPatrol Error: {list_url} must begin with either:{fails}")
raise Failed(f"FlixPatrol Error: {list_url} must begin with either:\n{fails}")
elif len(self._parse_list(list_url, language, is_movie)) > 0:
valid_lists.append(list_url)
else:
@ -133,7 +132,7 @@ class FlixPatrol:
logger.info(f"Processing FlixPatrol URL: {data}")
url = self.get_url(method, data, is_movie)
items = self._parse_list(url, language, is_movie)
items = self._parse_list(url, language, is_movie, limit=data["limit"] if isinstance(data, dict) else 0)
media_type = "movie" if is_movie else "show"
total_items = len(items)
if total_items > 0:

@ -5,7 +5,20 @@ from urllib.parse import urlparse, parse_qs
logger = logging.getLogger("Plex Meta Manager")
builders = ["imdb_list", "imdb_id"]
builders = ["imdb_list", "imdb_id", "imdb_chart"]
movie_charts = ["box_office", "popular_movies", "top_movies", "top_english", "top_indian", "lowest_rated"]
show_charts = ["popular_shows", "top_shows"]
charts = {
"box_office": "Box Office",
"popular_movies": "Most Popular Movies",
"popular_shows": "Most Popular TV Shows",
"top_movies": "Top 250 Movies",
"top_shows": "Top 250 TV Shows",
"top_english": "Top Rated English Movies",
"top_indian": "Top Rated Indian Movies",
"lowest_rated": "Lowest Rated Movies"
}
base_url = "https://www.imdb.com"
urls = {
"lists": f"{base_url}/list/ls",
@ -24,12 +37,31 @@ class IMDb:
if not isinstance(imdb_dict, dict):
imdb_dict = {"url": imdb_dict}
dict_methods = {dm.lower(): dm for dm in imdb_dict}
imdb_url = util.parse("url", imdb_dict, methods=dict_methods, parent="imdb_list").strip()
if "url" not in dict_methods:
raise Failed(f"Collection Error: imdb_list url attribute not found")
elif imdb_dict[dict_methods["url"]] is None:
raise Failed(f"Collection Error: imdb_list url attribute is blank")
else:
imdb_url = imdb_dict[dict_methods["url"]].strip()
if not imdb_url.startswith(tuple([v for k, v in urls.items()])):
fails = "\n".join([f"{v} (For {k.replace('_', ' ').title()})" for k, v in urls.items()])
raise Failed(f"IMDb Error: {imdb_url} must begin with either:{fails}")
self._total(imdb_url, language)
list_count = util.parse("limit", imdb_dict, datatype="int", methods=dict_methods, default=0, parent="imdb_list", minimum=0) if "limit" in dict_methods else 0
list_count = None
if "limit" in dict_methods:
if imdb_dict[dict_methods["limit"]] is None:
logger.warning(f"Collection Warning: imdb_list limit attribute is blank using 0 as default")
else:
try:
value = int(str(imdb_dict[dict_methods["limit"]]))
if 0 <= value:
list_count = value
except ValueError:
pass
if list_count is None:
logger.warning(f"Collection Warning: imdb_list limit attribute must be an integer 0 or greater using 0 as default")
if list_count is None:
list_count = 0
valid_lists.append({"url": imdb_url, "limit": list_count})
return valid_lists
@ -96,6 +128,27 @@ class IMDb:
return imdb_ids
raise Failed(f"IMDb Error: No IMDb IDs Found at {imdb_url}")
def _ids_from_chart(self, chart):
if chart == "box_office":
url = "chart/boxoffice"
elif chart == "popular_movies":
url = "chart/moviemeter"
elif chart == "popular_shows":
url = "chart/tvmeter"
elif chart == "top_movies":
url = "chart/top"
elif chart == "top_shows":
url = "chart/toptv"
elif chart == "top_english":
url = "chart/top-english-movies"
elif chart == "top_indian":
url = "india/top-rated-indian-movies"
elif chart == "lowest_rated":
url = "chart/bottom"
else:
raise Failed(f"IMDb Error: chart: {chart} not ")
return self.config.get_html(f"https://www.imdb.com/{url}").xpath("//div[@class='wlb_ribbon']/@data-tconst")
def get_imdb_ids(self, method, data, language):
if method == "imdb_id":
logger.info(f"Processing IMDb ID: {data}")
@ -104,5 +157,8 @@ class IMDb:
status = f"{data['limit']} Items at " if data['limit'] > 0 else ''
logger.info(f"Processing IMDb List: {status}{data['url']}")
return [(i, "imdb") for i in self._ids_from_url(data["url"], language, data["limit"])]
elif method == "imdb_chart":
logger.info(f"Processing IMDb Chart: {charts[data]}")
return [(_i, "imdb") for _i in self._ids_from_chart(data)]
else:
raise Failed(f"IMDb Error: Method {method} not supported")

@ -1,8 +1,8 @@
import logging, os, requests, shutil, time
from abc import ABC, abstractmethod
from modules import util
from modules.meta import Metadata
from modules.util import Failed, ImageData
from modules.meta import MetadataFile
from modules.util import Failed
from PIL import Image
from ruamel import yaml
@ -34,6 +34,8 @@ class Library(ABC):
self.name = params["name"]
self.original_mapping_name = params["mapping_name"]
self.metadata_path = params["metadata_path"]
self.skip_library = params["skip_library"]
self.asset_depth = params["asset_depth"]
self.asset_directory = params["asset_directory"] if params["asset_directory"] else []
self.default_dir = params["default_dir"]
self.mapping_name, output = util.validate_filename(self.original_mapping_name)
@ -41,6 +43,7 @@ class Library(ABC):
self.missing_path = os.path.join(self.default_dir, f"{self.mapping_name}_missing.yml")
self.asset_folders = params["asset_folders"]
self.create_asset_folders = params["create_asset_folders"]
self.dimensional_asset_rename = params["dimensional_asset_rename"]
self.show_missing_season_assets = params["show_missing_season_assets"]
self.sync_mode = params["sync_mode"]
self.collection_minimum = params["collection_minimum"]
@ -49,6 +52,7 @@ class Library(ABC):
self.missing_only_released = params["missing_only_released"]
self.show_unmanaged = params["show_unmanaged"]
self.show_filtered = params["show_filtered"]
self.show_options = params["show_options"]
self.show_missing = params["show_missing"]
self.show_missing_assets = params["show_missing_assets"]
self.save_missing = params["save_missing"]
@ -63,11 +67,14 @@ class Library(ABC):
self.mass_critic_rating_update = params["mass_critic_rating_update"]
self.mass_trakt_rating_update = params["mass_trakt_rating_update"]
self.radarr_add_all = params["radarr_add_all"]
self.radarr_remove_by_tag = params["radarr_remove_by_tag"]
self.sonarr_add_all = params["sonarr_add_all"]
self.sonarr_remove_by_tag = params["sonarr_remove_by_tag"]
self.mass_collection_mode = params["mass_collection_mode"]
self.tmdb_collections = params["tmdb_collections"]
self.genre_mapper = params["genre_mapper"]
self.error_webhooks = params["error_webhooks"]
self.collection_changes_webhooks = params["collection_changes_webhooks"]
self.changes_webhooks = params["changes_webhooks"]
self.split_duplicates = params["split_duplicates"] # TODO: Here or just in Plex?
self.clean_bundles = params["plex"]["clean_bundles"] # TODO: Here or just in Plex?
self.empty_trash = params["plex"]["empty_trash"] # TODO: Here or just in Plex?
@ -91,7 +98,7 @@ class Library(ABC):
metadata.append((file_type, metadata_file))
for file_type, metadata_file in metadata:
try:
meta_obj = Metadata(config, self, file_type, metadata_file)
meta_obj = MetadataFile(config, self, file_type, metadata_file)
if meta_obj.collections:
self.collections.extend([c for c in meta_obj.collections])
if meta_obj.metadata:
@ -100,9 +107,9 @@ class Library(ABC):
except Failed as e:
util.print_multiline(e, error=True)
if len(self.metadata_files) == 0 and not self.library_operation:
if len(self.metadata_files) == 0 and not self.library_operation and not self.config.playlist_files:
logger.info("")
raise Failed("Config Error: No valid metadata files or library operations found")
raise Failed("Config Error: No valid metadata files, playlist files, or library operations found")
if self.asset_directory:
logger.info("")
@ -190,8 +197,9 @@ class Library(ABC):
if background_uploaded:
self.config.Cache.update_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds", item.art, background.compare)
@abstractmethod
def notify(self, text, collection=None, critical=True):
self.config.notify(text, library=self, collection=collection, critical=critical)
pass
@abstractmethod
def _upload_image(self, item, image):
@ -247,30 +255,3 @@ class Library(ABC):
logger.info("")
logger.info(util.adjust_space(f"Processed {len(items)} {self.type}s"))
return items
def find_collection_assets(self, item, name=None, create=False):
if name is None:
name = item.title
for ad in self.asset_directory:
poster = None
background = None
if self.asset_folders:
if not os.path.isdir(os.path.join(ad, name)):
continue
poster_filter = os.path.join(ad, name, "poster.*")
background_filter = os.path.join(ad, name, "background.*")
else:
poster_filter = os.path.join(ad, f"{name}.*")
background_filter = os.path.join(ad, f"{name}_background.*")
matches = util.glob_filter(poster_filter)
if len(matches) > 0:
poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_url=False)
matches = util.glob_filter(background_filter)
if len(matches) > 0:
background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_poster=False, is_url=False)
if poster or background:
return poster, background
if create and self.asset_folders and not os.path.isdir(os.path.join(self.asset_directory[0], name)):
os.makedirs(os.path.join(self.asset_directory[0], name), exist_ok=True)
logger.info(f"Asset Directory Created: {os.path.join(self.asset_directory[0], name)}")
return None, None

@ -108,7 +108,7 @@ class MyAnimeList:
def _save(self, authorization):
if authorization is not None and "access_token" in authorization and authorization["access_token"] and self._check(authorization):
if self.authorization != authorization:
if self.authorization != authorization and self.config.read_only:
yaml.YAML().allow_duplicate_keys = True
config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path))
config["mal"]["authorization"] = {

@ -9,61 +9,211 @@ logger = logging.getLogger("Plex Meta Manager")
github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/"
class Metadata:
def __init__(self, config, library, file_type, path):
def get_dict(attribute, attr_data, check_list=None):
if check_list is None:
check_list = []
if attr_data and attribute in attr_data:
if attr_data[attribute]:
if isinstance(attr_data[attribute], dict):
new_dict = {}
for _name, _data in attr_data[attribute].items():
if _name in check_list:
logger.error(
f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name}")
elif _data is None:
logger.error(
f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} has no data")
elif not isinstance(_data, dict):
logger.error(
f"Config Warning: {attribute[:-1] if attribute[-1] == 's' else attribute}: {_name} must be a dictionary")
else:
new_dict[str(_name)] = _data
return new_dict
else:
logger.warning(f"Config Warning: {attribute} must be a dictionary")
else:
logger.warning(f"Config Warning: {attribute} attribute is blank")
return None
class DataFile:
def __init__(self, config, file_type, path):
self.config = config
self.library = library
self.type = file_type
self.path = path
def get_dict(attribute, attr_data, check_list=None):
if check_list is None:
check_list = []
if attr_data and attribute in attr_data:
if attr_data[attribute]:
if isinstance(attr_data[attribute], dict):
new_dict = {}
for a_name, a_data in attr_data[attribute].items():
if a_name in check_list:
logger.error(f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {a_name}")
else:
new_dict[str(a_name)] = a_data
return new_dict
else:
logger.warning(f"Config Warning: {attribute} must be a dictionary")
self.data_type = ""
self.templates = {}
def load_file(self):
try:
if self.type in ["URL", "Git"]:
content_path = self.path if self.type == "URL" else f"{github_base}{self.path}.yml"
response = self.config.get(content_path)
if response.status_code >= 400:
raise Failed(f"URL Error: No file found at {content_path}")
content = response.content
elif os.path.exists(os.path.abspath(self.path)):
content = open(self.path, encoding="utf-8")
else:
raise Failed(f"File Error: File does not exist {os.path.abspath(self.path)}")
data, _, _ = yaml.util.load_yaml_guess_indent(content)
return data
except yaml.scanner.ScannerError as ye:
raise Failed(f"YAML Error: {util.tab_new_lines(ye)}")
except Exception as e:
util.print_stacktrace()
raise Failed(f"YAML Error: {e}")
def apply_template(self, name, data, template):
if not self.templates:
raise Failed(f"{self.data_type} Error: No templates found")
elif not template:
raise Failed(f"{self.data_type} Error: template attribute is blank")
else:
logger.debug(f"Value: {template}")
for variables in util.get_list(template, split=False):
if not isinstance(variables, dict):
raise Failed(f"{self.data_type} Error: template attribute is not a dictionary")
elif "name" not in variables:
raise Failed(f"{self.data_type} Error: template sub-attribute name is required")
elif not variables["name"]:
raise Failed(f"{self.data_type} Error: template sub-attribute name is blank")
elif variables["name"] not in self.templates:
raise Failed(f"{self.data_type} Error: template {variables['name']} not found")
elif not isinstance(self.templates[variables["name"]], dict):
raise Failed(f"{self.data_type} Error: template {variables['name']} is not a dictionary")
else:
logger.warning(f"Config Warning: {attribute} attribute is blank")
return None
for tm in variables:
if not variables[tm]:
raise Failed(f"{self.data_type} Error: template sub-attribute {tm} is blank")
if self.data_type == "Collection" and "collection_name" not in variables:
variables["collection_name"] = str(name)
if self.data_type == "Playlist" and "playlist_name" not in variables:
variables["playlist_name"] = str(name)
template_name = variables["name"]
template = self.templates[template_name]
default = {}
if "default" in template:
if template["default"]:
if isinstance(template["default"], dict):
for dv in template["default"]:
if template["default"][dv]:
default[dv] = template["default"][dv]
else:
raise Failed(f"{self.data_type} Error: template default sub-attribute {dv} is blank")
else:
raise Failed(f"{self.data_type} Error: template sub-attribute default is not a dictionary")
else:
raise Failed(f"{self.data_type} Error: template sub-attribute default is blank")
optional = []
if "optional" in template:
if template["optional"]:
for op in util.get_list(template["optional"]):
if op not in default:
optional.append(str(op))
else:
logger.warning(f"Template Warning: variable {op} cannot be optional if it has a default")
else:
raise Failed(f"{self.data_type} Error: template sub-attribute optional is blank")
if "move_prefix" in template or "move_collection_prefix" in template:
prefix = None
if "move_prefix" in template:
prefix = template["move_prefix"]
elif "move_collection_prefix" in template:
logger.warning(f"{self.data_type} Error: template sub-attribute move_collection_prefix will run as move_prefix")
prefix = template["move_collection_prefix"]
if prefix:
for op in util.get_list(prefix):
variables["collection_name"] = variables["collection_name"].replace(f"{str(op).strip()} ", "") + f", {str(op).strip()}"
else:
raise Failed(f"{self.data_type} Error: template sub-attribute move_prefix is blank")
def check_data(_method, _data):
if isinstance(_data, dict):
final_data = {}
for sm, sd in _data.items():
try:
final_data[sm] = check_data(_method, sd)
except Failed:
continue
elif isinstance(_data, list):
final_data = []
for li in _data:
try:
final_data.append(check_data(_method, li))
except Failed:
continue
else:
txt = str(_data)
def scan_text(og_txt, var, var_value):
if og_txt == f"<<{var}>>":
return str(var_value)
elif f"<<{var}>>" in str(og_txt):
return str(og_txt).replace(f"<<{var}>>", str(var_value))
else:
return og_txt
for option in optional:
if option not in variables and f"<<{option}>>" in txt:
raise Failed
for variable, variable_data in variables.items():
if (variable == "collection_name" or variable == "playlist_name") and _method in ["radarr_tag", "item_radarr_tag", "sonarr_tag", "item_sonarr_tag"]:
txt = scan_text(txt, variable, variable_data.replace(",", ""))
elif variable != "name":
txt = scan_text(txt, variable, variable_data)
for dm, dd in default.items():
txt = scan_text(txt, dm, dd)
if txt in ["true", "True"]:
final_data = True
elif txt in ["false", "False"]:
final_data = False
else:
try:
num_data = float(txt)
final_data = int(num_data) if num_data.is_integer() else num_data
except (ValueError, TypeError):
final_data = txt
return final_data
new_attributes = {}
for method_name, attr_data in template.items():
if method_name not in data and method_name not in ["default", "optional", "move_collection_prefix", "move_prefix"]:
if attr_data is None:
logger.error(f"Template Error: template attribute {method_name} is blank")
continue
try:
new_attributes[method_name] = check_data(method_name, attr_data)
except Failed:
continue
return new_attributes
class MetadataFile(DataFile):
def __init__(self, config, library, file_type, path):
super().__init__(config, file_type, path)
self.data_type = "Collection"
self.library = library
if file_type == "Data":
self.metadata = None
self.collections = get_dict("collections", path, library.collections)
self.templates = get_dict("templates", path)
else:
try:
logger.info("")
logger.info(f"Loading Metadata {file_type}: {path}")
if file_type in ["URL", "Git"]:
content_path = path if file_type == "URL" else f"{github_base}{path}.yml"
response = self.config.get(content_path)
if response.status_code >= 400:
raise Failed(f"URL Error: No file found at {content_path}")
content = response.content
elif os.path.exists(os.path.abspath(path)):
content = open(path, encoding="utf-8")
else:
raise Failed(f"File Error: File does not exist {path}")
data, ind, bsi = yaml.util.load_yaml_guess_indent(content)
self.metadata = get_dict("metadata", data, library.metadatas)
self.templates = get_dict("templates", data)
self.collections = get_dict("collections", data, library.collections)
if self.metadata is None and self.collections is None:
raise Failed("YAML Error: metadata or collections attribute is required")
logger.info(f"Metadata File Loaded Successfully")
except yaml.scanner.ScannerError as ye:
raise Failed(f"YAML Error: {util.tab_new_lines(ye)}")
except Exception as e:
util.print_stacktrace()
raise Failed(f"YAML Error: {e}")
logger.info("")
logger.info(f"Loading Metadata {file_type}: {path}")
data = self.load_file()
self.metadata = get_dict("metadata", data, library.metadatas)
self.templates = get_dict("templates", data)
self.collections = get_dict("collections", data, library.collections)
if self.metadata is None and self.collections is None:
raise Failed("YAML Error: metadata or collections attribute is required")
logger.info(f"Metadata File Loaded Successfully")
def get_collections(self, requested_collections):
if requested_collections:
@ -97,7 +247,17 @@ class Metadata:
final_value = util.validate_date(value, name, return_as="%Y-%m-%d")
current = current[:-9]
elif var_type == "float":
final_value = util.parse(name, value, datatype="float", minimum=0, maximum=10)
if value is None:
raise Failed(f"Metadata Error: {name} attribute is blank")
final_value = None
try:
value = float(str(value))
if 0 <= value <= 10:
final_value = value
except ValueError:
pass
if final_value is None:
raise Failed(f"Metadata Error: {name} attribute must be a number between 0 and 10")
else:
final_value = value
if current != str(final_value):
@ -174,7 +334,17 @@ class Metadata:
logger.info("")
year = None
if "year" in methods:
year = util.parse("year", meta, datatype="int", methods=methods, minimum=1800, maximum=datetime.now().year + 1)
next_year = datetime.now().year + 1
if meta[methods["year"]] is None:
raise Failed("Metadata Error: year attribute is blank")
try:
year_value = int(str(meta[methods["year"]]))
if 1800 <= year_value <= next_year:
year = year_value
except ValueError:
pass
if year is None:
raise Failed(f"Metadata Error: year attribute must be an integer between 1800 and {next_year}")
title = mapping_name
if "title" in methods:
@ -379,3 +549,18 @@ class Metadata:
logger.error("Metadata Error: episodes attribute is blank")
elif "episodes" in methods:
logger.error("Metadata Error: episodes attribute only works for show libraries")
class PlaylistFile(DataFile):
def __init__(self, config, file_type, path):
super().__init__(config, file_type, path)
self.data_type = "Playlist"
self.playlists = {}
logger.info("")
logger.info(f"Loading Playlist File {file_type}: {path}")
data = self.load_file()
self.playlists = get_dict("playlists", data, self.config.playlist_names)
self.templates = get_dict("templates", data)
if not self.playlists:
raise Failed("YAML Error: playlists attribute is required")
logger.info(f"Playlist File Loaded Successfully")

@ -1,4 +1,5 @@
import logging
from json import JSONDecodeError
from modules.util import Failed
@ -14,9 +15,14 @@ class Notifiarr:
self.apikey = params["apikey"]
self.develop = params["develop"]
self.test = params["test"]
logger.debug(f"Environment: {'Test' if self.test else 'Develop' if self.develop else 'Production'}")
url, _ = self.get_url("user/validate/")
response = self.config.get(url)
response_json = response.json()
try:
response_json = response.json()
except JSONDecodeError as e:
logger.debug(e)
raise Failed("Notifiarr Error: Invalid response")
if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"):
logger.debug(f"Response: {response_json}")
raise Failed(f"({response.status_code} [{response.reason}]) {response_json}")
@ -27,5 +33,5 @@ class Notifiarr:
url = f"{dev_url if self.develop else base_url}{'notification/test' if self.test else f'{path}{self.apikey}'}"
if self.config.trace_mode:
logger.debug(url.replace(self.apikey, "APIKEY"))
params = {"event": "pmm" if self.test else "collections"}
params = {"event": "pmm"} if self.test else None
return url, params

@ -2,10 +2,13 @@ import logging, os, plexapi, requests
from modules import builder, util
from modules.library import Library
from modules.util import Failed, ImageData
from PIL import Image
from plexapi import utils
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.collection import Collection
from plexapi.playlist import Playlist
from plexapi.server import PlexServer
from plexapi.video import Movie, Show
from retrying import retry
from urllib import parse
from xml.etree.ElementTree import ParseError
@ -79,11 +82,6 @@ plex_languages = ["default", "ar-SA", "ca-ES", "cs-CZ", "da-DK", "de-DE", "el-GR
metadata_language_options = {lang.lower(): lang for lang in plex_languages}
metadata_language_options["default"] = None
use_original_title_options = {"default": -1, "no": 0, "yes": 1}
collection_mode_options = {
"default": "default", "hide": "hide",
"hide_items": "hideItems", "hideitems": "hideItems",
"show_items": "showItems", "showitems": "showItems"
}
collection_order_options = ["release", "alpha", "custom"]
collection_level_options = ["episode", "season"]
collection_mode_keys = {-1: "default", 0: "hide", 1: "hideItems", 2: "showItems"}
@ -254,6 +252,7 @@ class Plex(Library):
else:
raise Failed(f"Plex Error: Plex Library must be a Movies or TV Shows library")
self._users = []
self.agent = self.Plex.agent
self.is_movie = self.type == "Movie"
self.is_show = self.type == "Show"
@ -264,6 +263,9 @@ class Plex(Library):
self.tmdb_collections = None
logger.error("Config Error: tmdb_collections only work with Movie Libraries.")
def notify(self, text, collection=None, critical=True):
self.config.notify(text, server=self.PlexServer.friendlyName, library=self.name, collection=collection, critical=critical)
def set_server_preroll(self, preroll):
self.PlexServer.settings.get('cinemaTrailersPrerollID').set(preroll)
self.PlexServer.settings.save()
@ -304,10 +306,18 @@ class Plex(Library):
logger.info(util.adjust_space(f"Loaded {self.Plex._totalViewSize} {self.type}s"))
return results
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def create_playlist(self, name, items):
return self.PlexServer.createPlaylist(name, items=items)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def fetchItems(self, key, container_start, container_size):
return self.Plex.fetchItems(key, container_start=container_start, container_size=container_size)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def moveItem(self, obj, item, after):
obj.moveItem(item, after=after)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def query(self, method):
return method()
@ -325,7 +335,9 @@ class Plex(Library):
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def collection_mode_query(self, collection, data):
collection.modeUpdate(mode=data)
if int(collection.collectionMode) not in collection_mode_keys or collection_mode_keys[int(collection.collectionMode)] != data:
collection.modeUpdate(mode=data)
logger.info(f"Detail: collection_order updated Collection Order to {data}")
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def collection_order_query(self, collection, data):
@ -376,14 +388,19 @@ class Plex(Library):
final_search = search_translation[search_name] if search_name in search_translation else search_name
final_search = show_translation[final_search] if self.is_show and final_search in show_translation else final_search
try:
names = []
choices = {}
for choice in self.Plex.listFilterChoices(final_search):
if choice.title not in names:
names.append(choice.title)
if choice.key not in names:
names.append(choice.key)
choices[choice.title.lower()] = choice.title if title else choice.key
choices[choice.key.lower()] = choice.title if title else choice.key
return choices
return choices, names
except NotFound:
logger.debug(f"Search Attribute: {final_search}")
raise Failed(f"Collection Error: plex search attribute: {search_name} not supported")
raise Failed(f"Plex Error: plex_search attribute: {search_name} not supported")
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def get_labels(self):
@ -396,6 +413,16 @@ class Plex(Library):
else: method = None
return self.Plex._server.query(key, method=method)
@property
def users(self):
if not self._users:
users = []
for user in self.PlexServer.myPlexAccount().users():
if self.PlexServer.machineIdentifier in [s.machineIdentifier for s in user.servers]:
users.append(user.title)
self._users = users
return self._users
def alter_collection(self, item, collection, smart_label_collection=False, add=True):
if smart_label_collection:
self.query_data(item.addLabel if add else item.removeLabel, collection)
@ -473,15 +500,25 @@ class Plex(Library):
key += f"&promotedToSharedHome={1 if (shared is None and visibility['shared']) or shared else 0}"
self._query(key, post=True)
def get_playlist(self, title):
try:
return self.PlexServer.playlist(title)
except NotFound:
raise Failed(f"Plex Error: Playlist {title} not found")
def get_collection(self, data):
if isinstance(data, int):
return self.fetchItem(data)
elif isinstance(data, Collection):
return data
else:
for d in self.search(title=str(data), libtype="collection"):
cols = self.search(title=str(data), libtype="collection")
for d in cols:
if d.title == data:
return d
for d in cols:
logger.debug(f"Found: {d.title}")
logger.debug(f"Looking for: {data}")
raise Failed(f"Plex Error: Collection {data} not found")
def validate_collections(self, collections):
@ -541,7 +578,7 @@ class Plex(Library):
else:
raise Failed(f"Plex Error: Method {method} not supported")
if len(items) > 0:
ids = [item.ratingKey for item in items]
ids = [(item.ratingKey, "ratingKey") for item in items]
logger.debug("")
logger.debug(f"{len(ids)} Keys Found: {ids}")
return ids
@ -551,7 +588,7 @@ class Plex(Library):
def get_collection_items(self, collection, smart_label_collection):
if smart_label_collection:
return self.get_labeled_items(collection.title if isinstance(collection, Collection) else str(collection))
elif isinstance(collection, Collection):
elif isinstance(collection, (Collection, Playlist)):
if collection.smart:
return self.get_filter_items(self.smart_filter(collection))
else:
@ -564,7 +601,7 @@ class Plex(Library):
return self.Plex._search(key, None, 0, plexapi.X_PLEX_CONTAINER_SIZE)
def get_collection_name_and_items(self, collection, smart_label_collection):
name = collection.title if isinstance(collection, Collection) else str(collection)
name = collection.title if isinstance(collection, (Collection, Playlist)) else str(collection)
return name, self.get_collection_items(collection, smart_label_collection)
def get_tmdb_from_map(self, item):
@ -620,20 +657,36 @@ class Plex(Library):
logger.info(f"{obj.title[:25]:<25} | {attr.capitalize()} | {display}")
return len(display) > 0
def update_item_from_assets(self, item, overlay=None, create=False):
name = os.path.basename(os.path.dirname(str(item.locations[0])) if self.is_movie else str(item.locations[0]))
def find_assets(self, item, name=None, upload=True, overlay=None, folders=None, create=None):
if isinstance(item, Movie):
name = os.path.basename(os.path.dirname(str(item.locations[0])))
elif isinstance(item, Show):
name = os.path.basename(str(item.locations[0]))
elif isinstance(item, Collection):
name = name if name else item.title
else:
return None, None
if not folders:
folders = self.asset_folders
if not create:
create = self.create_asset_folders
found_folder = False
poster = None
background = None
for ad in self.asset_directory:
item_dir = None
if self.asset_folders:
if folders:
if os.path.isdir(os.path.join(ad, name)):
item_dir = os.path.join(ad, name)
else:
matches = util.glob_filter(os.path.join(ad, "*", name))
if len(matches) > 0:
item_dir = os.path.abspath(matches[0])
for n in range(1, self.asset_depth + 1):
new_path = ad
for i in range(1, n + 1):
new_path = os.path.join(new_path, "*")
matches = util.glob_filter(os.path.join(new_path, name))
if len(matches) > 0:
item_dir = os.path.abspath(matches[0])
break
if item_dir is None:
continue
found_folder = True
@ -642,15 +695,38 @@ class Plex(Library):
else:
poster_filter = os.path.join(ad, f"{name}.*")
background_filter = os.path.join(ad, f"{name}_background.*")
matches = util.glob_filter(poster_filter)
if len(matches) > 0:
poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_url=False)
matches = util.glob_filter(background_filter)
if len(matches) > 0:
background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_poster=False, is_url=False)
poster_matches = util.glob_filter(poster_filter)
if len(poster_matches) > 0:
poster = ImageData("asset_directory", os.path.abspath(poster_matches[0]), prefix=f"{item.title}'s ", is_url=False)
background_matches = util.glob_filter(background_filter)
if len(background_matches) > 0:
background = ImageData("asset_directory", os.path.abspath(background_matches[0]), prefix=f"{item.title}'s ", is_poster=False, is_url=False)
if item_dir and self.dimensional_asset_rename and (not poster or not background):
for file in util.glob_filter(os.path.join(item_dir, "*.*")):
if file.lower().endswith((".jpg", ".png", ".jpeg")):
image = Image.open(file)
_w, _h = image.size
image.close()
if not poster and _h > _w:
new_path = os.path.join(os.path.dirname(file), f"poster{os.path.splitext(file)[1].lower()}")
os.rename(file, new_path)
poster = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_url=False)
elif not background and _w > _h:
new_path = os.path.join(os.path.dirname(file), f"background{os.path.splitext(file)[1].lower()}")
os.rename(file, new_path)
background = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_poster=False, is_url=False)
if poster and background:
break
if poster or background:
self.upload_images(item, poster=poster, background=background, overlay=overlay)
if self.is_show:
if upload:
self.upload_images(item, poster=poster, background=background, overlay=overlay)
else:
return poster, background
if isinstance(item, Show):
missing_assets = ""
found_season = False
for season in self.query(item.seasons):
@ -685,12 +761,13 @@ class Plex(Library):
self.upload_images(episode, poster=episode_poster)
if self.show_missing_season_assets and found_season and missing_assets:
util.print_multiline(f"Missing Season Posters for {item.title}{missing_assets}", info=True)
if not poster and overlay:
if isinstance(item, (Movie, Show)) and not poster and overlay:
self.upload_images(item, overlay=overlay)
if create and self.asset_folders and not found_folder:
if create and folders and not found_folder:
os.makedirs(os.path.join(self.asset_directory[0], name), exist_ok=True)
logger.info(f"Asset Directory Created: {os.path.join(self.asset_directory[0], name)}")
elif not overlay and self.asset_folders and not found_folder:
elif isinstance(item, (Movie, Show)) and not overlay and folders and not found_folder:
logger.error(f"Asset Warning: No asset folder found called '{name}'")
elif not poster and not background and self.show_missing_assets:
elif isinstance(item, (Movie, Show)) and not poster and not background and self.show_missing_assets:
logger.error(f"Asset Warning: No poster or background found in an assets folder for '{name}'")
return None, None

@ -60,8 +60,9 @@ class Radarr:
if movie.path:
arr_paths[movie.path[:-1] if movie.path.endswith(("/", "\\")) else movie.path] = movie.tmdbId
arr_ids[movie.tmdbId] = movie
logger.debug(arr_paths)
logger.debug(arr_ids)
if self.config.trace_mode:
logger.debug(arr_paths)
logger.debug(arr_ids)
added = []
exists = []
@ -167,3 +168,18 @@ class Radarr:
logger.info("")
for tmdb_id in not_exists:
logger.info(f"TMDb ID Not in Radarr | {tmdb_id}")
def remove_all_with_tags(self, tags):
lower_tags = [_t.lower() for _t in tags]
remove_items = []
for movie in self.api.all_movies():
tag_strs = [_t.label.lower() for _t in movie.tags]
remove = True
for tag in lower_tags:
if tag not in tag_strs:
remove = False
break
if remove:
remove_items.append(movie)
if remove_items:
self.api.delete_multiple_movies(remove_items)

@ -6,7 +6,7 @@ from arrapi.exceptions import ArrException, Invalid
logger = logging.getLogger("Plex Meta Manager")
series_type = ["standard", "daily", "anime"]
series_types = ["standard", "daily", "anime"]
monitor_translation = {
"all": "all", "future": "future", "missing": "missing", "existing": "existing",
"pilot": "pilot", "first": "firstSeason", "latest": "latestSeason", "none": "none"
@ -66,7 +66,7 @@ class Sonarr:
_paths.append(tvdb_id)
else:
_ids.append(tvdb_id)
logger.debug(f"Radarr Adds: {_ids if _ids else ''}")
logger.debug(f"Sonarr Adds: {_ids if _ids else ''}")
for tvdb_id in _paths:
logger.debug(tvdb_id)
folder = options["folder"] if "folder" in options else self.root_folder_path
@ -74,7 +74,7 @@ class Sonarr:
quality_profile = options["quality"] if "quality" in options else self.quality_profile
language_profile = options["language"] if "language" in options else self.language_profile
language_profile = language_profile if self.api._raw.v3 else 1
series = options["series"] if "series" in options else self.series_type
series_type = options["series"] if "series" in options else self.series_type
season = options["season"] if "season" in options else self.season_folder
tags = options["tag"] if "tag" in options else self.tag
search = options["search"] if "search" in options else self.search
@ -86,8 +86,9 @@ class Sonarr:
if series.path:
arr_paths[series.path[:-1] if series.path.endswith(("/", "\\")) else series.path] = series.tvdbId
arr_paths[series.tvdbId] = series
logger.debug(arr_paths)
logger.debug(arr_ids)
if self.config.trace_mode:
logger.debug(arr_paths)
logger.debug(arr_ids)
added = []
exists = []
@ -127,7 +128,7 @@ class Sonarr:
if len(shows) == 100 or len(tvdb_ids) == i:
try:
_a, _e, _i = self.api.add_multiple_series(shows, folder, quality_profile, language_profile, monitor,
season, search, cutoff_search, series, tags, per_request=100)
season, search, cutoff_search, series_type, tags, per_request=100)
added.extend(_a)
exists.extend(_e)
invalid.extend(_i)
@ -193,3 +194,18 @@ class Sonarr:
logger.info("")
for tvdb_id in not_exists:
logger.info(f"TVDb ID Not in Sonarr | {tvdb_id}")
def remove_all_with_tags(self, tags):
lower_tags = [_t.lower() for _t in tags]
remove_items = []
for series in self.api.all_series():
tag_strs = [_t.label.lower() for _t in series.tags]
remove = True
for tag in lower_tags:
if tag not in tag_strs:
remove = False
break
if remove:
remove_items.append(series)
if remove_items:
self.api.delete_multiple_series(remove_items)

@ -49,11 +49,11 @@ class Tautulli:
plex_item = library.fetchItem(int(item["rating_key"]))
if not isinstance(plex_item, (Movie, Show)):
raise BadRequest
rating_keys.append(item["rating_key"])
rating_keys.append((item["rating_key"], "ratingKey"))
except (BadRequest, NotFound):
new_item = library.exact_search(item["title"], year=item["year"])
if new_item:
rating_keys.append(new_item[0].ratingKey)
rating_keys.append((new_item[0].ratingKey, "ratingKey"))
else:
logger.error(f"Plex Error: Item {item} not found")
logger.debug("")

@ -112,6 +112,9 @@ class TMDb:
return int(search["movie_results"][0]["id"]), "movie"
elif len(search["tv_results"]) > 0:
return int(search["tv_results"][0]["id"]), "show"
elif len(search["tv_episode_results"]) > 0:
item = search['tv_episode_results'][0]
return f"{item['show_id']}_{item['season_number']}_{item['episode_number']}", "episode"
else:
raise Failed(f"TMDb Error: No TMDb ID found for IMDb ID {imdb_id}")

@ -80,7 +80,7 @@ class Trakt:
def _save(self, authorization):
if authorization and self._check(authorization):
if self.authorization != authorization:
if self.authorization != authorization and self.config.read_only:
yaml.YAML().allow_duplicate_keys = True
config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path))
config["trakt"]["authorization"] = {

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from logging.handlers import RotatingFileHandler
from pathvalidate import is_valid_filename, sanitize_filename
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.video import Season, Episode, Movie
try:
import msvcrt
@ -65,6 +66,11 @@ pretty_months = {
}
seasons = ["winter", "spring", "summer", "fall"]
pretty_ids = {"anidbid": "AniDB", "imdbid": "IMDb", "mal_id": "MyAnimeList", "themoviedb_id": "TMDb", "thetvdb_id": "TVDb", "tvdbid": "TVDb"}
collection_mode_options = {
"default": "default", "hide": "hide",
"hide_items": "hideItems", "hideitems": "hideItems",
"show_items": "showItems", "showitems": "showItems"
}
def tab_new_lines(data):
return str(data).replace("\n", "\n|\t ") if "\n" in str(data) else str(data)
@ -241,6 +247,26 @@ def validate_filename(filename):
mapping_name = sanitize_filename(filename)
return mapping_name, f"Log Folder Name: {filename} is invalid using {mapping_name}"
def item_title(item):
if isinstance(item, Season):
if f"Season {item.index}" == item.title:
return f"{item.parentTitle} {item.title}"
else:
return f"{item.parentTitle} Season {item.index}: {item.title}"
elif isinstance(item, Episode):
text = f"{item.grandparentTitle} S{add_zero(item.parentIndex)}E{add_zero(item.index)}"
if f"Season {item.parentIndex}" == item.parentTitle:
return f"{text}: {item.title}"
else:
return f"{text}: {item.parentTitle}: {item.title}"
elif isinstance(item, Movie) and item.year:
return f"{item.title} ({item.year})"
else:
return item.title
def item_set(item, item_id):
return {"title": item_title(item), "tmdb" if isinstance(item, Movie) else "tvdb": item_id}
def is_locked(filepath):
locked = None
file_object = None
@ -256,26 +282,26 @@ def is_locked(filepath):
file_object.close()
return locked
def time_window(time_window):
def time_window(tw):
today = datetime.now()
if time_window == "today":
if tw == "today":
return f"{today:%Y-%m-%d}"
elif time_window == "yesterday":
elif tw == "yesterday":
return f"{today - timedelta(days=1):%Y-%m-%d}"
elif time_window == "this_week":
elif tw == "this_week":
return f"{today:%Y-0%V}"
elif time_window == "last_week":
elif tw == "last_week":
return f"{today - timedelta(weeks=1):%Y-0%V}"
elif time_window == "this_month":
elif tw == "this_month":
return f"{today:%Y-%m}"
elif time_window == "last_month":
elif tw == "last_month":
return f"{today.year}-{today.month - 1 or 12}"
elif time_window == "this_year":
elif tw == "this_year":
return f"{today.year}"
elif time_window == "last_year":
elif tw == "last_year":
return f"{today.year - 1}"
else:
return time_window
return tw
def glob_filter(filter_in):
filter_in = filter_in.translate({ord("["): "[[]", ord("]"): "[]]"}) if "[" in filter_in else filter_in
@ -309,6 +335,9 @@ def is_number_filter(value, modifier, data):
or (modifier == ".lt" and value >= data) \
or (modifier == ".lte" and value > data)
def is_boolean_filter(value, data):
return (data and not value) or (not data and value)
def is_string_filter(values, modifier, data):
jailbreak = False
for value in values:
@ -323,72 +352,101 @@ def is_string_filter(values, modifier, data):
if jailbreak: break
return (jailbreak and modifier in [".not", ".isnot"]) or (not jailbreak and modifier in ["", ".is", ".begins", ".ends", ".regex"])
def parse(attribute, data, datatype=None, methods=None, parent=None, default=None, options=None, translation=None, minimum=1, maximum=None, regex=None):
display = f"{parent + ' ' if parent else ''}{attribute} attribute"
if options is None and translation is not None:
options = [o for o in translation]
value = data[methods[attribute]] if methods and attribute in methods else data
if datatype == "list":
if value:
return [v for v in value if v] if isinstance(value, list) else [str(value)]
return []
elif datatype == "intlist":
if value:
try:
return [int(v) for v in value if v] if isinstance(value, list) else [int(value)]
except ValueError:
pass
return []
elif datatype == "dictlist":
final_list = []
for dict_data in get_list(value):
if isinstance(dict_data, dict):
final_list.append((dict_data, {dm.lower(): dm for dm in dict_data}))
else:
raise Failed(f"Collection Error: {display} {dict_data} is not a dictionary")
return final_list
elif methods and attribute not in methods:
message = f"{display} not found"
elif value is None:
message = f"{display} is blank"
elif regex is not None:
regex_str, example = regex
if re.compile(regex_str).match(str(value)):
return str(value)
else:
message = f"{display}: {value} must match pattern {regex_str} e.g. {example}"
elif datatype == "bool":
if isinstance(value, bool):
return value
elif isinstance(value, int):
return value > 0
elif str(value).lower() in ["t", "true"]:
return True
elif str(value).lower() in ["f", "false"]:
return False
else:
message = f"{display} must be either true or false"
elif datatype in ["int", "float"]:
try:
value = int(str(value)) if datatype == "int" else float(str(value))
if (maximum is None and minimum <= value) or (maximum is not None and minimum <= value <= maximum):
return value
except ValueError:
pass
pre = f"{display} {value} must {'an integer' if datatype == 'int' else 'a number'}"
if maximum is None:
message = f"{pre} {minimum} or greater"
else:
message = f"{pre} between {minimum} and {maximum}"
elif (translation is not None and str(value).lower() not in translation) or \
(options is not None and translation is None and str(value).lower() not in options):
message = f"{display} {value} must be in {', '.join([str(o) for o in options])}"
def check_collection_mode(collection_mode):
if collection_mode and str(collection_mode).lower() in collection_mode_options:
return collection_mode_options[str(collection_mode).lower()]
else:
return translation[value] if translation is not None else value
if default is None:
raise Failed(f"Collection Error: {message}")
raise Failed(f"Config Error: {collection_mode} collection_mode invalid\n\tdefault (Library default)\n\thide (Hide Collection)\n\thide_items (Hide Items in this Collection)\n\tshow_items (Show this Collection and its Items)")
def check_day(_m, _d):
if _m in [1, 3, 5, 7, 8, 10, 12] and _d > 31:
return _m, 31
elif _m in [4, 6, 9, 11] and _d > 30:
return _m, 30
elif _m == 2 and _d > 28:
return _m, 28
else:
logger.warning(f"Collection Warning: {message} using {default} as default")
return translation[default] if translation is not None else default
return _m, _d
def schedule_check(attribute, data, current_time, run_hour):
skip_collection = True
schedule_list = get_list(data)
next_month = current_time.replace(day=28) + timedelta(days=4)
last_day = next_month - timedelta(days=next_month.day)
schedule_str = ""
for schedule in schedule_list:
run_time = str(schedule).lower()
if run_time.startswith(("day", "daily")):
skip_collection = False
elif run_time == "never":
schedule_str += f"\nNever scheduled to run"
elif run_time.startswith(("hour", "week", "month", "year", "range")):
match = re.search("\\(([^)]+)\\)", run_time)
if not match:
logger.error(f"Schedule Error: failed to parse {attribute}: {schedule}")
continue
param = match.group(1)
if run_time.startswith("hour"):
try:
if 0 <= int(param) <= 23:
schedule_str += f"\nScheduled to run only on the {make_ordinal(int(param))} hour"
if run_hour == int(param):
skip_collection = False
else:
raise ValueError
except ValueError:
logger.error(f"Schedule Error: hourly {attribute} attribute {schedule} invalid must be an integer between 0 and 23")
elif run_time.startswith("week"):
if param.lower() not in days_alias:
logger.error(f"Schedule Error: weekly {attribute} attribute {schedule} invalid must be a day of the week i.e. weekly(Monday)")
continue
weekday = days_alias[param.lower()]
schedule_str += f"\nScheduled weekly on {pretty_days[weekday]}"
if weekday == current_time.weekday():
skip_collection = False
elif run_time.startswith("month"):
try:
if 1 <= int(param) <= 31:
schedule_str += f"\nScheduled monthly on the {make_ordinal(int(param))}"
if current_time.day == int(param) or (
current_time.day == last_day.day and int(param) > last_day.day):
skip_collection = False
else:
raise ValueError
except ValueError:
logger.error(f"Schedule Error: monthly {attribute} attribute {schedule} invalid must be an integer between 1 and 31")
elif run_time.startswith("year"):
try:
if "/" in param:
opt = param.split("/")
month = int(opt[0])
day = int(opt[1])
schedule_str += f"\nScheduled yearly on {pretty_months[month]} {make_ordinal(day)}"
if current_time.month == month and (current_time.day == day or (
current_time.day == last_day.day and day > last_day.day)):
skip_collection = False
else:
raise ValueError
except ValueError:
logger.error(
f"Schedule Error: yearly {attribute} attribute {schedule} invalid must be in the MM/DD format i.e. yearly(11/22)")
elif run_time.startswith("range"):
match = re.match("^(1[0-2]|0?[1-9])/(3[01]|[12][0-9]|0?[1-9])-(1[0-2]|0?[1-9])/(3[01]|[12][0-9]|0?[1-9])$", param)
if not match:
logger.error(f"Schedule Error: range {attribute} attribute {schedule} invalid must be in the MM/DD-MM/DD format i.e. range(12/01-12/25)")
continue
month_start, day_start = check_day(int(match.group(1)), int(match.group(2)))
month_end, day_end = check_day(int(match.group(3)), int(match.group(4)))
month_check, day_check = check_day(current_time.month, current_time.day)
check = datetime.strptime(f"{month_check}/{day_check}", "%m/%d")
start = datetime.strptime(f"{month_start}/{day_start}", "%m/%d")
end = datetime.strptime(f"{month_end}/{day_end}", "%m/%d")
schedule_str += f"\nScheduled between {pretty_months[month_start]} {make_ordinal(day_start)} and {pretty_months[month_end]} {make_ordinal(day_end)}"
if start <= check <= end if start < end else (check <= end or check >= start):
skip_collection = False
else:
logger.error(f"Schedule Error: {attribute} attribute {schedule} invalid")
if len(schedule_str) == 0:
skip_collection = False
if skip_collection:
raise NotScheduled(schedule_str)

@ -19,27 +19,30 @@ class Webhooks:
logger.debug("")
logger.debug(f"JSON: {json}")
for webhook in list(set(webhooks)):
response = None
if self.config.trace_mode:
logger.debug(f"Webhook: {webhook}")
if webhook == "notifiarr":
url, params = self.notifiarr.get_url("notification/plex/")
for x in range(6):
response = self.config.get(url, json=json, params=params)
if response.status_code < 500:
break
if self.notifiarr:
url, params = self.notifiarr.get_url("notification/pmm/")
for x in range(6):
response = self.config.get(url, json=json, params=params)
if response.status_code < 500:
break
else:
response = self.config.post(webhook, json=json)
try:
response_json = response.json()
if self.config.trace_mode:
logger.debug(f"Response: {response_json}")
if "result" in response_json and response_json["result"] == "error" and "details" in response_json and "response" in response_json["details"]:
raise Failed(f"Notifiarr Error: {response_json['details']['response']}")
if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"):
raise Failed(f"({response.status_code} [{response.reason}]) {response_json}")
except JSONDecodeError:
if response.status_code >= 400:
raise Failed(f"({response.status_code} [{response.reason}])")
if response:
try:
response_json = response.json()
if self.config.trace_mode:
logger.debug(f"Response: {response_json}")
if "result" in response_json and response_json["result"] == "error" and "details" in response_json and "response" in response_json["details"]:
raise Failed(f"Notifiarr Error: {response_json['details']['response']}")
if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"):
raise Failed(f"({response.status_code} [{response.reason}]) {response_json}")
except JSONDecodeError:
if response.status_code >= 400:
raise Failed(f"({response.status_code} [{response.reason}])")
def start_time_hooks(self, start_time):
if self.run_start_webhooks:
@ -60,36 +63,33 @@ class Webhooks:
"added_to_sonarr": stats["sonarr"],
})
def error_hooks(self, text, library=None, collection=None, critical=True):
def error_hooks(self, text, server=None, library=None, collection=None, playlist=None, critical=True):
if self.error_webhooks:
json = {"error": str(text), "critical": critical}
if library:
json["server_name"] = library.PlexServer.friendlyName
json["library_name"] = library.name
if collection:
json["collection"] = str(collection)
if server: json["server_name"] = str(server)
if library: json["library_name"] = str(library)
if collection: json["collection"] = str(collection)
if playlist: json["playlist"] = str(playlist)
self._request(self.error_webhooks, json)
def collection_hooks(self, webhooks, collection, created=False, deleted=False, additions=None, removals=None):
def collection_hooks(self, webhooks, collection, poster_url=None, background_url=None, created=False, deleted=False, additions=None, removals=None, playlist=False):
if self.library:
thumb = None
if collection.thumb and next((f for f in collection.fields if f.name == "thumb"), None):
if not poster_url and collection.thumb and next((f for f in collection.fields if f.name == "thumb"), None):
thumb = self.config.get_image_encoded(f"{self.library.url}{collection.thumb}?X-Plex-Token={self.library.token}")
art = None
if collection.art and next((f for f in collection.fields if f.name == "art"), None):
if not playlist and not background_url and collection.art and next((f for f in collection.fields if f.name == "art"), None):
art = self.config.get_image_encoded(f"{self.library.url}{collection.art}?X-Plex-Token={self.library.token}")
json = {
self._request(webhooks, {
"server_name": self.library.PlexServer.friendlyName,
"library_name": self.library.name,
"type": "movie" if self.library.is_movie else "show",
"collection": collection.title,
"playlist" if playlist else "collection": collection.title,
"created": created,
"deleted": deleted,
"poster": thumb,
"background": art
}
if additions:
json["additions"] = additions
if removals:
json["removals"] = removals
self._request(webhooks, json)
"background": art,
"poster_url": poster_url,
"background_url": background_url,
"additions": additions if additions else [],
"removals": removals if removals else [],
})

@ -1,12 +1,16 @@
import argparse, logging, os, sys, time
from datetime import datetime
from logging.handlers import RotatingFileHandler
from plexapi.exceptions import NotFound
from plexapi.video import Show, Season
try:
import plexapi, schedule
from modules import util
from modules.builder import CollectionBuilder
from modules.config import Config
from modules.meta import Metadata
from modules.config import ConfigFile
from modules.meta import MetadataFile
from modules.util import Failed, NotScheduled
except ModuleNotFoundError:
print("Requirements Error: Requirements are not installed")
@ -30,6 +34,7 @@ parser.add_argument("-rc", "-cl", "--collection", "--collections", "--run-collec
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("-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()
@ -62,6 +67,7 @@ libraries = get_arg("PMM_LIBRARIES", args.libraries)
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)
@ -149,6 +155,7 @@ def start(attrs):
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}")
@ -159,7 +166,7 @@ def start(attrs):
global stats
stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "removed": 0, "radarr": 0, "sonarr": 0}
try:
config = Config(default_dir, attrs)
config = ConfigFile(default_dir, attrs, read_only_config)
except Exception as e:
util.print_stacktrace()
util.print_multiline(e, critical=True)
@ -185,6 +192,10 @@ def start(attrs):
def update_libraries(config):
global stats
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")
@ -279,6 +290,18 @@ def update_libraries(config):
util.print_stacktrace()
util.print_multiline(e, critical=True)
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)
run_playlists(config)
logger.removeHandler(playlists_handler)
has_run_again = False
for library in config.libraries:
if library.run_again:
@ -345,9 +368,12 @@ def library_operations(config, library):
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: {library.radarr_add_all}")
logger.debug(f"Radarr Remove by Tag: {library.radarr_remove_by_tag}")
logger.debug(f"Sonarr Add All: {library.sonarr_add_all}")
logger.debug(f"Sonarr Remove by Tag: {library.sonarr_remove_by_tag}")
logger.debug(f"TMDb Collections: {library.tmdb_collections}")
logger.debug(f"Genre Mapper: {library.genre_mapper}")
tmdb_operation = library.assets_for_all or library.mass_genre_update or library.mass_audience_rating_update \
@ -376,7 +402,7 @@ def library_operations(config, library):
continue
util.print_return(f"Processing: {i}/{len(items)} {item.title}")
if library.assets_for_all:
library.update_item_from_assets(item, create=library.create_asset_folders)
library.find_assets(item)
tmdb_id = None
tvdb_id = None
imdb_id = None
@ -535,30 +561,40 @@ def library_operations(config, library):
logger.info("")
util.separator(f"Starting TMDb Collections")
logger.info("")
metadata = Metadata(config, library, "Data", {
"collections": {
_n.replace(library.tmdb_collections["remove_suffix"], "").strip() if library.tmdb_collections["remove_suffix"] else _n:
{"template": {"name": "TMDb Collection", "collection_id": _i}}
for _i, _n in tmdb_collections.items() if int(_i) not in library.tmdb_collections["exclude_ids"]
},
"templates": {
"TMDb Collection": library.tmdb_collections["template"]
}
new_collections = {}
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"]:
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(_n)]
new_collections[_n.strip()] = {"template": template}
metadata = MetadataFile(config, library, "Data", {
"collections": new_collections,
"templates": {"TMDb Collection": library.tmdb_collections["template"]}
})
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("")
suffix = ""
print_suffix = ""
unmanaged = ""
if library.delete_collections_with_less is not None and library.delete_collections_with_less > 0:
suffix = f" with less then {library.delete_collections_with_less} item{'s' if library.delete_collections_with_less > 1 else ''}"
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{suffix}", space=False, border=False)
util.separator(f"Deleting All {unmanaged}Collections{print_suffix}", space=False, border=False)
logger.info("")
unmanaged_collections = []
for col in library.get_all_collections():
@ -569,6 +605,8 @@ def library_operations(config, library):
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("")
@ -588,8 +626,7 @@ def library_operations(config, library):
util.separator(f"Unmanaged Collection Assets Check for {library.name} Library", space=False, border=False)
logger.info("")
for col in unmanaged_collections:
poster, background = library.find_collection_assets(col, create=library.create_asset_folders)
library.upload_images(col, poster=poster, background=background)
library.find_assets(col)
def run_collection(config, library, metadata, requested_collections):
global stats
@ -655,6 +692,7 @@ def run_collection(config, library, metadata, requested_collections):
items_added = 0
items_removed = 0
valid = True
if not builder.smart_url and builder.builders:
logger.info("")
logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}")
@ -668,7 +706,7 @@ def run_collection(config, library, metadata, requested_collections):
builder.find_rating_keys()
if len(builder.rating_keys) >= builder.minimum and builder.build_collection:
if len(builder.added_items) >= builder.minimum and builder.build_collection:
logger.info("")
util.separator(f"Adding to {mapping_name} Collection", space=False, border=False)
logger.info("")
@ -678,14 +716,14 @@ def run_collection(config, library, metadata, requested_collections):
if builder.sync:
items_removed = builder.sync_collection()
stats["removed"] += items_removed
elif len(builder.rating_keys) < builder.minimum and builder.build_collection:
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:
builder.delete_collection()
builder.deleted = True
logger.info("")
logger.info(f"Collection {builder.obj.title} deleted")
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):
if builder.details["show_missing"] is True:
@ -697,7 +735,7 @@ def run_collection(config, library, metadata, requested_collections):
stats["sonarr"] += sonarr_add
run_item_details = True
if builder.build_collection and builder.builders:
if valid and builder.build_collection and (builder.builders or builder.smart_url):
try:
builder.load_collection()
if builder.created:
@ -711,9 +749,6 @@ def run_collection(config, library, metadata, requested_collections):
util.separator("No Collection to Update", space=False, border=False)
else:
builder.update_details()
if builder.custom_sort:
library.run_sort.append(builder)
# builder.sort_collection()
if builder.deleted:
stats["deleted"] += 1
@ -723,16 +758,20 @@ def run_collection(config, library, metadata, requested_collections):
logger.info("")
logger.info(f"Plex Server Movie pre-roll video updated to {builder.server_preroll}")
builder.send_notifications()
if builder.item_details and run_item_details and builder.builders:
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:
builder.update_item_details()
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)
@ -752,6 +791,321 @@ def run_collection(config, library, metadata, requested_collections):
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):
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)
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_user"]
if "sync_to_users" not in playlist_attrs:
logger.warning(f"Playlist Error: sync_to_users attribute not found defaulting to playlist_sync_to_user: {sync_to_users}")
elif not playlist_attrs["sync_to_users"]:
logger.warning(f"Playlist Error: sync_to_users attribute is blank defaulting to playlist_sync_to_user: {sync_to_users}")
else:
sync_to_users = playlist_attrs["sync_to_users"]
valid_users = []
plex_users = pl_libraries[0].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 = []
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.error(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.error(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:
logger.info("")
util.separator(f"Adding to {mapping_name} Playlist", space=False, border=False)
logger.info("")
items_added = builder.add_to_collection()
stats["added"] += items_added
items_removed = 0
if builder.sync:
items_removed = builder.sync_collection()
stats["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):
if builder.details["show_missing"] is True:
logger.info("")
util.separator(f"Missing from Library", space=False, border=False)
logger.info("")
radarr_add, sonarr_add = builder.run_missing()
stats["radarr"] += radarr_add
stats["sonarr"] += sonarr_add
run_item_details = True
try:
builder.load_collection()
if builder.created:
stats["created"] += 1
elif items_added > 0 or items_removed > 0:
stats["modified"] += 1
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
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)
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)
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}")
logger.info("")
util.separator(
f"Finished {mapping_name} Playlist\nPlaylist Run Time: {str(datetime.now() - playlist_start).split('.')[0]}")
logger.removeHandler(playlist_handler)
try:
if run or test or collections or libraries or resume:
start({
@ -778,11 +1132,11 @@ try:
while True:
schedule.run_pending()
if not no_countdown:
current = datetime.now().strftime("%H:%M")
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, "%H:%M")).total_seconds()
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:
@ -793,7 +1147,7 @@ try:
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_str} until the next run at {og_time_str} | Runs: {', '.join(times_to_run)}")
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)

@ -1,7 +1,7 @@
PlexAPI==4.8.0
tmdbv3api==1.7.6
arrapi==1.2.8
lxml==4.6.4
arrapi==1.3.0
lxml==4.7.1
requests==2.26.0
ruamel.yaml==0.17.17
schedule==1.1.0

Loading…
Cancel
Save