diff --git a/README.md b/README.md index 40f36b2e..6f2cc759 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,6 @@ If you are unable to use the [Plex Meta Manager Discord Server](https://discord. ## IBRACORP Video Walkthrough -[IBRACORP](https://ibracorp.io/) made a video walkthough for installing Plex Meta Manager on unRAID. While you might not be using unRAID the video goes over many key aspects of Plex Meta Manager and can be a great place to start learning how to use the script. +[IBRACORP](https://ibracorp.io/) made a video walkthrough for installing Plex Meta Manager on unRAID. While you might not be using unRAID the video goes over many key aspects of Plex Meta Manager and can be a great place to start learning how to use the script. [![Plex Meta Manager](https://img.youtube.com/vi/dF69MNoot3w/0.jpg)](https://www.youtube.com/watch?v=dF69MNoot3w "Plex Meta Manager") diff --git a/Salma.otf b/Salma.otf new file mode 100644 index 00000000..66d1a696 Binary files /dev/null and b/Salma.otf differ diff --git a/VERSION b/VERSION index 7652e244..456894a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.5-develop94 +1.16.5-develop95 diff --git a/docs/metadata/overlay.md b/docs/metadata/overlay.md index 818dce2f..040ebeda 100644 --- a/docs/metadata/overlay.md +++ b/docs/metadata/overlay.md @@ -63,8 +63,10 @@ Each overlay definition needs to specify what overlay to use. This can happen in | `repo` | Location in the [Custom Repo](../config/settings.md#custom-repo) of the Overlay Image. | ❌ | | `group` | Name of the Grouping for this overlay. **`weight` is required when using `group`** | ❌ | | `weight` | Weight of this overlay in its group. **`group` is required when using `weight`** | ❌ | -| `x_coordinate` | Top Left X Coordinate of this overlay. **`y_coordinate` is required when using `x_coordinate`** | ❌ | -| `y_coordinate` | Top Left Y Coordinate of this overlay. **`x_coordinate` is required when using `y_coordinate`** | ❌ | +| `x_coordinate` | X Coordinate of this overlay. Can be a %. **`y_coordinate` is required when using `x_coordinate`** | ❌ | +| `x_align` | Where the `x_coordinate` is calculated from. **Values:** `left`, `center`, `right` | ❌ | +| `y_coordinate` | Y Coordinate of this overlay. Can be a %. **`x_coordinate` is required when using `y_coordinate`** | ❌ | +| `y_align` | Where the `y_coordinate` is calculated from. **Values:** `top`, `center`, `bottom` | ❌ | | `font` | System Font Filename or path to font file for the Text Overlay | ❌ | | `font_size` | Font Size for the Text Overlay. **Value:** Integer greater than 0 | ❌ | | `font_color` | Font Color for the Text Overlay. **Value:** Color Hex Code. ex `#00FF00` | ❌ | @@ -106,6 +108,8 @@ The `x_coordinate` and `y_coordinate` overlay attributes are required when using You can add an items rating to the image by using `text(audience_rating)`, `text(critic_rating)`, or `text(user_rating)` +Default font `Salma.otf` provided by [Alifinart Studio](https://www.behance.net/alifinart) + ```yaml overlays: audience_rating: @@ -113,7 +117,7 @@ overlays: name: text(audience_rating) x_coordinate: 15 y_coordinate: 15 - font: arial.ttf + font: Salma.otf font_size: 200 plex_all: true ``` diff --git a/modules/config.py b/modules/config.py index 7e345535..a65290c5 100644 --- a/modules/config.py +++ b/modules/config.py @@ -715,8 +715,8 @@ class ConfigFile: logger.error("Config Error: operations must be a dictionary") def error_check(attr, service): + logger.error(f"Config Error: Operation {attr} cannot be {params[attr]} without a successful {service} Connection") params[attr] = None - logger.error(f"Config Error: {attr} cannot be {params[attr]} without a successful {service} Connection") for mass_key in ["mass_genre_update", "mass_audience_rating_update", "mass_critic_rating_update", "mass_content_rating_update", "mass_originally_available_update"]: if params[mass_key] == "omdb" and self.OMDb is None: diff --git a/modules/meta.py b/modules/meta.py index 0a1f6d52..d614ebc0 100644 --- a/modules/meta.py +++ b/modules/meta.py @@ -314,7 +314,6 @@ class MetadataFile(DataFile): auto_list = {} all_keys = [] dynamic_data = None - logger.debug(exclude) def _check_dict(check_dict): for ck, cv in check_dict.items(): all_keys.append(ck) @@ -527,7 +526,7 @@ class MetadataFile(DataFile): for template_name in template_names: if template_name not in self.templates: raise Failed(f"Config Error: {map_name} template: {template_name} not found") - if "<>" in str(self.templates[template_name][0]) or f"<<{auto_type}>>" in str(self.templates[template_name][0]): + if any([a in str(self.templates[template_name][0]) for a in ["<>", "<>", f"<<{auto_type}>>"]]): has_var = True if not has_var: raise Failed(f"Config Error: One {map_name} template: {template_names} is required to have the template variable <>") diff --git a/modules/overlays.py b/modules/overlays.py index 5e3c596d..9025c21d 100644 --- a/modules/overlays.py +++ b/modules/overlays.py @@ -160,15 +160,17 @@ class Overlays: logger.error(f"{item_title[:60]:<60} | Overlay Error: No poster found") elif changed_image or overlay_change: try: + image_width = 1920 if isinstance(item, Episode) else 1000 + image_height = 1080 if isinstance(item, Episode) else 1500 + new_poster = Image.open(poster.location if poster else has_original) \ - .convert("RGBA") \ - .resize((1920, 1080) if isinstance(item, Episode) else (1000, 1500), Image.ANTIALIAS) + .convert("RGBA").resize((image_width, image_height), Image.ANTIALIAS) if blur_num > 0: new_poster = new_poster.filter(ImageFilter.GaussianBlur(blur_num)) for over_name in normal_overlays: overlay = properties[over_name] if overlay.coordinates: - new_poster.paste(overlay.image, overlay.coordinates, overlay.image) + new_poster.paste(overlay.image, overlay.get_coordinates(image_width, image_height), overlay.image) else: new_poster = new_poster.resize(overlay.image.size, Image.ANTIALIAS) new_poster.paste(overlay.image, (0, 0), overlay.image) @@ -176,7 +178,6 @@ class Overlays: drawing = ImageDraw.Draw(new_poster) for over_name in text_names: overlay = properties[over_name] - font = ImageFont.truetype(overlay.font, overlay.font_size) if overlay.font else None text = over_name[5:-1] if text in ["audience_rating", "critic_rating", "user_rating"]: rating_type = text @@ -187,7 +188,7 @@ class Overlays: text = getattr(item, actual) if self.config.Cache: self.config.Cache.update_overlay_ratings(item.ratingKey, rating_type, text) - drawing.text(overlay.coordinates, str(text), font=font, fill=overlay.font_color) + drawing.text(overlay.get_coordinates(image_width, image_height, text=str(text)), str(text), font=overlay.font, fill=overlay.font_color) temp = os.path.join(self.library.overlay_folder, f"temp.png") new_poster.save(temp, "PNG") self.library.upload_poster(item, temp) @@ -277,19 +278,6 @@ class Overlays: for suppress_name in over_obj.suppress: if suppress_name in properties and rk in properties[suppress_name].keys: properties[suppress_name].keys.remove(rk) - if not overlay_name.startswith(("blur", "text")): - image_compare = None - if self.config.Cache: - _, image_compare, _ = self.config.Cache.query_image_map(overlay_name, f"{self.library.image_table_name}_overlays") - overlay_size = os.stat(over_obj.path).st_size - over_obj.updated = not image_compare or str(overlay_size) != str(image_compare) - try: - over_obj.image = Image.open(over_obj.path).convert("RGBA") - if self.config.Cache: - self.config.Cache.update_image_map(overlay_name, f"{self.library.image_table_name}_overlays", overlay_name, overlay_size) - except OSError: - logger.error(f"Overlay Error: overlay image {over_obj.path} failed to load") - properties.pop(overlay_name) for overlay_name, over_obj in properties.items(): for over_key in over_obj.keys: diff --git a/modules/plex.py b/modules/plex.py index 62dfdfa5..b7ebec62 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -439,7 +439,7 @@ class Plex(Library): if label: label_id = next((c.key for c in self.get_tags("label") if c.title == label), None) if label_id: - args = f"{args}&{label_id}" + args = f"{args}&label={label_id}" else: return [] return self.get_filter_items(args) diff --git a/modules/util.py b/modules/util.py index e45b5ca6..55236485 100644 --- a/modules/util.py +++ b/modules/util.py @@ -4,7 +4,7 @@ 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 ImageColor +from PIL import Image, ImageColor, ImageDraw, ImageFont try: import msvcrt @@ -840,126 +840,195 @@ class Overlay: self.path = None self.coordinates = None self.font = None + self.font_name = None self.font_size = 12 self.font_color = None logger.debug("") logger.debug("Validating Method: overlay") logger.debug(f"Value: {self.data}") - if isinstance(self.data, dict): - 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 "group" in self.data and self.data["group"]: - self.group = str(self.data["group"]) - if "weight" in self.data and self.data["weight"] is not None: - pri = check_num(self.data["weight"]) - if pri is None: - raise Failed(f"Overlay Error: overlay weight must be a number") - self.weight = pri - if ("group" in self.data or "weight" in self.data) and (self.weight is None or not self.group): - raise Failed(f"Overlay Error: overlay attribute's group and weight must be used together") - - x_cord = None - y_cord = None - if "x_coordinate" in self.data and self.data["x_coordinate"] is not None: - x_cord = check_num(self.data["x_coordinate"]) - if x_cord is None or x_cord < 0: - raise Failed(f"Overlay Error: overlay x_coordinate: {self.data['x_coordinate']} must be a number 0 or greater") - if "y_coordinate" in self.data and self.data["y_coordinate"] is not None: - y_cord = check_num(self.data["y_coordinate"]) - if y_cord is None or y_cord < 0: - raise Failed(f"Overlay Error: overlay y_coordinate: {self.data['y_coordinate']} must be a number 0 or greater") - if ("x_coordinate" in self.data or "y_coordinate" in self.data) and (x_cord is None or y_cord is None): - raise Failed(f"Overlay Error: overlay x_coordinate and overlay y_coordinate must be used together") - if x_cord is not None or y_cord is not None: - self.coordinates = (x_cord, y_cord) - - 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", "text")): - 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 self.name.startswith("blur"): + 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 "group" in self.data and self.data["group"]: + self.group = str(self.data["group"]) + if "weight" in self.data and self.data["weight"] is not None: + pri = check_num(self.data["weight"]) + if pri is None: + raise Failed(f"Overlay Error: overlay weight must be a number") + self.weight = pri + if ("group" in self.data or "weight" in self.data) and (self.weight is None or not self.group): + raise Failed(f"Overlay Error: overlay attribute's group and weight must be used together") + + self.x_align = parse("Overlay", "x_align", self.data["x_align"], options=["left", "center", "right"]) if "x_align" in self.data else "left" + self.y_align = parse("Overlay", "y_align", self.data["y_align"], options=["top", "center", "bottom"]) if "y_align" in self.data else "top" + + x_cord = None + if "x_coordinate" in self.data and self.data["x_coordinate"] is not None: + x_cord = self.data["x_coordinate"] + per = False + if str(x_cord).endswith("%"): + x_cord = x_cord[:-1] + per = True + x_cord = check_num(x_cord) + error = f"Overlay Error: overlay x_coordinate: {self.data['x_coordinate']} must be a number" + if x_cord is None: + raise Failed(error) + if self.x_align != "center" and not per and x_cord < 0: + raise Failed(f"{error} 0 or greater") + elif self.x_align != "center" and per and x_cord > 100: + raise Failed(f"{error} between 0% and 100%") + elif self.x_align == "center" and per and (x_cord > 50 or x_cord < -50): + raise Failed(f"{error} between -50% and 50%") + if per: + x_cord = f"{x_cord}%" + + y_cord = None + if "y_coordinate" in self.data and self.data["y_coordinate"] is not None: + y_cord = self.data["y_coordinate"] + per = False + if str(y_cord).endswith("%"): + y_cord = y_cord[:-1] + per = True + y_cord = check_num(y_cord) + error = f"Overlay Error: overlay y_coordinate: {self.data['y_coordinate']} must be a number" + if y_cord is None: + raise Failed(error) + if self.y_align != "center" and not per and y_cord < 0: + raise Failed(f"{error} 0 or greater") + elif self.y_align != "center" and per and y_cord > 100: + raise Failed(f"{error} between 0% and 100%") + elif self.y_align == "center" and per and (y_cord > 50 or y_cord < -50): + raise Failed(f"{error} between -50% and 50%") + if per: + y_cord = f"{y_cord}%" + + if ("x_coordinate" in self.data or "y_coordinate" in self.data) and (x_cord is None or y_cord is None): + raise Failed(f"Overlay Error: overlay x_coordinate and overlay y_coordinate must be used together") + + if x_cord is not None or y_cord is not None: + self.coordinates = (x_cord, y_cord) + + 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", "text")): + 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 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.coordinates: + raise Failed(f"Overlay Error: overlay attribute's x_coordinate and y_coordinate are required when using text") + 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)})" + if os.path.exists("Salma.otf"): + self.font_name = "Salma.otf" + if "font_size" in self.data and self.data["font_size"] is not None: + font_size = check_num(self.data["font_size"]) + if font_size is None or font_size < 1: + logger.error(f"Overlay Error: overlay font_size: {self.data['font_size']} invalid must be a greater than 0") + else: + self.font_size = 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_color" in self.data and self.data["font_color"]: 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)})" + color_str = self.data["font_color"] + color_str = color_str if color_str.startswith("#") else f"#{color_str}" + self.font_color = ImageColor.getcolor(color_str, "RGB") 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.coordinates: - raise Failed(f"Overlay Error: overlay attribute's x_coordinate and y_coordinate are required when using text") - 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)})" - 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 = font - if "font_size" in self.data and self.data["font_size"] is not None: - font_size = check_num(self.data["font_size"]) - if font_size is None or font_size < 1: - logger.error(f"Overlay Error: overlay font_size: {self.data['font_size']} invalid must be a greater than 0") - else: - self.font_size = font_size - if "font_color" in self.data and self.data["font_color"]: - try: - color_str = self.data["font_color"] - color_str = color_str if color_str.startswith("#") else f"#{color_str}" - self.font_color = ImageColor.getcolor(color_str, "RGB") - except ValueError: - logger.error(f"Overlay Error: overlay color: {self.data['color']} invalid") - else: - if "|" in self.name: - raise Failed(f"Overlay Error: Overlay Name: {self.name} cannot contain '|'") - 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}") + logger.error(f"Overlay Error: overlay color: {self.data['color']} invalid") else: - self.name = str(self.data) - logger.warning(f"Overlay Warning: No overlay attribute using mapping name {self.data} as the overlay name") + if "|" in self.name: + raise Failed(f"Overlay Error: Overlay Name: {self.name} cannot contain '|'") + 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.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.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") def get_overlay_compare(self): output = self.name if self.group: output += f"{self.group}{self.weight}" if self.coordinates: - output += str(self.coordinates) - if self.font: - output += f"{self.font}{self.font_size}" + output += f"{self.coordinates}{self.x_align}{self.y_align}" + if self.font_name: + output += f"{self.font_name}{self.font_size}" if self.font_color: output += str(self.font_color) return output + + def get_coordinates(self, image_width, image_length, text=None): + if text: + _, _, width, height = ImageDraw.Draw(Image.new("RGB", (0, 0))).textbbox((0, 0), text, font=self.font) + else: + width, height = self.image.size + x_cord, y_cord = self.coordinates + if str(x_cord).endswith("%"): + x_cord = image_width * 0.01 * int(x_cord[:-1]) + if str(y_cord).endswith("%"): + y_cord = image_length * 0.01 * int(y_cord[:-1]) + if self.x_align == "right": + x_cord = image_width - width - x_cord + elif self.x_align == "center": + x_cord = (image_width / 2) - (width / 2) + x_cord + if self.x_align == "bottom": + y_cord = image_length - height - y_cord + elif self.x_align == "center": + y_cord = (image_length / 2) - (height / 2) + y_cord + return x_cord, y_cord diff --git a/plex_meta_manager.py b/plex_meta_manager.py index 9fe0b041..51cb63df 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -444,7 +444,7 @@ def run_libraries(config): config.Cache.delete_list_ids(list_key) list_key = config.Cache.update_list_cache("library", library.mapping_name, expired, 1) config.Cache.update_list_ids(list_key, [(i.ratingKey, i.guid) for i in temp_items]) - if not library.is_other and not library.is_music: + if not library.is_music: logger.info("") logger.separator(f"Mapping {library.name} Library", space=False, border=False) logger.info("")