diff --git a/README.md b/README.md index bc9a0d0e..50ca8d06 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Plex Meta Manager -#### Version 1.5.0 +#### Version 1.5.1 The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YAML configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services. diff --git a/config/Movies.yml.template b/config/Movies.yml.template index d2db458d..18631dc8 100644 --- a/config/Movies.yml.template +++ b/config/Movies.yml.template @@ -1290,7 +1290,7 @@ collections: plex_collectionless: exclude_prefix: - + - - "~" + - ~ sort_title: ~_Collectionless collection_order: alpha collection_order: alpha \ No newline at end of file diff --git a/modules/anilist.py b/modules/anilist.py index eed4c1e8..53e71826 100644 --- a/modules/anilist.py +++ b/modules/anilist.py @@ -41,12 +41,11 @@ class AniListAPI: count = 0 page_num = 0 if variables is None: - variables = {"page": page_num} - else: - variables["page"] = page_num + variables = {} next_page = True while next_page: page_num += 1 + variables["page"] = page_num json_obj = self.send_request(query, variables) next_page = json_obj["data"]["Page"]["pageInfo"]["hasNextPage"] for media in json_obj["data"]["Page"]["media"]: @@ -84,7 +83,7 @@ class AniListAPI: def season(self, season, year, sort, limit): query = """ - query ($page: Int, $season: String, $year: Int, $sort: String) { + query ($page: Int, $season: MediaSeason, $year: Int, $sort: [MediaSort]) { Page(page: $page){ pageInfo {hasNextPage} media(season: $season, seasonYear: $year, type: ANIME, sort: $sort){idMal} diff --git a/modules/builder.py b/modules/builder.py index d532ff5a..6f03f599 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -358,12 +358,12 @@ class CollectionBuilder: elif method_name in util.dictionary_lists: if isinstance(data[m], dict): def get_int(parent, method, data_in, default_in, minimum=1, maximum=None): - if method not in data_in: logger.warning(f"Collection Warning: {parent} {method} attribute not found using {default} as default") - elif not data_in[method]: logger.warning(f"Collection Warning: {parent} {method} attribute is blank using {default} as default") + if method not in data_in: logger.warning(f"Collection Warning: {parent} {method} attribute not found using {default_in} as default") + elif not data_in[method]: logger.warning(f"Collection Warning: {parent} {method} attribute is blank using {default_in} as default") elif isinstance(data_in[method], int) and data_in[method] >= minimum: if maximum is None or data_in[method] <= maximum: return data_in[method] - else: logger.warning(f"Collection Warning: {parent} {method} attribute {data_in[method]} invalid must an integer <= {maximum} using {default} as default") - else: logger.warning(f"Collection Warning: {parent} {method} attribute {data_in[method]} invalid must an integer >= {minimum} using {default} as default") + else: logger.warning(f"Collection Warning: {parent} {method} attribute {data_in[method]} invalid must an integer <= {maximum} using {default_in} as default") + else: logger.warning(f"Collection Warning: {parent} {method} attribute {data_in[method]} invalid must an integer >= {minimum} using {default_in} as default") return default_in if method_name == "filters": for f in data[m]: @@ -406,9 +406,6 @@ class CollectionBuilder: if isinstance(data[m]["exclude"], list): exact_list.extend(data[m]["exclude"]) else: exact_list.append(str(data[m]["exclude"])) if len(prefix_list) == 0 and len(exact_list) == 0: raise Failed("Collection Error: you must have at least one exclusion") - self.details["add_to_arr"] = False - self.details["collection_mode"] = "hide" - self.sync = True new_dictionary["exclude_prefix"] = prefix_list new_dictionary["exclude"] = exact_list self.methods.append((method_name, [new_dictionary])) @@ -641,6 +638,11 @@ class CollectionBuilder: if self.library.Sonarr: self.do_arr = self.details["add_to_arr"] if "add_to_arr" in self.details else self.library.Sonarr.add + if self.collectionless: + self.details["add_to_arr"] = False + self.details["collection_mode"] = "hide" + self.sync = True + def run_methods(self, collection_obj, collection_name, rating_key_map, movie_map, show_map): items_found = 0 for method, values in self.methods: @@ -718,20 +720,21 @@ class CollectionBuilder: if col.title.startswith(pre) or (col.titleSort and col.titleSort.startswith(pre)): keep_collection = False break - for ext in value["exclude"]: - if col.title == ext or (col.titleSort and col.titleSort == ext): - keep_collection = False - break if keep_collection: - good_collections.append(col.title.lower()) - + for ext in value["exclude"]: + if col.title == ext or (col.titleSort and col.titleSort == ext): + keep_collection = False + break + if keep_collection: + good_collections.append(col.index) all_items = self.library.Plex.all() length = 0 for i, item in enumerate(all_items, 1): length = util.print_return(length, f"Processing: {i}/{len(all_items)} {item.title}") add_item = True + item.reload() for collection in item.collections: - if collection.tag.lower() in good_collections: + if collection.id in good_collections: add_item = False break if add_item: diff --git a/modules/plex.py b/modules/plex.py index 6301ccf6..23968b13 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -122,6 +122,8 @@ class PlexAPI: for i, item in enumerate(items, 1): try: current = self.fetchItem(item.ratingKey if isinstance(item, (Movie, Show)) else int(item)) + if not isinstance(current, (Movie, Show)): + raise NotFound except (BadRequest, NotFound): logger.error(f"Plex Error: Item {item} not found") continue @@ -397,7 +399,7 @@ class PlexAPI: logger.info("") match = re.search("[Ss]\\d+[Ee]\\d+", episode_str) if match: - output = match.group(0)[1:].split("E" if "E" in m.group(0) else "e") + output = match.group(0)[1:].split("E" if "E" in match.group(0) else "e") episode_id = int(output[0]) season_id = int(output[1]) logger.info(f"Updating episode S{episode_id}E{season_id} of {m}...") diff --git a/modules/trakttv.py b/modules/trakttv.py index 2a7adb6a..00014b65 100644 --- a/modules/trakttv.py +++ b/modules/trakttv.py @@ -112,9 +112,11 @@ class TraktAPI: return requests.get(url, headers={"Content-Type": "application/json", "trakt-api-version": "2", "trakt-api-key": self.client_id}).json() def get_pagenation(self, pagenation, amount, is_movie): - items = self.send_request(f"{self.base_url}/{'movies' if is_movie else 'shows'}/{pagenation}?limit={amount}") - if is_movie: return [item["ids"]["tmdb"] for item in items], [] - else: return [], [item["ids"]["tvdb"] for item in items] + items = self.send_request(f"{self.base_url}/{'movies' if not is_movie else 'shows'}/{pagenation}?limit={amount}") + if pagenation == "popular" and is_movie: return [item["ids"]["tmdb"] for item in items], [] + elif pagenation == "popular": return [], [item["ids"]["tvdb"] for item in items] + elif is_movie: return [item["movie"]["ids"]["tmdb"] for item in items], [] + else: return [], [item["show"]["ids"]["tvdb"] for item in items] def validate_trakt_list(self, values): trakt_values = [] diff --git a/plex_meta_manager.py b/plex_meta_manager.py index bf8b1918..680700b6 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -15,23 +15,45 @@ parser.add_argument("-c", "--config", dest="config", help="Run with desired *.ym parser.add_argument("-t", "--time", dest="time", help="Time to update each day use format HH:MM (Default: 03:00)", default="03:00", type=str) parser.add_argument("-r", "--run", dest="run", help="Run without the scheduler", action="store_true", default=False) parser.add_argument("-rt", "--test", "--tests", "--run-test", "--run-tests", dest="test", help="Run in debug mode with only collections that have test: true", action="store_true", default=False) -parser.add_argument("-cl", "--collection", "--collections", dest="collections", help="Process only specified collections (comma-separated list)", type=str, default="") +parser.add_argument("-cl", "--collection", "--collections", dest="collections", help="Process only specified collections (comma-separated list)", 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) args = parser.parse_args() -if not re.match("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$", args.time): - raise util.Failed(f"Argument Error: time argument invalid: {args.time} must be in the HH:MM format") +def check_bool(env_str, default): + env_var = os.environ.get(env_str) + if env_var is not None: + if env_var is True or env_var is False: + return env_var + elif env_var.lower() in ["t", "true"]: + return True + else: + return False + else: + return default + +my_tests = check_bool("PMM_TESTS", args.tests) +test = check_bool("PMM_TEST", args.test) +debug = check_bool("PMM_DEBUG", args.debug) +run = check_bool("PMM_RUN", args.run) +collections = os.environ.get("PMM_COLLECTIONS") if os.environ.get("PMM_COLLECTIONS") else args.collections + +time_to_run = os.environ.get("PMM_TIME") if os.environ.get("PMM_TIME") else args.time +if not re.match("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$", time_to_run): + raise util.Failed(f"Argument Error: time argument invalid: {time_to_run} must be in the HH:MM format") + +util.separating_character = os.environ.get("PMM_DIVIDER")[0] if os.environ.get("PMM_DIVIDER") else args.divider[0] -util.separating_character = args.divider[0] -if 90 <= args.width <= 300: - util.screen_width = args.width +screen_width = os.environ.get("PMM_WIDTH") if os.environ.get("PMM_WIDTH") else args.width +if 90 <= screen_width <= 300: + util.screen_width = screen_width else: - raise util.Failed(f"Argument Error: width argument invalid: {args.width} must be an integer between 90 and 300") + raise util.Failed(f"Argument Error: width argument invalid: {screen_width} must be an integer between 90 and 300") +config_file = os.environ.get("PMM_CONFIG") if os.environ.get("PMM_CONFIG") else args.config default_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config") -if args.config and os.path.exists(args.config): default_dir = os.path.join(os.path.dirname(os.path.abspath(args.config))) -elif args.config and not os.path.exists(args.config): raise util.Failed(f"Config Error: config not found at {os.path.abspath(args.config)}") +if config_file and os.path.exists(config_file): default_dir = os.path.join(os.path.dirname(os.path.abspath(config_file))) +elif config_file and not os.path.exists(config_file): raise util.Failed(f"Config Error: config not found at {os.path.abspath(config_file)}") elif not os.path.exists(os.path.join(default_dir, "config.yml")): raise util.Failed(f"Config Error: config not found at {os.path.abspath(default_dir)}") os.makedirs(os.path.join(default_dir, "logs"), exist_ok=True) @@ -50,7 +72,7 @@ file_handler.setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(lev cmd_handler = logging.StreamHandler() cmd_handler.setFormatter(logging.Formatter("| %(message)-100s |")) -cmd_handler.setLevel(logging.DEBUG if args.tests or args.test or args.debug else logging.INFO) +cmd_handler.setLevel(logging.DEBUG if tests or test or debug else logging.INFO) logger.addHandler(cmd_handler) logger.addHandler(file_handler) @@ -65,23 +87,23 @@ logger.info(util.get_centered_text("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` logger.info(util.get_centered_text("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")) logger.info(util.get_centered_text("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")) logger.info(util.get_centered_text(" |___/ ")) -logger.info(util.get_centered_text(" Version: 1.5.0 ")) +logger.info(util.get_centered_text(" Version: 1.5.1 ")) util.separator() -if args.tests: +if my_tests: tests.run_tests(default_dir) sys.exit(0) -def start(config_path, test, daily, collections): +def start(config_path, is_test, daily, collections): if daily: start_type = "Daily " - elif test: start_type = "Test " + elif is_test: start_type = "Test " elif collections: start_type = "Collections " else: start_type = "" start_time = datetime.now() util.separator(f"Starting {start_type}Run") try: config = Config(default_dir, config_path) - config.update_libraries(test, collections) + config.update_libraries(is_test, collections) except Exception as e: util.print_stacktrace() logger.critical(e) @@ -89,15 +111,15 @@ def start(config_path, test, daily, collections): util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}") try: - if args.run or args.test or args.collections: - start(args.config, args.test, False, args.collections) + if run or test or collections: + start(config_file, test, False, collections) else: length = 0 - schedule.every().day.at(args.time).do(start, args.config, False, True, None) + schedule.every().day.at(time_to_run).do(start, config_file, False, True, None) while True: schedule.run_pending() current = datetime.now().strftime("%H:%M") - seconds = (datetime.strptime(args.time, "%H:%M") - datetime.strptime(current, "%H:%M")).total_seconds() + seconds = (datetime.strptime(time_to_run, "%H:%M") - datetime.strptime(current, "%H:%M")).total_seconds() hours = int(seconds // 3600) if hours < 0: hours += 24 @@ -105,7 +127,7 @@ try: 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 ''}" - length = util.print_return(length, f"Current Time: {current} | {time_str} until the daily run at {args.time}") + length = util.print_return(length, f"Current Time: {current} | {time_str} until the daily run at {time_to_run}") time.sleep(1) except KeyboardInterrupt: util.separator("Exiting Plex Meta Manager")