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 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 ## Getting Started

@ -1 +1 @@
1.13.3 1.14.0

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

@ -50,15 +50,22 @@ country_codes = [
class AniList: class AniList:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.options = { self._options = None
@property
def options(self):
if self._options:
return self._options
self._options = {
"Tag": {}, "Tag Category": {}, "Tag": {}, "Tag Category": {},
"Genre": {g.lower().replace(" ", "-"): g for g in self._request(genre_query, {})["data"]["GenreCollection"]}, "Genre": {g.lower().replace(" ", "-"): g for g in self._request(genre_query, {})["data"]["GenreCollection"]},
"Country": {c: c.upper() for c in country_codes}, "Country": {c: c.upper() for c in country_codes},
"Season": media_season, "Format": media_format, "Status": media_status, "Source": media_source, "Season": media_season, "Format": media_format, "Status": media_status, "Source": media_source,
} }
for media_tag in self._request(tag_query, {})["data"]["MediaTagCollection"]: for media_tag in self._request(tag_query, {})["data"]["MediaTagCollection"]:
self.options["Tag"][media_tag["name"].lower().replace(" ", "-")] = media_tag["name"] 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 Category"][media_tag["category"].lower().replace(" ", "-")] = media_tag["category"]
return self._options
def _request(self, query, variables, level=1): def _request(self, query, variables, level=1):
if self.config.trace_mode: 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]: if row and row[to_id]:
datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d") datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d")
time_between_insertion = datetime.now() - datetime_object 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 expired = time_between_insertion.days > self.expiration
out_type = row["media_type"] if return_type else None out_type = row["media_type"] if return_type else None
if return_type: if return_type:

@ -11,6 +11,7 @@ from modules.icheckmovies import ICheckMovies
from modules.imdb import IMDb from modules.imdb import IMDb
from modules.letterboxd import Letterboxd from modules.letterboxd import Letterboxd
from modules.mal import MyAnimeList from modules.mal import MyAnimeList
from modules.meta import PlaylistFile
from modules.notifiarr import Notifiarr from modules.notifiarr import Notifiarr
from modules.omdb import OMDb from modules.omdb import OMDb
from modules.plex import Plex from modules.plex import Plex
@ -21,18 +22,18 @@ from modules.tautulli import Tautulli
from modules.tmdb import TMDb from modules.tmdb import TMDb
from modules.trakt import Trakt from modules.trakt import Trakt
from modules.tvdb import TVDb from modules.tvdb import TVDb
from modules.util import Failed from modules.util import Failed, NotScheduled
from modules.webhooks import Webhooks from modules.webhooks import Webhooks
from retrying import retry from retrying import retry
from ruamel import yaml from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager") 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"} mass_update_options = {"tmdb": "Use TMDb Metadata", "omdb": "Use IMDb Metadata through OMDb"}
class Config: class ConfigFile:
def __init__(self, default_dir, attrs): def __init__(self, default_dir, attrs, read_only):
logger.info("Locating config...") logger.info("Locating config...")
config_file = attrs["config_file"] config_file = attrs["config_file"]
if config_file and os.path.exists(config_file): self.config_path = os.path.abspath(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") logger.info(f"Using {self.config_path} as config")
self.default_dir = default_dir self.default_dir = default_dir
self.read_only = read_only
self.test_mode = attrs["test"] if "test" in attrs else False self.test_mode = attrs["test"] if "test" in attrs else False
self.trace_mode = attrs["trace"] if "trace" in attrs else False self.trace_mode = attrs["trace"] if "trace" in attrs else False
self.start_time = attrs["time_obj"] self.start_time = attrs["time_obj"]
@ -92,11 +94,14 @@ class Config:
hooks("collection_creation") hooks("collection_creation")
hooks("collection_addition") hooks("collection_addition")
hooks("collection_removal") 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 "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 "settings" in new_config: new_config["settings"] = new_config.pop("settings")
if "webhooks" in new_config: if "webhooks" in new_config:
temp = new_config.pop("webhooks") temp = new_config.pop("webhooks")
if "changes" not in temp:
changes = [] changes = []
def hooks(attr): def hooks(attr):
if attr in temp: if attr in temp:
@ -106,7 +111,8 @@ class Config:
hooks("collection_creation") hooks("collection_creation")
hooks("collection_addition") hooks("collection_addition")
hooks("collection_removal") hooks("collection_removal")
temp["collection_changes"] = changes if changes else None hooks("collection_changes")
temp["changes"] = changes if changes else None
new_config["webhooks"] = temp new_config["webhooks"] = temp
if "plex" in new_config: new_config["plex"] = new_config.pop("plex") if "plex" in new_config: new_config["plex"] = new_config.pop("plex")
if "tmdb" in new_config: new_config["tmdb"] = new_config.pop("tmdb") if "tmdb" in new_config: new_config["tmdb"] = new_config.pop("tmdb")
@ -118,6 +124,7 @@ class Config:
if "sonarr" in new_config: new_config["sonarr"] = new_config.pop("sonarr") 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 "trakt" in new_config: new_config["trakt"] = new_config.pop("trakt")
if "mal" in new_config: new_config["mal"] = new_config.pop("mal") if "mal" in new_config: new_config["mal"] = new_config.pop("mal")
if not read_only:
yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2) yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2)
self.data = new_config self.data = new_config
except yaml.scanner.ScannerError as e: except yaml.scanner.ScannerError as e:
@ -135,6 +142,8 @@ class Config:
data = None data = None
do_print = False do_print = False
save = False save = False
if self.read_only:
save = False
text = f"{attribute} attribute" if parent is None else f"{parent} sub-attribute {attribute}" text = f"{attribute} attribute" if parent is None else f"{parent} sub-attribute {attribute}"
if data is None or attribute not in data: if data is None or attribute not in data:
message = f"{text} not found" message = f"{text} not found"
@ -145,9 +154,9 @@ class Config:
elif attribute not in loaded_config[parent]: loaded_config[parent][attribute] = default elif attribute not in loaded_config[parent]: loaded_config[parent][attribute] = default
else: endline = "" else: endline = ""
yaml.round_trip_dump(loaded_config, open(self.config_path, "w"), indent=None, block_seq_indent=2) 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: 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 elif default_is_none: return None
else: message = f"{text} is blank" else: message = f"{text} is blank"
elif var_type == "url": elif var_type == "url":
@ -163,6 +172,7 @@ class Config:
if os.path.exists(os.path.abspath(data[attribute])): return data[attribute] if os.path.exists(os.path.abspath(data[attribute])): return data[attribute]
else: message = f"Path {os.path.abspath(data[attribute])} does not exist" 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 == "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 == "int_list": return util.get_list(data[attribute], int_list=True)
elif var_type == "list_path": elif var_type == "list_path":
temp_list = [] temp_list = []
@ -217,7 +227,9 @@ class Config:
"cache_expiration": check_for_attribute(self.data, "cache_expiration", parent="settings", var_type="int", default=60), "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_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_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), "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), "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), "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), "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), "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_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_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": 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), "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), "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"), "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_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), "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) "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 = { self.webhooks = {
"error": check_for_attribute(self.data, "error", parent="webhooks", var_type="list", default_is_none=True), "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_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), "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"]: if self.general["cache"]:
util.separator() 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) "test": check_for_attribute(self.data, "test", parent="notifiarr", var_type="bool", default=False, do_print=False, save=False)
}) })
except Failed as e: except Failed as e:
util.print_stacktrace()
logger.error(e) logger.error(e)
logger.info(f"Notifiarr Connection {'Failed' if self.NotifiarrFactory is None else 'Successful'}") logger.info(f"Notifiarr Connection {'Failed' if self.NotifiarrFactory is None else 'Successful'}")
else: else:
@ -340,8 +355,6 @@ class Config:
else: else:
logger.warning("mal attribute not found") logger.warning("mal attribute not found")
util.separator()
self.AniDB = None self.AniDB = None
if "anidb" in self.data: if "anidb" in self.data:
util.separator() util.separator()
@ -358,6 +371,63 @@ class Config:
if self.AniDB is None: if self.AniDB is None:
self.AniDB = AniDB(self, 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.TVDb = TVDb(self, self.general["tvdb_language"])
self.IMDb = IMDb(self) self.IMDb = IMDb(self)
self.Convert = Convert(self) self.Convert = Convert(self)
@ -418,13 +488,19 @@ class Config:
self.libraries = [] self.libraries = []
libs = check_for_attribute(self.data, "libraries", throw=True) libs = check_for_attribute(self.data, "libraries", throw=True)
current_time = datetime.now()
for library_name, lib in libs.items(): for library_name, lib in libs.items():
if self.requested_libraries and library_name not in self.requested_libraries: if self.requested_libraries and library_name not in self.requested_libraries:
continue continue
util.separator()
params = { params = {
"mapping_name": str(library_name), "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"] 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") 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_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["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_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_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"] = 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["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["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["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["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["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["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["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) 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"] = 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["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["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["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_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) 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["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["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["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 lib and "operations" in lib and lib["operations"]:
if isinstance(lib["operations"], dict): 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) params["split_duplicates"] = check_for_attribute(lib["operations"], "split_duplicates", var_type="bool", default=False, save=False)
if "radarr_add_all" in lib["operations"]: 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) 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"]: 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) 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"]: 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): 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"]["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): 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"] params["tmdb_collections"]["template"] = lib["operations"]["tmdb_collections"]["template"]
else: else:
logger.warning("Config Warning: Using default template for tmdb_collections") logger.warning("Config Warning: Using default template for tmdb_collections")
else: else:
logger.error("Config Error: tmdb_collections blank using default settings") 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 "genre_mapper" in lib["operations"]:
if lib["operations"]["genre_mapper"] and isinstance(lib["operations"]["genre_mapper"], dict): if lib["operations"]["genre_mapper"] and isinstance(lib["operations"]["genre_mapper"], dict):
params["genre_mapper"] = {} params["genre_mapper"] = {}
@ -555,6 +651,18 @@ class Config:
else: else:
params["metadata_path"] = [("File", os.path.join(default_dir, f"{library_name}.yml"))] params["metadata_path"] = [("File", os.path.join(default_dir, f"{library_name}.yml"))]
params["default_dir"] = default_dir 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"] = { params["plex"] = {
"url": check_for_attribute(lib, "url", parent="plex", var_type="url", default=self.general["plex"]["url"], req_default=True, save=False), "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), "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) self.notify(e)
raise 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): for error in util.get_list(text, split=False):
try: 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: except Failed as e:
util.print_stacktrace() util.print_stacktrace()
logger.error(f"Webhooks Error: {e}") logger.error(f"Webhooks Error: {e}")

