diff --git a/VERSION b/VERSION index 4e9e816c..8d97db78 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.17.1-develop19 +1.17.1-develop20 diff --git a/modules/builder.py b/modules/builder.py index f2e74c1e..b976d2aa 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -1,7 +1,8 @@ import os, re, time from datetime import datetime from modules import anidb, anilist, flixpatrol, icheckmovies, imdb, letterboxd, mal, plex, radarr, reciperr, sonarr, tautulli, tmdb, trakt, tvdb, mdblist, util -from modules.util import Failed, NonExisting, NotScheduled, NotScheduledRange, Overlay, Deleted +from modules.util import Failed, NonExisting, NotScheduled, NotScheduledRange, Deleted +from modules.overlay import Overlay from plexapi.audio import Artist, Album, Track from plexapi.exceptions import BadRequest, NotFound from plexapi.video import Movie, Show, Season, Episode diff --git a/modules/meta.py b/modules/meta.py index 5872a469..19f48805 100644 --- a/modules/meta.py +++ b/modules/meta.py @@ -1,6 +1,6 @@ import math, operator, os, re, requests from datetime import datetime -from modules import plex, ergast, util +from modules import plex, ergast, overlay, util from modules.util import Failed, YAML from plexapi.exceptions import NotFound, BadRequest @@ -71,7 +71,7 @@ class DataFile: self.templates = {} def get_file_name(self): - data = f"{util.github_base}{self.path}.yml" if self.type == "GIT" else self.path + data = f"{overlay.github_base}{self.path}.yml" if self.type == "GIT" else self.path if "/" in data: if data.endswith(".yml"): return data[data.rfind("/") + 1:-4] @@ -89,7 +89,7 @@ class DataFile: if file_type in ["URL", "Git", "Repo"]: if file_type == "Repo" and not self.config.custom_repo: raise Failed("Config Error: No custom_repo defined") - content_path = file_path if file_type == "URL" else f"{self.config.custom_repo if file_type == 'Repo' else util.github_base}{file_path}.yml" + content_path = file_path if file_type == "URL" else f"{self.config.custom_repo if file_type == 'Repo' else overlay.github_base}{file_path}.yml" response = self.config.get(content_path) if response.status_code >= 400: raise Failed(f"URL Error: No file found at {content_path}") diff --git a/modules/overlay.py b/modules/overlay.py new file mode 100644 index 00000000..8e5bd516 --- /dev/null +++ b/modules/overlay.py @@ -0,0 +1,414 @@ +import os, re, time +from PIL import Image, ImageColor, ImageDraw, ImageFont +from modules import util +from modules.util import Failed + +logger = util.logger + +portrait_dim = (1000, 1500) +landscape_dim = (1920, 1080) +github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/" +rating_mods = ["0", "%", "#"] +special_text_overlays = [f"text({a}{s})" for a in ["audience_rating", "critic_rating", "user_rating"] for s in [""] + rating_mods] + +def parse_cords(data, parent, required=False): + horizontal_align = util.parse("Overlay", "horizontal_align", data["horizontal_align"], parent=parent, + options=["left", "center", "right"]) if "horizontal_align" in data else "left" + vertical_align = util.parse("Overlay", "vertical_align", data["vertical_align"], parent=parent, + options=["top", "center", "bottom"]) if "vertical_align" in data else "top" + + horizontal_offset = None + if "horizontal_offset" in data and data["horizontal_offset"] is not None: + x_off = data["horizontal_offset"] + per = False + if str(x_off).endswith("%"): + x_off = x_off[:-1] + per = True + x_off = util.check_num(x_off) + error = f"Overlay Error: {parent} horizontal_offset: {data['horizontal_offset']} must be a number" + if x_off is None: + raise Failed(error) + if horizontal_align != "center" and not per and x_off < 0: + raise Failed(f"{error} 0 or greater") + elif horizontal_align != "center" and per and (x_off > 100 or x_off < 0): + raise Failed(f"{error} between 0% and 100%") + elif horizontal_align == "center" and per and (x_off > 50 or x_off < -50): + raise Failed(f"{error} between -50% and 50%") + horizontal_offset = f"{x_off}%" if per else x_off + if horizontal_offset is None and horizontal_align == "center": + horizontal_offset = 0 + if required and horizontal_offset is None: + raise Failed(f"Overlay Error: {parent} horizontal_offset is required") + + vertical_offset = None + if "vertical_offset" in data and data["vertical_offset"] is not None: + y_off = data["vertical_offset"] + per = False + if str(y_off).endswith("%"): + y_off = y_off[:-1] + per = True + y_off = util.check_num(y_off) + error = f"Overlay Error: {parent} vertical_offset: {data['vertical_offset']} must be a number" + if y_off is None: + raise Failed(error) + if vertical_align != "center" and not per and y_off < 0: + raise Failed(f"{error} 0 or greater") + elif vertical_align != "center" and per and (y_off > 100 or y_off < 0): + raise Failed(f"{error} between 0% and 100%") + elif vertical_align == "center" and per and (y_off > 50 or y_off < -50): + raise Failed(f"{error} between -50% and 50%") + vertical_offset = f"{y_off}%" if per else y_off + if vertical_offset is None and vertical_align == "center": + vertical_offset = 0 + if required and vertical_offset is None: + raise Failed(f"Overlay Error: {parent} vertical_offset is required") + + return horizontal_align, horizontal_offset, vertical_align, vertical_offset + + +class Overlay: + def __init__(self, config, library, original_mapping_name, overlay_data, suppress): + self.config = config + self.library = library + self.original_mapping_name = original_mapping_name + self.data = overlay_data + self.suppress = suppress + self.keys = [] + self.updated = False + self.image = None + self.landscape = None + self.landscape_box = None + self.portrait = None + self.portrait_box = None + self.group = None + self.queue = None + self.weight = None + self.path = None + self.font = None + self.font_name = None + self.font_size = 36 + self.font_color = None + self.addon_offset = 0 + self.addon_position = None + + logger.debug("") + logger.debug("Validating Method: overlay") + logger.debug(f"Value: {self.data}") + if not isinstance(self.data, dict): + self.data = {"name": str(self.data)} + logger.warning(f"Overlay Warning: No overlay attribute using mapping name {self.data} as the overlay name") + if "name" not in self.data or not self.data["name"]: + raise Failed(f"Overlay Error: overlay must have the name attribute") + self.name = str(self.data["name"]) + if self.original_mapping_name not in library.overlay_names: + library.overlay_names.append(self.original_mapping_name) + self.mapping_name = self.original_mapping_name + else: + name_count = 1 + test_name = f"{self.original_mapping_name} ({name_count})" + while test_name in library.overlay_names: + name_count += 1 + test_name = f"{self.original_mapping_name} ({name_count})" + library.overlay_names.append(test_name) + self.mapping_name = test_name + + if "group" in self.data and self.data["group"]: + self.group = str(self.data["group"]) + if "queue" in self.data and self.data["queue"]: + self.queue = str(self.data["queue"]) + if "weight" in self.data: + self.weight = util.parse("Overlay", "weight", self.data["weight"], datatype="int", parent="overlay", minimum=0) + if "group" in self.data and (self.weight is None or not self.group): + raise Failed(f"Overlay Error: overlay attribute's group requires the weight attribute") + elif "queue" in self.data and (self.weight is None or not self.queue): + raise Failed(f"Overlay Error: overlay attribute's queue requires the weight attribute") + elif self.group and self.queue: + raise Failed(f"Overlay Error: overlay attribute's group and queue cannot be used together") + self.horizontal_align, self.horizontal_offset, self.vertical_align, self.vertical_offset = parse_cords(self.data, "overlay") + + if (self.horizontal_offset is None and self.vertical_offset is not None) or (self.vertical_offset is None and self.horizontal_offset is not None): + raise Failed(f"Overlay Error: overlay attribute's horizontal_offset and vertical_offset must be used together") + + def color(attr): + if attr in self.data and self.data[attr]: + try: + return ImageColor.getcolor(self.data[attr], "RGBA") + except ValueError: + raise Failed(f"Overlay Error: overlay {attr}: {self.data[attr]} invalid") + self.back_color = color("back_color") + self.back_radius = util.parse("Overlay", "back_radius", self.data["back_radius"], datatype="int", parent="overlay") if "back_radius" in self.data else None + self.back_line_width = util.parse("Overlay", "back_line_width", self.data["back_line_width"], datatype="int", parent="overlay") if "back_line_width" in self.data else None + self.back_line_color = color("back_line_color") + self.back_padding = util.parse("Overlay", "back_padding", self.data["back_padding"], datatype="int", parent="overlay", default=0) if "back_padding" in self.data else 0 + self.back_align = util.parse("Overlay", "back_align", self.data["back_align"], parent="overlay", default="center", options=["left", "right", "center", "top", "bottom"]) if "back_align" in self.data else "center" + self.back_box = None + back_width = util.parse("Overlay", "back_width", self.data["back_width"], datatype="int", parent="overlay", minimum=0) if "back_width" in self.data else -1 + back_height = util.parse("Overlay", "back_height", self.data["back_height"], datatype="int", parent="overlay", minimum=0) if "back_height" in self.data else -1 + if (back_width >= 0 and back_height < 0) or (back_height >= 0 and back_width < 0): + raise Failed(f"Overlay Error: overlay attributes back_width and back_height must be used together") + if self.back_align != "center" and (back_width < 0 or back_height < 0): + raise Failed(f"Overlay Error: overlay attribute back_align only works when back_width and back_height are used") + elif back_width >= 0 and back_height >= 0: + self.back_box = (back_width, back_height) + self.has_back = True if self.back_color or self.back_line_color else False + if self.has_back and not self.has_coordinates() and not self.queue: + raise Failed(f"Overlay Error: horizontal_offset and vertical_offset are required when using a backdrop") + + def get_and_save_image(image_url): + response = self.config.get(image_url) + if response.status_code >= 400: + raise Failed(f"Overlay Error: Overlay Image not found at: {image_url}") + if "Content-Type" not in response.headers or response.headers["Content-Type"] != "image/png": + raise Failed(f"Overlay Error: Overlay Image not a png: {image_url}") + if not os.path.exists(library.overlay_folder) or not os.path.isdir(library.overlay_folder): + os.makedirs(library.overlay_folder, exist_ok=False) + logger.info(f"Creating Overlay Folder found at: {library.overlay_folder}") + clean_image_name, _ = util.validate_filename(self.name) + image_path = os.path.join(library.overlay_folder, f"{clean_image_name}.png") + if os.path.exists(image_path): + os.remove(image_path) + with open(image_path, "wb") as handler: + handler.write(response.content) + while util.is_locked(image_path): + time.sleep(1) + return image_path + + if not self.name.startswith("blur"): + if "file" in self.data and self.data["file"]: + self.path = self.data["file"] + elif "git" in self.data and self.data["git"]: + self.path = get_and_save_image(f"{github_base}{self.data['git']}.png") + elif "repo" in self.data and self.data["repo"]: + self.path = get_and_save_image(f"{self.config.custom_repo}{self.data['repo']}.png") + elif "url" in self.data and self.data["url"]: + self.path = get_and_save_image(self.data["url"]) + + if "|" in self.name: + raise Failed(f"Overlay Error: Overlay Name: {self.name} cannot contain '|'") + elif self.name.startswith("blur"): + try: + match = re.search("\\(([^)]+)\\)", self.name) + if not match or 0 >= int(match.group(1)) > 100: + raise ValueError + self.name = f"blur({match.group(1)})" + except ValueError: + logger.error(f"Overlay Error: failed to parse overlay blur name: {self.name} defaulting to blur(50)") + self.name = "blur(50)" + elif self.name.startswith("text"): + if not self.has_coordinates() and not self.queue: + raise Failed(f"Overlay Error: overlay attribute's horizontal_offset and vertical_offset are required when using text") + if self.path: + if not os.path.exists(self.path): + raise Failed(f"Overlay Error: Text Overlay Addon Image not found at: {self.path}") + self.addon_offset = util.parse("Overlay", "addon_offset", self.data["addon_offset"], datatype="int", parent="overlay") if "addon_offset" in self.data else 0 + self.addon_position = util.parse("Overlay", "addon_position", self.data["addon_position"], parent="overlay", options=["left", "right", "top", "bottom"]) if "addon_position" in self.data else "left" + image_compare = None + if self.config.Cache: + _, image_compare, _ = self.config.Cache.query_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays") + overlay_size = os.stat(self.path).st_size + self.updated = not image_compare or str(overlay_size) != str(image_compare) + try: + self.image = Image.open(self.path).convert("RGBA") + if self.config.Cache: + self.config.Cache.update_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays", self.name, overlay_size) + except OSError: + raise Failed(f"Overlay Error: overlay image {self.path} failed to load") + match = re.search("\\(([^)]+)\\)", self.name) + if not match: + raise Failed(f"Overlay Error: failed to parse overlay text name: {self.name}") + self.name = f"text({match.group(1)})" + self.font_name = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fonts", "Roboto-Medium.ttf") + if "font_size" in self.data: + self.font_size = util.parse("Overlay", "font_size", self.data["font_size"], datatype="int", parent="overlay", default=self.font_size) + if "font" in self.data and self.data["font"]: + font = str(self.data["font"]) + if not os.path.exists(font): + fonts = util.get_system_fonts() + if font not in fonts: + raise Failed(f"Overlay Error: font: {font} not found. Options: {', '.join(fonts)}") + self.font_name = font + self.font = ImageFont.truetype(self.font_name, self.font_size) + if "font_style" in self.data and self.data["font_style"]: + try: + variation_names = [n.decode("utf-8") for n in self.font.get_variation_names()] + if self.data["font_style"] in variation_names: + self.font.set_variation_by_name(self.data["font_style"]) + else: + raise Failed(f"Overlay Error: Font Style {self.data['font_style']} not found. Options: {','.join(variation_names)}") + except OSError: + logger.warning(f"Overlay Warning: font: {self.font} does not have variations") + self.font_color = None + if "font_color" in self.data and self.data["font_color"]: + try: + self.font_color = ImageColor.getcolor(self.data["font_color"], "RGBA") + except ValueError: + raise Failed(f"Overlay Error: overlay font_color: {self.data['font_color']} invalid") + if self.name not in special_text_overlays: + box = self.image.size if self.image else None + self.portrait, self.portrait_box = self.get_backdrop(portrait_dim, box=box, text=self.name[5:-1]) + self.landscape, self.landscape_box = self.get_backdrop(landscape_dim, box=box, text=self.name[5:-1]) + else: + if not self.path: + clean_name, _ = util.validate_filename(self.name) + self.path = os.path.join(library.overlay_folder, f"{clean_name}.png") + if not os.path.exists(self.path): + raise Failed(f"Overlay Error: Overlay Image not found at: {self.path}") + image_compare = None + if self.config.Cache: + _, image_compare, _ = self.config.Cache.query_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays") + overlay_size = os.stat(self.path).st_size + self.updated = not image_compare or str(overlay_size) != str(image_compare) + try: + self.image = Image.open(self.path).convert("RGBA") + if self.has_coordinates(): + self.portrait, self.portrait_box = self.get_backdrop(portrait_dim, box=self.image.size) + self.landscape, self.landscape_box = self.get_backdrop(landscape_dim, box=self.image.size) + if self.config.Cache: + self.config.Cache.update_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays", self.mapping_name, overlay_size) + except OSError: + raise Failed(f"Overlay Error: overlay image {self.path} failed to load") + + def get_backdrop(self, canvas_box, box=None, text=None, new_cords=None): + overlay_image = None + text_width = None + text_height = None + image_width, image_height = box if box else (None, None) + if text is not None: + _, _, text_width, text_height = self.get_text_size(text) + if image_width is not None and self.addon_position in ["left", "right"]: + box = (text_width + image_width + self.addon_offset, text_height if text_height > image_height else image_height) + elif image_width is not None: + box = (text_width if text_width > image_width else image_width, text_height + image_height + self.addon_offset) + else: + box = (text_width, text_height) + box_width, box_height = box + back_width, back_height = self.back_box if self.back_box else (None, None) + start_x, start_y = self.get_coordinates(canvas_box, box, new_cords=new_cords) + main_x = start_x + main_y = start_y + if text is not None or self.has_back: + overlay_image = Image.new("RGBA", canvas_box, (255, 255, 255, 0)) + drawing = ImageDraw.Draw(overlay_image) + if self.has_back: + cords = ( + start_x - self.back_padding, + start_y - self.back_padding, + start_x + (back_width if self.back_box else box_width) + self.back_padding, + start_y + (back_height if self.back_box else box_height) + self.back_padding + ) + if self.back_radius: + drawing.rounded_rectangle(cords, fill=self.back_color, outline=self.back_line_color, width=self.back_line_width, radius=self.back_radius) + else: + drawing.rectangle(cords, fill=self.back_color, outline=self.back_line_color, width=self.back_line_width) + + if self.back_box: + if self.back_align == "left": + main_y = start_y + (back_height - box_height) // 2 + elif self.back_align == "right": + main_x = start_x + back_width - (text_width if text is not None else image_width) + elif self.back_align == "top": + main_x = start_x + (back_width - box_width) // 2 + elif self.back_align == "bottom": + main_y = start_y + back_height - (text_height if text is not None else image_height) + else: + main_x = start_x + (back_width - box_width) // 2 + main_y = start_y + (back_height - box_height) // 2 + + addon_x = None + addon_y = None + if text is not None and image_width: + addon_x = main_x + addon_y = main_y + if self.addon_position == "left": + if self.back_align == "left": + main_x = start_x + self.addon_offset + elif self.back_align == "right": + addon_x = start_x + back_width - self.addon_offset + else: + main_x = addon_x + image_width + self.addon_offset + elif self.addon_position == "right": + if self.back_align == "left": + addon_x = start_x + self.addon_offset + elif self.back_align == "right": + addon_x = start_x + back_width - image_width + main_x = start_x + back_width - self.addon_offset + else: + addon_x = main_x + text_width + self.addon_offset + elif text_width < image_width: + main_x = main_x + ((image_width - text_width) / 2) + elif text_width > image_width: + addon_x = main_x + ((text_width - image_width) / 2) + + if self.addon_position == "top": + if self.back_align == "top": + main_y = start_y + self.addon_offset + elif self.back_align == "bottom": + addon_y = start_y + back_height - self.addon_offset + else: + main_y = addon_y + image_height + self.addon_offset + elif self.addon_position == "bottom": + if self.back_align == "top": + addon_y = start_y + self.addon_offset + elif self.back_align == "bottom": + addon_y = start_y + back_height - image_height + main_y = start_y + back_height - self.addon_offset + else: + addon_y = main_y + text_height + self.addon_offset + elif text_height < image_height: + main_y = main_y + ((image_height - text_height) / 2) + elif text_height > image_height: + addon_y = main_y + ((text_height - image_height) / 2) + + if text is not None: + drawing.text((int(main_x), int(main_y)), text, font=self.font, fill=self.font_color, anchor="lt") + if addon_x is not None: + main_x = addon_x + main_y = addon_y + return overlay_image, (int(main_x), int(main_y)) + + def get_overlay_compare(self): + output = f"{self.name}" + if self.group: + output += f"{self.group}{self.weight}" + if self.has_coordinates(): + output += f"{self.horizontal_align}{self.horizontal_offset}{self.vertical_offset}{self.vertical_align}" + if self.font_name: + output += f"{self.font_name}{self.font_size}" + if self.back_box: + output += f"{self.back_box[0]}{self.back_box[1]}{self.back_align}" + if self.addon_position is not None: + output += f"{self.addon_position}{self.addon_offset}" + for value in [self.font_color, self.back_color, self.back_radius, self.back_padding, self.back_line_color, self.back_line_width]: + if value is not None: + output += f"{value}" + return output + + def has_coordinates(self): + return self.horizontal_offset is not None and self.vertical_offset is not None + + def get_text_size(self, text): + return ImageDraw.Draw(Image.new("RGBA", (0, 0))).textbbox((0, 0), text, font=self.font, anchor='lt') + + def get_coordinates(self, canvas_box, box, new_cords=None): + if new_cords is None and not self.has_coordinates(): + return 0, 0 + if self.back_box: + box = self.back_box + + def get_cord(value, image_value, over_value, align): + value = int(image_value * 0.01 * int(value[:-1])) if str(value).endswith("%") else value + if align in ["right", "bottom"]: + return image_value - over_value - value + elif align == "center": + return int(image_value / 2) - int(over_value / 2) + value + else: + return value + + if new_cords is None: + ho = self.horizontal_offset + ha = self.horizontal_align + vo = self.vertical_offset + va = self.vertical_align + else: + ha, ho, va, vo = new_cords + return get_cord(ho, canvas_box[0], box[0], ha), get_cord(vo, canvas_box[1], box[1], va) diff --git a/modules/overlays.py b/modules/overlays.py index ea2265b3..a779a1bc 100644 --- a/modules/overlays.py +++ b/modules/overlays.py @@ -1,7 +1,8 @@ import os, re, time from datetime import datetime -from modules import plex, util +from modules import plex, util, overlay from modules.builder import CollectionBuilder +from modules.overlay import Overlay from modules.util import Failed, NotScheduled from plexapi.exceptions import BadRequest from plexapi.video import Movie, Show, Season, Episode @@ -90,18 +91,18 @@ class Overlays: applied_names = [] queue_overlays = {} for over_name in over_names: - overlay = properties[over_name] - if overlay.name.startswith("blur"): + current_overlay = properties[over_name] + if current_overlay.name.startswith("blur"): logger.info(over_name) - blur_test = int(re.search("\\(([^)]+)\\)", overlay.name).group(1)) + blur_test = int(re.search("\\(([^)]+)\\)", current_overlay.name).group(1)) if blur_test > blur_num: blur_num = blur_test - elif overlay.queue: - if overlay.queue not in queue_overlays: - queue_overlays[overlay.queue] = {} - if overlay.weight in queue_overlays[overlay.queue]: + elif current_overlay.queue: + if current_overlay.queue not in queue_overlays: + queue_overlays[current_overlay.queue] = {} + if current_overlay.weight in queue_overlays[current_overlay.queue]: raise Failed("Overlay Error: Overlays in a queue cannot have the same weight") - queue_overlays[overlay.queue][overlay.weight] = over_name + queue_overlays[current_overlay.queue][current_overlay.weight] = over_name else: applied_names.append(over_name) @@ -118,10 +119,10 @@ class Overlays: if self.config.Cache: for over_name in over_names: - overlay = properties[over_name] - if overlay.name in util.special_text_overlays: - rating_type = overlay.name[5:-1] - if rating_type.endswith(tuple(util.rating_mods)): + current_overlay = properties[over_name] + if current_overlay.name in overlay.special_text_overlays: + rating_type = current_overlay.name[5:-1] + if rating_type.endswith(tuple(overlay.rating_mods)): rating_type = rating_type[:-1] cache_rating = self.config.Cache.query_overlay_ratings(item.ratingKey, rating_type) actual = plex.attribute_translation[rating_type] @@ -191,9 +192,9 @@ class Overlays: def get_text(text): text = text[5:-1] - if f"text({text})" in util.special_text_overlays: + if f"text({text})" in overlay.special_text_overlays: rating_code = text[-1:] - text_rating_type = text[:-1] if rating_code in util.rating_mods else text + text_rating_type = text[:-1] if rating_code in overlay.rating_mods else text text_actual = plex.attribute_translation[text_rating_type] if not hasattr(item, text_actual) or getattr(item, text_actual) is None: raise Failed(f"Overlay Warning: No {text_rating_type} found") @@ -207,31 +208,31 @@ class Overlays: return str(text) for over_name in applied_names: - overlay = properties[over_name] - if overlay.name.startswith("text"): - if overlay.name in util.special_text_overlays: - image_box = overlay.image.size if overlay.image else None + current_overlay = properties[over_name] + if current_overlay.name.startswith("text"): + if current_overlay.name in overlay.special_text_overlays: + image_box = current_overlay.image.size if current_overlay.image else None try: - overlay_image, addon_box = overlay.get_backdrop((canvas_width, canvas_height), box=image_box, text=get_text(overlay.name)) + overlay_image, addon_box = current_overlay.get_backdrop((canvas_width, canvas_height), box=image_box, text=get_text(current_overlay.name)) except Failed as e: logger.warning(e) continue new_poster.paste(overlay_image, (0, 0), overlay_image) - if overlay.image: - new_poster.paste(overlay.image, addon_box, overlay.image) + if current_overlay.image: + new_poster.paste(current_overlay.image, addon_box, current_overlay.image) else: - overlay_image = overlay.landscape if isinstance(item, Episode) else overlay.portrait + overlay_image = current_overlay.landscape if isinstance(item, Episode) else current_overlay.portrait new_poster.paste(overlay_image, (0, 0), overlay_image) else: - if overlay.has_coordinates(): - if overlay.portrait is not None: - overlay_image = overlay.landscape if isinstance(item, Episode) else overlay.portrait + if current_overlay.has_coordinates(): + if current_overlay.portrait is not None: + overlay_image = current_overlay.landscape if isinstance(item, Episode) else current_overlay.portrait new_poster.paste(overlay_image, (0, 0), overlay_image) - overlay_box = overlay.landscape_box if isinstance(item, Episode) else overlay.portrait_box - new_poster.paste(overlay.image, overlay_box, overlay.image) + overlay_box = current_overlay.landscape_box if isinstance(item, Episode) else current_overlay.portrait_box + new_poster.paste(current_overlay.image, overlay_box, current_overlay.image) else: - new_poster = new_poster.resize(overlay.image.size, Image.ANTIALIAS) - new_poster.paste(overlay.image, (0, 0), overlay.image) + new_poster = new_poster.resize(current_overlay.image.size, Image.ANTIALIAS) + new_poster.paste(current_overlay.image, (0, 0), current_overlay.image) for queue, weights in queue_overlays.items(): if queue not in queues: @@ -243,24 +244,24 @@ class Overlays: if len(sorted_weights) <= o: break over_name = sorted_weights[o][1] - overlay = properties[over_name] - if overlay.name.startswith("text"): - image_box = overlay.image.size if overlay.image else None + current_overlay = properties[over_name] + if current_overlay.name.startswith("text"): + image_box = current_overlay.image.size if current_overlay.image else None try: - overlay_image, addon_box = overlay.get_backdrop((canvas_width, canvas_height), box=image_box, text=get_text(overlay.name), new_cords=cord) + overlay_image, addon_box = current_overlay.get_backdrop((canvas_width, canvas_height), box=image_box, text=get_text(current_overlay.name), new_cords=cord) except Failed as e: logger.warning(e) continue new_poster.paste(overlay_image, (0, 0), overlay_image) - if overlay.image: - new_poster.paste(overlay.image, addon_box, overlay.image) + if current_overlay.image: + new_poster.paste(current_overlay.image, addon_box, current_overlay.image) else: - if overlay.has_back: - overlay_image, overlay_box = overlay.get_backdrop((canvas_width, canvas_height), box=overlay.image.size, new_cords=cord) + if current_overlay.has_back: + overlay_image, overlay_box = current_overlay.get_backdrop((canvas_width, canvas_height), box=current_overlay.image.size, new_cords=cord) new_poster.paste(overlay_image, (0, 0), overlay_image) else: - overlay_box = overlay.get_coordinates((canvas_width, canvas_height), box=overlay.image.size, new_cords=cord) - new_poster.paste(overlay.image, overlay_box, overlay.image) + overlay_box = current_overlay.get_coordinates((canvas_width, canvas_height), box=current_overlay.image.size, new_cords=cord) + new_poster.paste(current_overlay.image, overlay_box, current_overlay.image) temp = os.path.join(self.library.overlay_folder, f"temp.png") new_poster.save(temp, "PNG") self.library.upload_poster(item, temp) @@ -297,7 +298,7 @@ class Overlays: if not isinstance(v, list): raise Failed(f"Overlay Error: Queue: {k} must be a list") try: - queues[k] = [util.parse_cords(q, f"{k} queue", required=True) for q in v] + queues[k] = [overlay.parse_cords(q, f"{k} queue", required=True) for q in v] except Failed as e: logger.error(e) for k, v in overlay_file.overlays.items(): diff --git a/modules/util.py b/modules/util.py index 8b109ee3..76e9492f 100644 --- a/modules/util.py +++ b/modules/util.py @@ -4,7 +4,6 @@ from pathvalidate import is_valid_filename, sanitize_filename from plexapi.audio import Album, Track from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.video import Season, Episode, Movie -from PIL import Image, ImageColor, ImageDraw, ImageFont try: import msvcrt @@ -93,11 +92,8 @@ collection_mode_options = { parental_types = ["nudity", "violence", "profanity", "alcohol", "frightening"] parental_values = ["None", "Mild", "Moderate", "Severe"] parental_labels = [f"{t.capitalize()}:{v}" for t in parental_types for v in parental_values] -github_base = "https://raw.githubusercontent.com/meisnate12/Plex-Meta-Manager-Configs/master/" previous_time = None start_time = None -rating_mods = ["0", "%", "#"] -special_text_overlays = [f"text({a}{s})" for a in ["audience_rating", "critic_rating", "user_rating"] for s in [""] + rating_mods] def make_ordinal(n): return f"{n}{'th' if 11 <= (n % 100) <= 13 else ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)]}" @@ -853,407 +849,3 @@ class YAML: self.yaml.dump(self.data, fp) -portrait_dim = (1000, 1500) -landscape_dim = (1920, 1080) - -def parse_cords(data, parent, required=False): - horizontal_align = parse("Overlay", "horizontal_align", data["horizontal_align"], parent=parent, - options=["left", "center", "right"]) if "horizontal_align" in data else "left" - vertical_align = parse("Overlay", "vertical_align", data["vertical_align"], parent=parent, - options=["top", "center", "bottom"]) if "vertical_align" in data else "top" - - horizontal_offset = None - if "horizontal_offset" in data and data["horizontal_offset"] is not None: - x_off = data["horizontal_offset"] - per = False - if str(x_off).endswith("%"): - x_off = x_off[:-1] - per = True - x_off = check_num(x_off) - error = f"Overlay Error: {parent} horizontal_offset: {data['horizontal_offset']} must be a number" - if x_off is None: - raise Failed(error) - if horizontal_align != "center" and not per and x_off < 0: - raise Failed(f"{error} 0 or greater") - elif horizontal_align != "center" and per and (x_off > 100 or x_off < 0): - raise Failed(f"{error} between 0% and 100%") - elif horizontal_align == "center" and per and (x_off > 50 or x_off < -50): - raise Failed(f"{error} between -50% and 50%") - horizontal_offset = f"{x_off}%" if per else x_off - if horizontal_offset is None and horizontal_align == "center": - horizontal_offset = 0 - if required and horizontal_offset is None: - raise Failed(f"Overlay Error: {parent} horizontal_offset is required") - - vertical_offset = None - if "vertical_offset" in data and data["vertical_offset"] is not None: - y_off = data["vertical_offset"] - per = False - if str(y_off).endswith("%"): - y_off = y_off[:-1] - per = True - y_off = check_num(y_off) - error = f"Overlay Error: {parent} vertical_offset: {data['vertical_offset']} must be a number" - if y_off is None: - raise Failed(error) - if vertical_align != "center" and not per and y_off < 0: - raise Failed(f"{error} 0 or greater") - elif vertical_align != "center" and per and (y_off > 100 or y_off < 0): - raise Failed(f"{error} between 0% and 100%") - elif vertical_align == "center" and per and (y_off > 50 or y_off < -50): - raise Failed(f"{error} between -50% and 50%") - vertical_offset = f"{y_off}%" if per else y_off - if vertical_offset is None and vertical_align == "center": - vertical_offset = 0 - if required and vertical_offset is None: - raise Failed(f"Overlay Error: {parent} vertical_offset is required") - - return horizontal_align, horizontal_offset, vertical_align, vertical_offset - - -class Overlay: - def __init__(self, config, library, original_mapping_name, overlay_data, suppress): - self.config = config - self.library = library - self.original_mapping_name = original_mapping_name - self.data = overlay_data - self.suppress = suppress - self.keys = [] - self.updated = False - self.image = None - self.landscape = None - self.landscape_box = None - self.portrait = None - self.portrait_box = None - self.group = None - self.queue = None - self.weight = None - self.path = None - self.font = None - self.font_name = None - self.font_size = 36 - self.font_color = None - self.addon_offset = 0 - self.addon_position = None - - logger.debug("") - logger.debug("Validating Method: overlay") - logger.debug(f"Value: {self.data}") - if not isinstance(self.data, dict): - self.data = {"name": str(self.data)} - logger.warning(f"Overlay Warning: No overlay attribute using mapping name {self.data} as the overlay name") - if "name" not in self.data or not self.data["name"]: - raise Failed(f"Overlay Error: overlay must have the name attribute") - self.name = str(self.data["name"]) - if self.original_mapping_name not in library.overlay_names: - library.overlay_names.append(self.original_mapping_name) - self.mapping_name = self.original_mapping_name - else: - name_count = 1 - test_name = f"{self.original_mapping_name} ({name_count})" - while test_name in library.overlay_names: - name_count += 1 - test_name = f"{self.original_mapping_name} ({name_count})" - library.overlay_names.append(test_name) - self.mapping_name = test_name - - if "group" in self.data and self.data["group"]: - self.group = str(self.data["group"]) - if "queue" in self.data and self.data["queue"]: - self.queue = str(self.data["queue"]) - if "weight" in self.data: - self.weight = parse("Overlay", "weight", self.data["weight"], datatype="int", parent="overlay", minimum=0) - if "group" in self.data and (self.weight is None or not self.group): - raise Failed(f"Overlay Error: overlay attribute's group requires the weight attribute") - elif "queue" in self.data and (self.weight is None or not self.queue): - raise Failed(f"Overlay Error: overlay attribute's queue requires the weight attribute") - elif self.group and self.queue: - raise Failed(f"Overlay Error: overlay attribute's group and queue cannot be used together") - self.horizontal_align, self.horizontal_offset, self.vertical_align, self.vertical_offset = parse_cords(self.data, "overlay") - - if (self.horizontal_offset is None and self.vertical_offset is not None) or (self.vertical_offset is None and self.horizontal_offset is not None): - raise Failed(f"Overlay Error: overlay attribute's horizontal_offset and vertical_offset must be used together") - - def color(attr): - if attr in self.data and self.data[attr]: - try: - return ImageColor.getcolor(self.data[attr], "RGBA") - except ValueError: - raise Failed(f"Overlay Error: overlay {attr}: {self.data[attr]} invalid") - self.back_color = color("back_color") - self.back_radius = parse("Overlay", "back_radius", self.data["back_radius"], datatype="int", parent="overlay") if "back_radius" in self.data else None - self.back_line_width = parse("Overlay", "back_line_width", self.data["back_line_width"], datatype="int", parent="overlay") if "back_line_width" in self.data else None - self.back_line_color = color("back_line_color") - self.back_padding = parse("Overlay", "back_padding", self.data["back_padding"], datatype="int", parent="overlay", default=0) if "back_padding" in self.data else 0 - self.back_align = parse("Overlay", "back_align", self.data["back_align"], parent="overlay", default="center", options=["left", "right", "center", "top", "bottom"]) if "back_align" in self.data else "center" - self.back_box = None - back_width = parse("Overlay", "back_width", self.data["back_width"], datatype="int", parent="overlay", minimum=0) if "back_width" in self.data else -1 - back_height = parse("Overlay", "back_height", self.data["back_height"], datatype="int", parent="overlay", minimum=0) if "back_height" in self.data else -1 - if (back_width >= 0 and back_height < 0) or (back_height >= 0 and back_width < 0): - raise Failed(f"Overlay Error: overlay attributes back_width and back_height must be used together") - if self.back_align != "center" and (back_width < 0 or back_height < 0): - raise Failed(f"Overlay Error: overlay attribute back_align only works when back_width and back_height are used") - elif back_width >= 0 and back_height >= 0: - self.back_box = (back_width, back_height) - self.has_back = True if self.back_color or self.back_line_color else False - if self.has_back and not self.has_coordinates() and not self.queue: - raise Failed(f"Overlay Error: horizontal_offset and vertical_offset are required when using a backdrop") - - def get_and_save_image(image_url): - response = self.config.get(image_url) - if response.status_code >= 400: - raise Failed(f"Overlay Error: Overlay Image not found at: {image_url}") - if "Content-Type" not in response.headers or response.headers["Content-Type"] != "image/png": - raise Failed(f"Overlay Error: Overlay Image not a png: {image_url}") - if not os.path.exists(library.overlay_folder) or not os.path.isdir(library.overlay_folder): - os.makedirs(library.overlay_folder, exist_ok=False) - logger.info(f"Creating Overlay Folder found at: {library.overlay_folder}") - clean_image_name, _ = validate_filename(self.name) - image_path = os.path.join(library.overlay_folder, f"{clean_image_name}.png") - if os.path.exists(image_path): - os.remove(image_path) - with open(image_path, "wb") as handler: - handler.write(response.content) - while is_locked(image_path): - time.sleep(1) - return image_path - - if not self.name.startswith("blur"): - if "file" in self.data and self.data["file"]: - self.path = self.data["file"] - elif "git" in self.data and self.data["git"]: - self.path = get_and_save_image(f"{github_base}{self.data['git']}.png") - elif "repo" in self.data and self.data["repo"]: - self.path = get_and_save_image(f"{self.config.custom_repo}{self.data['repo']}.png") - elif "url" in self.data and self.data["url"]: - self.path = get_and_save_image(self.data["url"]) - - if "|" in self.name: - raise Failed(f"Overlay Error: Overlay Name: {self.name} cannot contain '|'") - elif self.name.startswith("blur"): - try: - match = re.search("\\(([^)]+)\\)", self.name) - if not match or 0 >= int(match.group(1)) > 100: - raise ValueError - self.name = f"blur({match.group(1)})" - except ValueError: - logger.error(f"Overlay Error: failed to parse overlay blur name: {self.name} defaulting to blur(50)") - self.name = "blur(50)" - elif self.name.startswith("text"): - if not self.has_coordinates() and not self.queue: - raise Failed(f"Overlay Error: overlay attribute's horizontal_offset and vertical_offset are required when using text") - if self.path: - if not os.path.exists(self.path): - raise Failed(f"Overlay Error: Text Overlay Addon Image not found at: {self.path}") - self.addon_offset = parse("Overlay", "addon_offset", self.data["addon_offset"], datatype="int", parent="overlay") if "addon_offset" in self.data else 0 - self.addon_position = parse("Overlay", "addon_position", self.data["addon_position"], parent="overlay", options=["left", "right", "top", "bottom"]) if "addon_position" in self.data else "left" - image_compare = None - if self.config.Cache: - _, image_compare, _ = self.config.Cache.query_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays") - overlay_size = os.stat(self.path).st_size - self.updated = not image_compare or str(overlay_size) != str(image_compare) - try: - self.image = Image.open(self.path).convert("RGBA") - if self.config.Cache: - self.config.Cache.update_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays", self.name, overlay_size) - except OSError: - raise Failed(f"Overlay Error: overlay image {self.path} failed to load") - match = re.search("\\(([^)]+)\\)", self.name) - if not match: - raise Failed(f"Overlay Error: failed to parse overlay text name: {self.name}") - self.name = f"text({match.group(1)})" - self.font_name = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fonts", "Roboto-Medium.ttf") - if "font_size" in self.data: - self.font_size = parse("Overlay", "font_size", self.data["font_size"], datatype="int", parent="overlay", default=self.font_size) - if "font" in self.data and self.data["font"]: - font = str(self.data["font"]) - if not os.path.exists(font): - fonts = get_system_fonts() - if font not in fonts: - raise Failed(f"Overlay Error: font: {font} not found. Options: {', '.join(fonts)}") - self.font_name = font - self.font = ImageFont.truetype(self.font_name, self.font_size) - if "font_style" in self.data and self.data["font_style"]: - try: - variation_names = [n.decode("utf-8") for n in self.font.get_variation_names()] - if self.data["font_style"] in variation_names: - self.font.set_variation_by_name(self.data["font_style"]) - else: - raise Failed(f"Overlay Error: Font Style {self.data['font_style']} not found. Options: {','.join(variation_names)}") - except OSError: - logger.warning(f"Overlay Warning: font: {self.font} does not have variations") - self.font_color = None - if "font_color" in self.data and self.data["font_color"]: - try: - self.font_color = ImageColor.getcolor(self.data["font_color"], "RGBA") - except ValueError: - raise Failed(f"Overlay Error: overlay font_color: {self.data['font_color']} invalid") - if self.name not in special_text_overlays: - box = self.image.size if self.image else None - self.portrait, self.portrait_box = self.get_backdrop(portrait_dim, box=box, text=self.name[5:-1]) - self.landscape, self.landscape_box = self.get_backdrop(landscape_dim, box=box, text=self.name[5:-1]) - else: - if not self.path: - clean_name, _ = validate_filename(self.name) - self.path = os.path.join(library.overlay_folder, f"{clean_name}.png") - if not os.path.exists(self.path): - raise Failed(f"Overlay Error: Overlay Image not found at: {self.path}") - image_compare = None - if self.config.Cache: - _, image_compare, _ = self.config.Cache.query_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays") - overlay_size = os.stat(self.path).st_size - self.updated = not image_compare or str(overlay_size) != str(image_compare) - try: - self.image = Image.open(self.path).convert("RGBA") - if self.has_coordinates(): - self.portrait, self.portrait_box = self.get_backdrop(portrait_dim, box=self.image.size) - self.landscape, self.landscape_box = self.get_backdrop(landscape_dim, box=self.image.size) - if self.config.Cache: - self.config.Cache.update_image_map(self.mapping_name, f"{self.library.image_table_name}_overlays", self.mapping_name, overlay_size) - except OSError: - raise Failed(f"Overlay Error: overlay image {self.path} failed to load") - - def get_backdrop(self, canvas_box, box=None, text=None, new_cords=None): - overlay_image = None - text_width = None - text_height = None - image_width, image_height = box if box else (None, None) - if text is not None: - _, _, text_width, text_height = self.get_text_size(text) - if image_width is not None and self.addon_position in ["left", "right"]: - box = (text_width + image_width + self.addon_offset, text_height if text_height > image_height else image_height) - elif image_width is not None: - box = (text_width if text_width > image_width else image_width, text_height + image_height + self.addon_offset) - else: - box = (text_width, text_height) - box_width, box_height = box - back_width, back_height = self.back_box if self.back_box else (None, None) - start_x, start_y = self.get_coordinates(canvas_box, box, new_cords=new_cords) - main_x = start_x - main_y = start_y - if text is not None or self.has_back: - overlay_image = Image.new("RGBA", canvas_box, (255, 255, 255, 0)) - drawing = ImageDraw.Draw(overlay_image) - if self.has_back: - cords = ( - start_x - self.back_padding, - start_y - self.back_padding, - start_x + (back_width if self.back_box else box_width) + self.back_padding, - start_y + (back_height if self.back_box else box_height) + self.back_padding - ) - if self.back_radius: - drawing.rounded_rectangle(cords, fill=self.back_color, outline=self.back_line_color, width=self.back_line_width, radius=self.back_radius) - else: - drawing.rectangle(cords, fill=self.back_color, outline=self.back_line_color, width=self.back_line_width) - - if self.back_box: - if self.back_align == "left": - main_y = start_y + (back_height - box_height) // 2 - elif self.back_align == "right": - main_x = start_x + back_width - (text_width if text is not None else image_width) - elif self.back_align == "top": - main_x = start_x + (back_width - box_width) // 2 - elif self.back_align == "bottom": - main_y = start_y + back_height - (text_height if text is not None else image_height) - else: - main_x = start_x + (back_width - box_width) // 2 - main_y = start_y + (back_height - box_height) // 2 - - addon_x = None - addon_y = None - if text is not None and image_width: - addon_x = main_x - addon_y = main_y - if self.addon_position == "left": - if self.back_align == "left": - main_x = start_x + self.addon_offset - elif self.back_align == "right": - addon_x = start_x + back_width - self.addon_offset - else: - main_x = addon_x + image_width + self.addon_offset - elif self.addon_position == "right": - if self.back_align == "left": - addon_x = start_x + self.addon_offset - elif self.back_align == "right": - addon_x = start_x + back_width - image_width - main_x = start_x + back_width - self.addon_offset - else: - addon_x = main_x + text_width + self.addon_offset - elif text_width < image_width: - main_x = main_x + ((image_width - text_width) / 2) - elif text_width > image_width: - addon_x = main_x + ((text_width - image_width) / 2) - - if self.addon_position == "top": - if self.back_align == "top": - main_y = start_y + self.addon_offset - elif self.back_align == "bottom": - addon_y = start_y + back_height - self.addon_offset - else: - main_y = addon_y + image_height + self.addon_offset - elif self.addon_position == "bottom": - if self.back_align == "top": - addon_y = start_y + self.addon_offset - elif self.back_align == "bottom": - addon_y = start_y + back_height - image_height - main_y = start_y + back_height - self.addon_offset - else: - addon_y = main_y + text_height + self.addon_offset - elif text_height < image_height: - main_y = main_y + ((image_height - text_height) / 2) - elif text_height > image_height: - addon_y = main_y + ((text_height - image_height) / 2) - - if text is not None: - drawing.text((int(main_x), int(main_y)), text, font=self.font, fill=self.font_color, anchor="lt") - if addon_x is not None: - main_x = addon_x - main_y = addon_y - return overlay_image, (int(main_x), int(main_y)) - - def get_overlay_compare(self): - output = f"{self.name}" - if self.group: - output += f"{self.group}{self.weight}" - if self.has_coordinates(): - output += f"{self.horizontal_align}{self.horizontal_offset}{self.vertical_offset}{self.vertical_align}" - if self.font_name: - output += f"{self.font_name}{self.font_size}" - if self.back_box: - output += f"{self.back_box[0]}{self.back_box[1]}{self.back_align}" - if self.addon_position is not None: - output += f"{self.addon_position}{self.addon_offset}" - for value in [self.font_color, self.back_color, self.back_radius, self.back_padding, self.back_line_color, self.back_line_width]: - if value is not None: - output += f"{value}" - return output - - def has_coordinates(self): - return self.horizontal_offset is not None and self.vertical_offset is not None - - def get_text_size(self, text): - return ImageDraw.Draw(Image.new("RGBA", (0, 0))).textbbox((0, 0), text, font=self.font, anchor='lt') - - def get_coordinates(self, canvas_box, box, new_cords=None): - if new_cords is None and not self.has_coordinates(): - return 0, 0 - if self.back_box: - box = self.back_box - - def get_cord(value, image_value, over_value, align): - value = int(image_value * 0.01 * int(value[:-1])) if str(value).endswith("%") else value - if align in ["right", "bottom"]: - return image_value - over_value - value - elif align == "center": - return int(image_value / 2) - int(over_value / 2) + value - else: - return value - - if new_cords is None: - ho = self.horizontal_offset - ha = self.horizontal_align - vo = self.vertical_offset - va = self.vertical_align - else: - ha, ho, va, vo = new_cords - return get_cord(ho, canvas_box[0], box[0], ha), get_cord(vo, canvas_box[1], box[1], va)