@ -10,22 +10,52 @@ anime_lists_url = "https://raw.githubusercontent.com/Fribb/anime-lists/master/an
class Convert: class Convert:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.anidb_ids = {} self._loaded = False
self.mal_to_anidb = {} self._anidb_ids = {}
self.anilist_to_anidb = {} self._mal_to_anidb = {}
self.anidb_to_imdb = {} self._anilist_to_anidb = {}
self.anidb_to_tvdb = {} 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): for anime_id in self.config.get_json(anime_lists_url):
if "anidb_id" in anime_id: if "anidb_id" in anime_id:
self.anidb_ids[anime_id["anidb_id"]] = anime_id self._anidb_ids[anime_id["anidb_id"]] = anime_id
if "mal_id" in anime_id: if "mal_id" in anime_id:
self.mal_to_anidb[int(anime_id["mal_id"])] = int(anime_id["anidb_id"]) self._mal_to_anidb[int(anime_id["mal_id"])] = int(anime_id["anidb_id"])
if "anilist_id" in anime_id: if "anilist_id" in anime_id:
self.anilist_to_anidb[int(anime_id["anilist_id"])] = int(anime_id["anidb_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"): 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"]) self._anidb_to_imdb[int(anime_id["anidb_id"])] = util.get_list(anime_id["imdb_id"])
if "thetvdb_id" in anime_id: if "thetvdb_id" in anime_id:
self.anidb_to_tvdb[int(anime_id["anidb_id"])] = int(anime_id["thetvdb_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): def anidb_to_ids(self, anidb_ids, library):
ids = [] ids = []
@ -224,6 +254,16 @@ class Convert:
elif item_type == "imdb": imdb_id.append(check_id) elif item_type == "imdb": imdb_id.append(check_id)
elif item_type == "thetvdb": tvdb_id.append(int(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 == "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": elif item_type == "hama":
if check_id.startswith("tvdb"): if check_id.startswith("tvdb"):
tvdb_id.append(int(re.search("-(.*)", check_id).group(1))) tvdb_id.append(int(re.search("-(.*)", check_id).group(1)))

@ -50,10 +50,9 @@ class FlixPatrol:
if len(ids) > 0 and ids[0]: if len(ids) > 0 and ids[0]:
if "https://www.themoviedb.org" in 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") 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}") 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 = [] flixpatrol_urls = []
if list_url.startswith(urls["top10"]): if list_url.startswith(urls["top10"]):
platform = list_url[len(urls["top10"]):].split("/")[0] platform = list_url[len(urls["top10"]):].split("/")[0]
@ -73,7 +72,7 @@ class FlixPatrol:
list_url, language, list_url, language,
f"//a[@class='flex group' and .//span[.='{'Movie' if is_movie else 'TV Show'}']]/@href" 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): def validate_flixpatrol_lists(self, flixpatrol_lists, language, is_movie):
valid_lists = [] valid_lists = []
@ -81,7 +80,7 @@ class FlixPatrol:
list_url = flixpatrol_list.strip() list_url = flixpatrol_list.strip()
if not list_url.startswith(tuple([v for k, v in urls.items()])): 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()]) 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: elif len(self._parse_list(list_url, language, is_movie)) > 0:
valid_lists.append(list_url) valid_lists.append(list_url)
else: else:
@ -133,7 +132,7 @@ class FlixPatrol:
logger.info(f"Processing FlixPatrol URL: {data}") logger.info(f"Processing FlixPatrol URL: {data}")
url = self.get_url(method, data, is_movie) 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" media_type = "movie" if is_movie else "show"
total_items = len(items) total_items = len(items)
if total_items > 0: if total_items > 0:

@ -5,7 +5,20 @@ from urllib.parse import urlparse, parse_qs
logger = logging.getLogger("Plex Meta Manager") 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" base_url = "https://www.imdb.com"
urls = { urls = {
"lists": f"{base_url}/list/ls", "lists": f"{base_url}/list/ls",
@ -24,12 +37,31 @@ class IMDb:
if not isinstance(imdb_dict, dict): if not isinstance(imdb_dict, dict):
imdb_dict = {"url": imdb_dict} imdb_dict = {"url": imdb_dict}
dict_methods = {dm.lower(): dm for dm in 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()])): 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()]) 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}") raise Failed(f"IMDb Error: {imdb_url} must begin with either:{fails}")
self._total(imdb_url, language) 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}) valid_lists.append({"url": imdb_url, "limit": list_count})
return valid_lists return valid_lists
@ -96,6 +128,27 @@ class IMDb:
return imdb_ids return imdb_ids
raise Failed(f"IMDb Error: No IMDb IDs Found at {imdb_url}") 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): def get_imdb_ids(self, method, data, language):
if method == "imdb_id": if method == "imdb_id":
logger.info(f"Processing IMDb ID: {data}") logger.info(f"Processing IMDb ID: {data}")
@ -104,5 +157,8 @@ class IMDb:
status = f"{data['limit']} Items at " if data['limit'] > 0 else '' status = f"{data['limit']} Items at " if data['limit'] > 0 else ''
logger.info(f"Processing IMDb List: {status}{data['url']}") logger.info(f"Processing IMDb List: {status}{data['url']}")
return [(i, "imdb") for i in self._ids_from_url(data["url"], language, data["limit"])] 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: else:
raise Failed(f"IMDb Error: Method {method} not supported") raise Failed(f"IMDb Error: Method {method} not supported")

@ -1,8 +1,8 @@
import logging, os, requests, shutil, time import logging, os, requests, shutil, time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from modules import util from modules import util
from modules.meta import Metadata from modules.meta import MetadataFile
from modules.util import Failed, ImageData from modules.util import Failed
from PIL import Image from PIL import Image
from ruamel import yaml from ruamel import yaml
@ -34,6 +34,8 @@ class Library(ABC):
self.name = params["name"] self.name = params["name"]
self.original_mapping_name = params["mapping_name"] self.original_mapping_name = params["mapping_name"]
self.metadata_path = params["metadata_path"] 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.asset_directory = params["asset_directory"] if params["asset_directory"] else []
self.default_dir = params["default_dir"] self.default_dir = params["default_dir"]
self.mapping_name, output = util.validate_filename(self.original_mapping_name) 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.missing_path = os.path.join(self.default_dir, f"{self.mapping_name}_missing.yml")
self.asset_folders = params["asset_folders"] self.asset_folders = params["asset_folders"]
self.create_asset_folders = params["create_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.show_missing_season_assets = params["show_missing_season_assets"]
self.sync_mode = params["sync_mode"] self.sync_mode = params["sync_mode"]
self.collection_minimum = params["collection_minimum"] self.collection_minimum = params["collection_minimum"]
@ -49,6 +52,7 @@ class Library(ABC):
self.missing_only_released = params["missing_only_released"] self.missing_only_released = params["missing_only_released"]
self.show_unmanaged = params["show_unmanaged"] self.show_unmanaged = params["show_unmanaged"]
self.show_filtered = params["show_filtered"] self.show_filtered = params["show_filtered"]
self.show_options = params["show_options"]
self.show_missing = params["show_missing"] self.show_missing = params["show_missing"]
self.show_missing_assets = params["show_missing_assets"] self.show_missing_assets = params["show_missing_assets"]
self.save_missing = params["save_missing"] 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_critic_rating_update = params["mass_critic_rating_update"]
self.mass_trakt_rating_update = params["mass_trakt_rating_update"] self.mass_trakt_rating_update = params["mass_trakt_rating_update"]
self.radarr_add_all = params["radarr_add_all"] 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_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.tmdb_collections = params["tmdb_collections"]
self.genre_mapper = params["genre_mapper"] self.genre_mapper = params["genre_mapper"]
self.error_webhooks = params["error_webhooks"] 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.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.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? 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)) metadata.append((file_type, metadata_file))
for file_type, metadata_file in metadata: for file_type, metadata_file in metadata:
try: try:
meta_obj = Metadata(config, self, file_type, metadata_file) meta_obj = MetadataFile(config, self, file_type, metadata_file)
if meta_obj.collections: if meta_obj.collections:
self.collections.extend([c for c in meta_obj.collections]) self.collections.extend([c for c in meta_obj.collections])
if meta_obj.metadata: if meta_obj.metadata:
@ -100,9 +107,9 @@ class Library(ABC):
except Failed as e: except Failed as e:
util.print_multiline(e, error=True) 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("") 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: if self.asset_directory:
logger.info("") logger.info("")
@ -190,8 +197,9 @@ class Library(ABC):
if background_uploaded: if background_uploaded:
self.config.Cache.update_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds", item.art, background.compare) 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): def notify(self, text, collection=None, critical=True):
self.config.notify(text, library=self, collection=collection, critical=critical) pass
@abstractmethod @abstractmethod
def _upload_image(self, item, image): def _upload_image(self, item, image):
@ -247,30 +255,3 @@ class Library(ABC):
logger.info("") logger.info("")
logger.info(util.adjust_space(f"Processed {len(items)} {self.type}s")) logger.info(util.adjust_space(f"Processed {len(items)} {self.type}s"))
return items 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): def _save(self, authorization):
if authorization is not None and "access_token" in authorization and authorization["access_token"] and self._check(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 yaml.YAML().allow_duplicate_keys = True
config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path)) config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path))
config["mal"]["authorization"] = { config["mal"]["authorization"] = {

@ -9,49 +9,204 @@ logger = logging.getLogger("Plex Meta Manager")
github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/" 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):
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: if check_list is None:
check_list = [] check_list = []
if attr_data and attribute in attr_data: if attr_data and attribute in attr_data:
if attr_data[attribute]: if attr_data[attribute]:
if isinstance(attr_data[attribute], dict): if isinstance(attr_data[attribute], dict):
new_dict = {} new_dict = {}
for a_name, a_data in attr_data[attribute].items(): for _name, _data in attr_data[attribute].items():
if a_name in check_list: if _name in check_list:
logger.error(f"Config Warning: Skipping duplicate {attribute[:-1] if attribute[-1] == 's' else attribute}: {a_name}") 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: else:
new_dict[str(a_name)] = a_data new_dict[str(_name)] = _data
return new_dict return new_dict
else: else:
logger.warning(f"Config Warning: {attribute} must be a dictionary") logger.warning(f"Config Warning: {attribute} must be a dictionary")
else: else:
logger.warning(f"Config Warning: {attribute} attribute is blank") logger.warning(f"Config Warning: {attribute} attribute is blank")
return None return None
class DataFile:
def __init__(self, config, file_type, path):
self.config = config
self.type = file_type
self.path = path
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:
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": if file_type == "Data":
self.metadata = None self.metadata = None
self.collections = get_dict("collections", path, library.collections) self.collections = get_dict("collections", path, library.collections)
self.templates = get_dict("templates", path) self.templates = get_dict("templates", path)
else: else:
try:
logger.info("") logger.info("")
logger.info(f"Loading Metadata {file_type}: {path}") logger.info(f"Loading Metadata {file_type}: {path}")
if file_type in ["URL", "Git"]: data = self.load_file()
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.metadata = get_dict("metadata", data, library.metadatas)
self.templates = get_dict("templates", data) self.templates = get_dict("templates", data)
self.collections = get_dict("collections", data, library.collections) self.collections = get_dict("collections", data, library.collections)
@ -59,11 +214,6 @@ class Metadata:
if self.metadata is None and self.collections is None: if self.metadata is None and self.collections is None:
raise Failed("YAML Error: metadata or collections attribute is required") raise Failed("YAML Error: metadata or collections attribute is required")
logger.info(f"Metadata File Loaded Successfully") 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}")
def get_collections(self, requested_collections): def get_collections(self, requested_collections):
if requested_collections: if requested_collections:
@ -97,7 +247,17 @@ class Metadata:
final_value = util.validate_date(value, name, return_as="%Y-%m-%d") final_value = util.validate_date(value, name, return_as="%Y-%m-%d")
current = current[:-9] current = current[:-9]
elif var_type == "float": 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: else:
final_value = value final_value = value
if current != str(final_value): if current != str(final_value):
@ -174,7 +334,17 @@ class Metadata:
logger.info("") logger.info("")
year = None year = None
if "year" in methods: 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 title = mapping_name
if "title" in methods: if "title" in methods:
@ -379,3 +549,18 @@ class Metadata:
logger.error("Metadata Error: episodes attribute is blank") logger.error("Metadata Error: episodes attribute is blank")
elif "episodes" in methods: elif "episodes" in methods:
logger.error("Metadata Error: episodes attribute only works for show libraries") 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 import logging
from json import JSONDecodeError
from modules.util import Failed from modules.util import Failed
@ -14,9 +15,14 @@ class Notifiarr:
self.apikey = params["apikey"] self.apikey = params["apikey"]
self.develop = params["develop"] self.develop = params["develop"]
self.test = params["test"] 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/") url, _ = self.get_url("user/validate/")
response = self.config.get(url) response = self.config.get(url)
try:
response_json = response.json() 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"): if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"):
logger.debug(f"Response: {response_json}") logger.debug(f"Response: {response_json}")
raise Failed(f"({response.status_code} [{response.reason}]) {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}'}" 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: if self.config.trace_mode:
logger.debug(url.replace(self.apikey, "APIKEY")) 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 return url, params

@ -2,10 +2,13 @@ import logging, os, plexapi, requests
from modules import builder, util from modules import builder, util
from modules.library import Library from modules.library import Library
from modules.util import Failed, ImageData from modules.util import Failed, ImageData
from PIL import Image
from plexapi import utils from plexapi import utils
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.collection import Collection from plexapi.collection import Collection
from plexapi.playlist import Playlist
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.video import Movie, Show
from retrying import retry from retrying import retry
from urllib import parse from urllib import parse
from xml.etree.ElementTree import ParseError 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 = {lang.lower(): lang for lang in plex_languages}
metadata_language_options["default"] = None metadata_language_options["default"] = None
use_original_title_options = {"default": -1, "no": 0, "yes": 1} 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_order_options = ["release", "alpha", "custom"]
collection_level_options = ["episode", "season"] collection_level_options = ["episode", "season"]
collection_mode_keys = {-1: "default", 0: "hide", 1: "hideItems", 2: "showItems"} collection_mode_keys = {-1: "default", 0: "hide", 1: "hideItems", 2: "showItems"}
@ -254,6 +252,7 @@ class Plex(Library):
else: else:
raise Failed(f"Plex Error: Plex Library must be a Movies or TV Shows library") raise Failed(f"Plex Error: Plex Library must be a Movies or TV Shows library")
self._users = []
self.agent = self.Plex.agent self.agent = self.Plex.agent
self.is_movie = self.type == "Movie" self.is_movie = self.type == "Movie"
self.is_show = self.type == "Show" self.is_show = self.type == "Show"
@ -264,6 +263,9 @@ class Plex(Library):
self.tmdb_collections = None self.tmdb_collections = None
logger.error("Config Error: tmdb_collections only work with Movie Libraries.") 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): def set_server_preroll(self, preroll):
self.PlexServer.settings.get('cinemaTrailersPrerollID').set(preroll) self.PlexServer.settings.get('cinemaTrailersPrerollID').set(preroll)
self.PlexServer.settings.save() self.PlexServer.settings.save()
@ -304,10 +306,18 @@ class Plex(Library):
logger.info(util.adjust_space(f"Loaded {self.Plex._totalViewSize} {self.type}s")) logger.info(util.adjust_space(f"Loaded {self.Plex._totalViewSize} {self.type}s"))
return results 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) @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): def fetchItems(self, key, container_start, container_size):
return self.Plex.fetchItems(key, container_start=container_start, container_size=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) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def query(self, method): def query(self, method):
return 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) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def collection_mode_query(self, collection, data): def collection_mode_query(self, collection, data):
if int(collection.collectionMode) not in collection_mode_keys or collection_mode_keys[int(collection.collectionMode)] != data:
collection.modeUpdate(mode=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) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def collection_order_query(self, collection, data): 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 = 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 final_search = show_translation[final_search] if self.is_show and final_search in show_translation else final_search
try: try:
names = []
choices = {} choices = {}
for choice in self.Plex.listFilterChoices(final_search): 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.title.lower()] = choice.title if title else choice.key
choices[choice.key.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: except NotFound:
logger.debug(f"Search Attribute: {final_search}") 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) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def get_labels(self): def get_labels(self):
@ -396,6 +413,16 @@ class Plex(Library):
else: method = None else: method = None
return self.Plex._server.query(key, method=method) 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): def alter_collection(self, item, collection, smart_label_collection=False, add=True):
if smart_label_collection: if smart_label_collection:
self.query_data(item.addLabel if add else item.removeLabel, 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}" key += f"&promotedToSharedHome={1 if (shared is None and visibility['shared']) or shared else 0}"
self._query(key, post=True) 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): def get_collection(self, data):
if isinstance(data, int): if isinstance(data, int):
return self.fetchItem(data) return self.fetchItem(data)
elif isinstance(data, Collection): elif isinstance(data, Collection):
return data return data
else: 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: if d.title == data:
return d 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") raise Failed(f"Plex Error: Collection {data} not found")
def validate_collections(self, collections): def validate_collections(self, collections):
@ -541,7 +578,7 @@ class Plex(Library):
else: else:
raise Failed(f"Plex Error: Method {method} not supported") raise Failed(f"Plex Error: Method {method} not supported")
if len(items) > 0: if len(items) > 0:
ids = [item.ratingKey for item in items] ids = [(item.ratingKey, "ratingKey") for item in items]
logger.debug("") logger.debug("")
logger.debug(f"{len(ids)} Keys Found: {ids}") logger.debug(f"{len(ids)} Keys Found: {ids}")
return ids return ids
@ -551,7 +588,7 @@ class Plex(Library):
def get_collection_items(self, collection, smart_label_collection): def get_collection_items(self, collection, smart_label_collection):
if smart_label_collection: if smart_label_collection:
return self.get_labeled_items(collection.title if isinstance(collection, Collection) else str(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: if collection.smart:
return self.get_filter_items(self.smart_filter(collection)) return self.get_filter_items(self.smart_filter(collection))
else: else:
@ -564,7 +601,7 @@ class Plex(Library):
return self.Plex._search(key, None, 0, plexapi.X_PLEX_CONTAINER_SIZE) return self.Plex._search(key, None, 0, plexapi.X_PLEX_CONTAINER_SIZE)
def get_collection_name_and_items(self, collection, smart_label_collection): 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) return name, self.get_collection_items(collection, smart_label_collection)
def get_tmdb_from_map(self, item): def get_tmdb_from_map(self, item):
@ -620,20 +657,36 @@ class Plex(Library):
logger.info(f"{obj.title[:25]:<25} | {attr.capitalize()} | {display}") logger.info(f"{obj.title[:25]:<25} | {attr.capitalize()} | {display}")
return len(display) > 0 return len(display) > 0
def update_item_from_assets(self, item, overlay=None, create=False): def find_assets(self, item, name=None, upload=True, overlay=None, folders=None, create=None):
name = os.path.basename(os.path.dirname(str(item.locations[0])) if self.is_movie else str(item.locations[0])) 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 found_folder = False
poster = None poster = None
background = None background = None
for ad in self.asset_directory: for ad in self.asset_directory:
item_dir = None item_dir = None
if self.asset_folders: if folders:
if os.path.isdir(os.path.join(ad, name)): if os.path.isdir(os.path.join(ad, name)):
item_dir = os.path.join(ad, name) item_dir = os.path.join(ad, name)
else: else:
matches = util.glob_filter(os.path.join(ad, "*", name)) 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: if len(matches) > 0:
item_dir = os.path.abspath(matches[0]) item_dir = os.path.abspath(matches[0])
break
if item_dir is None: if item_dir is None:
continue continue
found_folder = True found_folder = True
@ -642,15 +695,38 @@ class Plex(Library):
else: else:
poster_filter = os.path.join(ad, f"{name}.*") poster_filter = os.path.join(ad, f"{name}.*")
background_filter = os.path.join(ad, f"{name}_background.*") background_filter = os.path.join(ad, f"{name}_background.*")
matches = util.glob_filter(poster_filter)
if len(matches) > 0: poster_matches = util.glob_filter(poster_filter)
poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_url=False) if len(poster_matches) > 0:
matches = util.glob_filter(background_filter) poster = ImageData("asset_directory", os.path.abspath(poster_matches[0]), prefix=f"{item.title}'s ", is_url=False)
if len(matches) > 0:
background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_poster=False, 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: if poster or background:
if upload:
self.upload_images(item, poster=poster, background=background, overlay=overlay) self.upload_images(item, poster=poster, background=background, overlay=overlay)
if self.is_show: else:
return poster, background
if isinstance(item, Show):
missing_assets = "" missing_assets = ""
found_season = False found_season = False
for season in self.query(item.seasons): for season in self.query(item.seasons):
@ -685,12 +761,13 @@ class Plex(Library):
self.upload_images(episode, poster=episode_poster) self.upload_images(episode, poster=episode_poster)
if self.show_missing_season_assets and found_season and missing_assets: 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) 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) 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) 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)}") 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}'") 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}'") logger.error(f"Asset Warning: No poster or background found in an assets folder for '{name}'")
return None, None

@ -60,6 +60,7 @@ class Radarr:
if movie.path: if movie.path:
arr_paths[movie.path[:-1] if movie.path.endswith(("/", "\\")) else movie.path] = movie.tmdbId arr_paths[movie.path[:-1] if movie.path.endswith(("/", "\\")) else movie.path] = movie.tmdbId
arr_ids[movie.tmdbId] = movie arr_ids[movie.tmdbId] = movie
if self.config.trace_mode:
logger.debug(arr_paths) logger.debug(arr_paths)
logger.debug(arr_ids) logger.debug(arr_ids)
@ -167,3 +168,18 @@ class Radarr:
logger.info("") logger.info("")
for tmdb_id in not_exists: for tmdb_id in not_exists:
logger.info(f"TMDb ID Not in Radarr | {tmdb_id}") 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") logger = logging.getLogger("Plex Meta Manager")
series_type = ["standard", "daily", "anime"] series_types = ["standard", "daily", "anime"]
monitor_translation = { monitor_translation = {
"all": "all", "future": "future", "missing": "missing", "existing": "existing", "all": "all", "future": "future", "missing": "missing", "existing": "existing",
"pilot": "pilot", "first": "firstSeason", "latest": "latestSeason", "none": "none" "pilot": "pilot", "first": "firstSeason", "latest": "latestSeason", "none": "none"
@ -66,7 +66,7 @@ class Sonarr:
_paths.append(tvdb_id) _paths.append(tvdb_id)
else: else:
_ids.append(tvdb_id) _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: for tvdb_id in _paths:
logger.debug(tvdb_id) logger.debug(tvdb_id)
folder = options["folder"] if "folder" in options else self.root_folder_path 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 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 = options["language"] if "language" in options else self.language_profile
language_profile = language_profile if self.api._raw.v3 else 1 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 season = options["season"] if "season" in options else self.season_folder
tags = options["tag"] if "tag" in options else self.tag tags = options["tag"] if "tag" in options else self.tag
search = options["search"] if "search" in options else self.search search = options["search"] if "search" in options else self.search
@ -86,6 +86,7 @@ class Sonarr:
if series.path: if series.path:
arr_paths[series.path[:-1] if series.path.endswith(("/", "\\")) else series.path] = series.tvdbId arr_paths[series.path[:-1] if series.path.endswith(("/", "\\")) else series.path] = series.tvdbId
arr_paths[series.tvdbId] = series arr_paths[series.tvdbId] = series
if self.config.trace_mode:
logger.debug(arr_paths) logger.debug(arr_paths)
logger.debug(arr_ids) logger.debug(arr_ids)
@ -127,7 +128,7 @@ class Sonarr:
if len(shows) == 100 or len(tvdb_ids) == i: if len(shows) == 100 or len(tvdb_ids) == i:
try: try:
_a, _e, _i = self.api.add_multiple_series(shows, folder, quality_profile, language_profile, monitor, _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) added.extend(_a)
exists.extend(_e) exists.extend(_e)
invalid.extend(_i) invalid.extend(_i)
@ -193,3 +194,18 @@ class Sonarr:
logger.info("") logger.info("")
for tvdb_id in not_exists: for tvdb_id in not_exists:
logger.info(f"TVDb ID Not in Sonarr | {tvdb_id}") 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"])) plex_item = library.fetchItem(int(item["rating_key"]))
if not isinstance(plex_item, (Movie, Show)): if not isinstance(plex_item, (Movie, Show)):
raise BadRequest raise BadRequest
rating_keys.append(item["rating_key"]) rating_keys.append((item["rating_key"], "ratingKey"))
except (BadRequest, NotFound): except (BadRequest, NotFound):
new_item = library.exact_search(item["title"], year=item["year"]) new_item = library.exact_search(item["title"], year=item["year"])
if new_item: if new_item:
rating_keys.append(new_item[0].ratingKey) rating_keys.append((new_item[0].ratingKey, "ratingKey"))
else: else:
logger.error(f"Plex Error: Item {item} not found") logger.error(f"Plex Error: Item {item} not found")
logger.debug("") logger.debug("")

@ -112,6 +112,9 @@ class TMDb:
return int(search["movie_results"][0]["id"]), "movie" return int(search["movie_results"][0]["id"]), "movie"
elif len(search["tv_results"]) > 0: elif len(search["tv_results"]) > 0:
return int(search["tv_results"][0]["id"]), "show" 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: else:
raise Failed(f"TMDb Error: No TMDb ID found for IMDb ID {imdb_id}") raise Failed(f"TMDb Error: No TMDb ID found for IMDb ID {imdb_id}")

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

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathvalidate import is_valid_filename, sanitize_filename from pathvalidate import is_valid_filename, sanitize_filename
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.video import Season, Episode, Movie
try: try:
import msvcrt import msvcrt
@ -65,6 +66,11 @@ pretty_months = {
} }
seasons = ["winter", "spring", "summer", "fall"] seasons = ["winter", "spring", "summer", "fall"]
pretty_ids = {"anidbid": "AniDB", "imdbid": "IMDb", "mal_id": "MyAnimeList", "themoviedb_id": "TMDb", "thetvdb_id": "TVDb", "tvdbid": "TVDb"} 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): def tab_new_lines(data):
return str(data).replace("\n", "\n|\t ") if "\n" in str(data) else str(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) mapping_name = sanitize_filename(filename)
return mapping_name, f"Log Folder Name: {filename} is invalid using {mapping_name}" 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): def is_locked(filepath):
locked = None locked = None
file_object = None file_object = None
@ -256,26 +282,26 @@ def is_locked(filepath):
file_object.close() file_object.close()
return locked return locked
def time_window(time_window): def time_window(tw):
today = datetime.now() today = datetime.now()
if time_window == "today": if tw == "today":
return f"{today:%Y-%m-%d}" return f"{today:%Y-%m-%d}"
elif time_window == "yesterday": elif tw == "yesterday":
return f"{today - timedelta(days=1):%Y-%m-%d}" return f"{today - timedelta(days=1):%Y-%m-%d}"
elif time_window == "this_week": elif tw == "this_week":
return f"{today:%Y-0%V}" return f"{today:%Y-0%V}"
elif time_window == "last_week": elif tw == "last_week":
return f"{today - timedelta(weeks=1):%Y-0%V}" return f"{today - timedelta(weeks=1):%Y-0%V}"
elif time_window == "this_month": elif tw == "this_month":
return f"{today:%Y-%m}" return f"{today:%Y-%m}"
elif time_window == "last_month": elif tw == "last_month":
return f"{today.year}-{today.month - 1 or 12}" return f"{today.year}-{today.month - 1 or 12}"
elif time_window == "this_year": elif tw == "this_year":
return f"{today.year}" return f"{today.year}"
elif time_window == "last_year": elif tw == "last_year":
return f"{today.year - 1}" return f"{today.year - 1}"
else: else:
return time_window return tw
def glob_filter(filter_in): def glob_filter(filter_in):
filter_in = filter_in.translate({ord("["): "[[]", ord("]"): "[]]"}) if "[" in filter_in else 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 == ".lt" and value >= data) \
or (modifier == ".lte" 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): def is_string_filter(values, modifier, data):
jailbreak = False jailbreak = False
for value in values: for value in values:
@ -323,72 +352,101 @@ def is_string_filter(values, modifier, data):
if jailbreak: break if jailbreak: break
return (jailbreak and modifier in [".not", ".isnot"]) or (not jailbreak and modifier in ["", ".is", ".begins", ".ends", ".regex"]) 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): def check_collection_mode(collection_mode):
display = f"{parent + ' ' if parent else ''}{attribute} attribute" if collection_mode and str(collection_mode).lower() in collection_mode_options:
if options is None and translation is not None: return collection_mode_options[str(collection_mode).lower()]
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: else:
raise Failed(f"Collection Error: {display} {dict_data} is not a dictionary") 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)")
return final_list
elif methods and attribute not in methods: def check_day(_m, _d):
message = f"{display} not found" if _m in [1, 3, 5, 7, 8, 10, 12] and _d > 31:
elif value is None: return _m, 31
message = f"{display} is blank" elif _m in [4, 6, 9, 11] and _d > 30:
elif regex is not None: return _m, 30
regex_str, example = regex elif _m == 2 and _d > 28:
if re.compile(regex_str).match(str(value)): return _m, 28
return str(value)
else: else:
message = f"{display}: {value} must match pattern {regex_str} e.g. {example}" return _m, _d
elif datatype == "bool":
if isinstance(value, bool): def schedule_check(attribute, data, current_time, run_hour):
return value skip_collection = True
elif isinstance(value, int): schedule_list = get_list(data)
return value > 0 next_month = current_time.replace(day=28) + timedelta(days=4)
elif str(value).lower() in ["t", "true"]: last_day = next_month - timedelta(days=next_month.day)
return True schedule_str = ""
elif str(value).lower() in ["f", "false"]: for schedule in schedule_list:
return False run_time = str(schedule).lower()
else: if run_time.startswith(("day", "daily")):
message = f"{display} must be either true or false" skip_collection = False
elif datatype in ["int", "float"]: 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: try:
value = int(str(value)) if datatype == "int" else float(str(value)) if 0 <= int(param) <= 23:
if (maximum is None and minimum <= value) or (maximum is not None and minimum <= value <= maximum): schedule_str += f"\nScheduled to run only on the {make_ordinal(int(param))} hour"
return value if run_hour == int(param):
skip_collection = False
else:
raise ValueError
except ValueError: except ValueError:
pass logger.error(f"Schedule Error: hourly {attribute} attribute {schedule} invalid must be an integer between 0 and 23")
pre = f"{display} {value} must {'an integer' if datatype == 'int' else 'a number'}" elif run_time.startswith("week"):
if maximum is None: if param.lower() not in days_alias:
message = f"{pre} {minimum} or greater" 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: else:
message = f"{pre} between {minimum} and {maximum}" raise ValueError
elif (translation is not None and str(value).lower() not in translation) or \ except ValueError:
(options is not None and translation is None and str(value).lower() not in options): logger.error(f"Schedule Error: monthly {attribute} attribute {schedule} invalid must be an integer between 1 and 31")
message = f"{display} {value} must be in {', '.join([str(o) for o in options])}" 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: else:
return translation[value] if translation is not None else value raise ValueError
except ValueError:
if default is None: logger.error(
raise Failed(f"Collection Error: {message}") 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: else:
logger.warning(f"Collection Warning: {message} using {default} as default") logger.error(f"Schedule Error: {attribute} attribute {schedule} invalid")
return translation[default] if translation is not None else default if len(schedule_str) == 0:
skip_collection = False
if skip_collection:
raise NotScheduled(schedule_str)

@ -19,16 +19,19 @@ class Webhooks:
logger.debug("") logger.debug("")
logger.debug(f"JSON: {json}") logger.debug(f"JSON: {json}")
for webhook in list(set(webhooks)): for webhook in list(set(webhooks)):
response = None
if self.config.trace_mode: if self.config.trace_mode:
logger.debug(f"Webhook: {webhook}") logger.debug(f"Webhook: {webhook}")
if webhook == "notifiarr": if webhook == "notifiarr":
url, params = self.notifiarr.get_url("notification/plex/") if self.notifiarr:
url, params = self.notifiarr.get_url("notification/pmm/")
for x in range(6): for x in range(6):
response = self.config.get(url, json=json, params=params) response = self.config.get(url, json=json, params=params)
if response.status_code < 500: if response.status_code < 500:
break break
else: else:
response = self.config.post(webhook, json=json) response = self.config.post(webhook, json=json)
if response:
try: try:
response_json = response.json() response_json = response.json()
if self.config.trace_mode: if self.config.trace_mode:
@ -60,36 +63,33 @@ class Webhooks:
"added_to_sonarr": stats["sonarr"], "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: if self.error_webhooks:
json = {"error": str(text), "critical": critical} json = {"error": str(text), "critical": critical}
if library: if server: json["server_name"] = str(server)
json["server_name"] = library.PlexServer.friendlyName if library: json["library_name"] = str(library)
json["library_name"] = library.name if collection: json["collection"] = str(collection)
if collection: if playlist: json["playlist"] = str(playlist)
json["collection"] = str(collection)
self._request(self.error_webhooks, json) 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: if self.library:
thumb = None 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}") thumb = self.config.get_image_encoded(f"{self.library.url}{collection.thumb}?X-Plex-Token={self.library.token}")
art = None 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}") 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, "server_name": self.library.PlexServer.friendlyName,
"library_name": self.library.name, "library_name": self.library.name,
"type": "movie" if self.library.is_movie else "show", "playlist" if playlist else "collection": collection.title,
"collection": collection.title,
"created": created, "created": created,
"deleted": deleted, "deleted": deleted,
"poster": thumb, "poster": thumb,
"background": art "background": art,
} "poster_url": poster_url,
if additions: "background_url": background_url,
json["additions"] = additions "additions": additions if additions else [],
if removals: "removals": removals if removals else [],
json["removals"] = removals })
self._request(webhooks, json)

@ -1,12 +1,16 @@
import argparse, logging, os, sys, time import argparse, logging, os, sys, time
from datetime import datetime from datetime import datetime
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from plexapi.exceptions import NotFound
from plexapi.video import Show, Season
try: try:
import plexapi, schedule import plexapi, schedule
from modules import util from modules import util
from modules.builder import CollectionBuilder from modules.builder import CollectionBuilder
from modules.config import Config from modules.config import ConfigFile
from modules.meta import Metadata from modules.meta import MetadataFile
from modules.util import Failed, NotScheduled from modules.util import Failed, NotScheduled
except ModuleNotFoundError: except ModuleNotFoundError:
print("Requirements Error: Requirements are not installed") 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("-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("-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("-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("-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) parser.add_argument("-w", "--width", dest="width", help="Screen Width (Default: 100)", default=100, type=int)
args = parser.parse_args() args = parser.parse_args()
@ -62,6 +67,7 @@ libraries = get_arg("PMM_LIBRARIES", args.libraries)
resume = get_arg("PMM_RESUME", args.resume) resume = get_arg("PMM_RESUME", args.resume)
no_countdown = get_arg("PMM_NO_COUNTDOWN", args.no_countdown, arg_bool=True) 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) 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) divider = get_arg("PMM_DIVIDER", args.divider)
screen_width = get_arg("PMM_WIDTH", args.width, arg_int=True) screen_width = get_arg("PMM_WIDTH", args.width, arg_int=True)
debug = get_arg("PMM_DEBUG", args.debug, arg_bool=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"--resume (PMM_RESUME): {resume}")
logger.debug(f"--no-countdown (PMM_NO_COUNTDOWN): {no_countdown}") logger.debug(f"--no-countdown (PMM_NO_COUNTDOWN): {no_countdown}")
logger.debug(f"--no-missing (PMM_NO_MISSING): {no_missing}") 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"--divider (PMM_DIVIDER): {divider}")
logger.debug(f"--width (PMM_WIDTH): {screen_width}") logger.debug(f"--width (PMM_WIDTH): {screen_width}")
logger.debug(f"--debug (PMM_DEBUG): {debug}") logger.debug(f"--debug (PMM_DEBUG): {debug}")
@ -159,7 +166,7 @@ def start(attrs):
global stats global stats
stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "removed": 0, "radarr": 0, "sonarr": 0} stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "removed": 0, "radarr": 0, "sonarr": 0}
try: try:
config = Config(default_dir, attrs) config = ConfigFile(default_dir, attrs, read_only_config)
except Exception as e: except Exception as e:
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, critical=True) util.print_multiline(e, critical=True)
@ -185,6 +192,10 @@ def start(attrs):
def update_libraries(config): def update_libraries(config):
global stats global stats
for library in config.libraries: for library in config.libraries:
if library.skip_library:
logger.info("")
util.separator(f"Skipping {library.name} Library")
continue
try: try:
os.makedirs(os.path.join(default_dir, "logs", library.mapping_name, "collections"), exist_ok=True) 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") 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_stacktrace()
util.print_multiline(e, critical=True) 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 has_run_again = False
for library in config.libraries: for library in config.libraries:
if library.run_again: 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 Audience Rating Update: {library.mass_audience_rating_update}")
logger.debug(f"Mass Critic Rating Update: {library.mass_critic_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 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"Split Duplicates: {library.split_duplicates}")
logger.debug(f"Radarr Add All: {library.radarr_add_all}") 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 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"TMDb Collections: {library.tmdb_collections}")
logger.debug(f"Genre Mapper: {library.genre_mapper}") 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 \ 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 continue
util.print_return(f"Processing: {i}/{len(items)} {item.title}") util.print_return(f"Processing: {i}/{len(items)} {item.title}")
if library.assets_for_all: if library.assets_for_all:
library.update_item_from_assets(item, create=library.create_asset_folders) library.find_assets(item)
tmdb_id = None tmdb_id = None
tvdb_id = None tvdb_id = None
imdb_id = None imdb_id = None
@ -535,30 +561,40 @@ def library_operations(config, library):
logger.info("") logger.info("")
util.separator(f"Starting TMDb Collections") util.separator(f"Starting TMDb Collections")
logger.info("") logger.info("")
metadata = Metadata(config, library, "Data", { new_collections = {}
"collections": { for _i, _n in tmdb_collections.items():
_n.replace(library.tmdb_collections["remove_suffix"], "").strip() if library.tmdb_collections["remove_suffix"] else _n: if int(_i) not in library.tmdb_collections["exclude_ids"]:
{"template": {"name": "TMDb Collection", "collection_id": _i}} template = {"name": "TMDb Collection", "collection_id": _i}
for _i, _n in tmdb_collections.items() if int(_i) not in library.tmdb_collections["exclude_ids"] for k, v in library.tmdb_collections["dictionary_variables"]:
}, if int(_i) in v:
"templates": { template[k] = v[int(_i)]
"TMDb Collection": library.tmdb_collections["template"] 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)) 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: if library.delete_collections_with_less is not None or library.delete_unmanaged_collections:
logger.info("") logger.info("")
suffix = "" print_suffix = ""
unmanaged = "" unmanaged = ""
if library.delete_collections_with_less is not None and library.delete_collections_with_less > 0: 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_unmanaged_collections:
if library.delete_collections_with_less is None: if library.delete_collections_with_less is None:
unmanaged = "Unmanaged Collections " unmanaged = "Unmanaged Collections "
elif library.delete_collections_with_less > 0: elif library.delete_collections_with_less > 0:
unmanaged = "Unmanaged Collections and " 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("") logger.info("")
unmanaged_collections = [] unmanaged_collections = []
for col in library.get_all_collections(): for col in library.get_all_collections():
@ -569,6 +605,8 @@ def library_operations(config, library):
logger.info(f"{col.title} Deleted") logger.info(f"{col.title} Deleted")
elif col.title not in library.collections: elif col.title not in library.collections:
unmanaged_collections.append(col) 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: if library.show_unmanaged and len(unmanaged_collections) > 0:
logger.info("") 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) util.separator(f"Unmanaged Collection Assets Check for {library.name} Library", space=False, border=False)
logger.info("") logger.info("")
for col in unmanaged_collections: for col in unmanaged_collections:
poster, background = library.find_collection_assets(col, create=library.create_asset_folders) library.find_assets(col)
library.upload_images(col, poster=poster, background=background)
def run_collection(config, library, metadata, requested_collections): def run_collection(config, library, metadata, requested_collections):
global stats global stats
@ -655,6 +692,7 @@ def run_collection(config, library, metadata, requested_collections):
items_added = 0 items_added = 0
items_removed = 0 items_removed = 0
valid = True
if not builder.smart_url and builder.builders: if not builder.smart_url and builder.builders:
logger.info("") logger.info("")
logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}") 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() 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("") logger.info("")
util.separator(f"Adding to {mapping_name} Collection", space=False, border=False) util.separator(f"Adding to {mapping_name} Collection", space=False, border=False)
logger.info("") logger.info("")
@ -678,14 +716,14 @@ def run_collection(config, library, metadata, requested_collections):
if builder.sync: if builder.sync:
items_removed = builder.sync_collection() items_removed = builder.sync_collection()
stats["removed"] += items_removed 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("")
logger.info(f"Collection Minimum: {builder.minimum} not met for {mapping_name} Collection") logger.info(f"Collection Minimum: {builder.minimum} not met for {mapping_name} Collection")
valid = False
if builder.details["delete_below_minimum"] and builder.obj: if builder.details["delete_below_minimum"] and builder.obj:
builder.delete_collection()
builder.deleted = True
logger.info("") 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.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0):
if builder.details["show_missing"] is True: if builder.details["show_missing"] is True:
@ -697,7 +735,7 @@ def run_collection(config, library, metadata, requested_collections):
stats["sonarr"] += sonarr_add stats["sonarr"] += sonarr_add
run_item_details = True 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: try:
builder.load_collection() builder.load_collection()
if builder.created: 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) util.separator("No Collection to Update", space=False, border=False)
else: else:
builder.update_details() builder.update_details()
if builder.custom_sort:
library.run_sort.append(builder)
# builder.sort_collection()
if builder.deleted: if builder.deleted:
stats["deleted"] += 1 stats["deleted"] += 1
@ -723,16 +758,20 @@ def run_collection(config, library, metadata, requested_collections):
logger.info("") logger.info("")
logger.info(f"Plex Server Movie pre-roll video updated to {builder.server_preroll}") logger.info(f"Plex Server Movie pre-roll video updated to {builder.server_preroll}")
builder.send_notifications() if (builder.item_details or builder.custom_sort) and run_item_details and builder.builders:
if builder.item_details and run_item_details and builder.builders:
try: try:
builder.load_collection_items() builder.load_collection_items()
except Failed: except Failed:
logger.info("") logger.info("")
util.separator("No Items Found", space=False, border=False) util.separator("No Items Found", space=False, border=False)
else: else:
if builder.item_details:
builder.update_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): if builder.run_again and (len(builder.run_again_movies) > 0 or len(builder.run_again_shows) > 0):
library.run_again.append(builder) 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]}") util.separator(f"Finished {mapping_name} Collection\nCollection Run Time: {str(datetime.now() - collection_start).split('.')[0]}")
logger.removeHandler(collection_handler) 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: try:
if run or test or collections or libraries or resume: if run or test or collections or libraries or resume:
start({ start({
@ -778,11 +1132,11 @@ try:
while True: while True:
schedule.run_pending() schedule.run_pending()
if not no_countdown: if not no_countdown:
current = datetime.now().strftime("%H:%M") current_time = datetime.now().strftime("%H:%M")
seconds = None seconds = None
og_time_str = "" og_time_str = ""
for time_to_run in valid_times: 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: if new_seconds < 0:
new_seconds += 86400 new_seconds += 86400
if (seconds is None or new_seconds < seconds) and new_seconds > 0: if (seconds is None or new_seconds < seconds) and new_seconds > 0:
@ -793,7 +1147,7 @@ try:
minutes = int((seconds % 3600) // 60) minutes = int((seconds % 3600) // 60)
time_str = f"{hours} Hour{'s' if hours > 1 else ''} and " if hours > 0 else "" 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 ''}" 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: else:
logger.error(f"Time Error: {valid_times}") logger.error(f"Time Error: {valid_times}")
time.sleep(60) time.sleep(60)

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

Loading…
Cancel
Save