From 8e802cdeb71824b04be8247ce90363122e434c22 Mon Sep 17 00:00:00 2001 From: meisnate12 Date: Mon, 25 Apr 2022 16:18:04 -0400 Subject: [PATCH] [34] assets fix --- VERSION | 2 +- modules/builder.py | 11 +-- modules/config.py | 45 +++++++--- modules/library.py | 36 +------- modules/meta.py | 1 + modules/operations.py | 60 ++++++++++++- modules/overlays.py | 29 ++++--- modules/plex.py | 198 ++++++++++++++++++++---------------------- plex_meta_manager.py | 3 + 9 files changed, 207 insertions(+), 178 deletions(-) diff --git a/VERSION b/VERSION index 2ccf7683..2d7368c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.5-develop33 +1.16.5-develop34 diff --git a/modules/builder.py b/modules/builder.py index 90a61289..6de27f9a 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -2,7 +2,6 @@ import os, re, time from datetime import datetime, timedelta 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, ImageData, NotScheduled, NotScheduledRange -from PIL import Image from plexapi.audio import Artist, Album, Track from plexapi.exceptions import BadRequest, NotFound from plexapi.video import Movie, Show, Season, Episode @@ -464,7 +463,7 @@ class CollectionBuilder: logger.debug(f"Value: {data[methods['delete_not_scheduled']]}") self.details["delete_not_scheduled"] = util.parse(self.Type, "delete_not_scheduled", self.data, datatype="bool", methods=methods, default=False) - if "schedule" in methods and not self.config.requested_collections: + if "schedule" in methods and not self.config.requested_collections and not self.overlay: logger.debug("") logger.debug("Validating Method: schedule") if not self.data[methods["schedule"]]: @@ -473,7 +472,7 @@ class CollectionBuilder: logger.debug(f"Value: {self.data[methods['schedule']]}") err = None try: - util.schedule_check("schedule", self.data[methods['schedule']], self.current_time, self.config.run_hour) + util.schedule_check("schedule", self.data[methods["schedule"]], self.current_time, self.config.run_hour) except NotScheduledRange as e: err = e except NotScheduled as e: @@ -2607,11 +2606,7 @@ class CollectionBuilder: if self.details["name_mapping"]: name_mapping = self.details["name_mapping"] else: logger.error(f"{self.Type} Error: name_mapping attribute is blank") final_name, _ = util.validate_filename(name_mapping) - poster_image, background_image, asset_location = self.library.find_assets( - name="poster" if self.details["asset_folders"] else final_name, - folder_name=final_name if self.details["asset_folders"] else None, - prefix=f"{name_mapping}'s " - ) + poster_image, background_image, asset_location, _ = self.library.find_item_assets(name_mapping, asset_directory=self.asset_directory) if poster_image: self.posters["asset_directory"] = poster_image if background_image: diff --git a/modules/config.py b/modules/config.py index 3794e5e4..e48e61ea 100644 --- a/modules/config.py +++ b/modules/config.py @@ -26,7 +26,7 @@ from modules.tautulli import Tautulli from modules.tmdb import TMDb from modules.trakt import Trakt from modules.tvdb import TVDb -from modules.util import Failed, NotScheduled +from modules.util import Failed, NotScheduled, NotScheduledRange from modules.webhooks import Webhooks from retrying import retry from ruamel import yaml @@ -367,7 +367,7 @@ class ConfigFile: self.Webhooks = Webhooks(self, self.webhooks, notifiarr=self.NotifiarrFactory) try: self.Webhooks.start_time_hooks(self.start_time) - if self.version and (self.version[1] != self.latest_version[1] or (self.version[2] and self.version[2] < self.latest_version[2])): + if self.version and self.latest_version and (self.version[1] != self.latest_version[1] or (self.version[2] and self.version[2] < self.latest_version[2])): self.Webhooks.version_hooks(self.version, self.latest_version) except Failed as e: logger.stacktrace() @@ -751,16 +751,37 @@ class ConfigFile: params["overlay_path"] = [] params["remove_overlays"] = False if lib and "overlay_path" in lib: - if not lib["overlay_path"]: - raise Failed("Config Error: overlay_path attribute is blank") - files = util.load_files(lib["overlay_path"], "overlay_path") - if not files: - raise Failed("Config Error: No Paths Found for overlay_path") - for file in util.get_list(lib["overlay_path"], split=False): - if isinstance(file, dict) \ - and ("remove_overlays" in file and file["remove_overlays"] is True) \ - or ("revert_overlays" in file and file["revert_overlays"] is True): - params["remove_overlays"] = True + try: + if not lib["overlay_path"]: + raise Failed("Config Error: overlay_path attribute is blank") + files = util.load_files(lib["overlay_path"], "overlay_path") + if not files: + raise Failed("Config Error: No Paths Found for overlay_path") + for file in util.get_list(lib["overlay_path"], split=False): + if isinstance(file, dict): + if ("remove_overlays" in file and file["remove_overlays"] is True) \ + or ("revert_overlays" in file and file["revert_overlays"] is True): + params["remove_overlays"] = True + if "schedule" in file and file["schedule"]: + logger.debug(f"Value: {file['schedule']}") + err = None + try: + util.schedule_check("schedule", file["schedule"], current_time, self.run_hour) + except NotScheduledRange as e: + err = e + except NotScheduled as e: + if not self.ignore_schedules: + err = e + if err: + raise NotScheduled(f"{err}\n\nOverlays not scheduled to run") + except NotScheduled as e: + logger.error(e) + params["overlay_path"] = [] + params["remove_overlays"] = False + + + + params["overlay_path"] = files logger.info("") diff --git a/modules/library.py b/modules/library.py index 39891993..dcfb9ce3 100644 --- a/modules/library.py +++ b/modules/library.py @@ -139,7 +139,7 @@ class Library(ABC): except Failed as e: logger.error(e) - def upload_images(self, item, poster=None, background=None, overlay=None): + def upload_images(self, item, poster=None, background=None): image = None image_compare = None poster_uploaded = False @@ -160,40 +160,6 @@ class Library(ABC): logger.stacktrace() logger.error(f"Detail: {poster.attribute} failed to update {poster.message}") - if overlay is not None: - overlay_name, overlay_folder, overlay_image = overlay - self.reload(item) - item_labels = {item_tag.tag.lower(): item_tag.tag for item_tag in item.labels} - for item_label in item_labels: - if item_label.endswith(" overlay") and item_label != f"{overlay_name.lower()} overlay": - raise Failed(f"Overlay Error: Poster already has an existing Overlay: {item_labels[item_label]}") - if poster_uploaded or image is None or image != item.thumb or f"{overlay_name.lower()} overlay" not in item_labels: - if not item.posterUrl: - raise Failed(f"Overlay Error: No existing poster to Overlay for {item.title}") - response = self.config.get(item.posterUrl) - if response.status_code >= 400: - raise Failed(f"Overlay Error: Overlay Failed for {item.title}") - ext = "jpg" if response.headers["Content-Type"] == "image/jpegss" else "png" - temp_image = os.path.join(overlay_folder, f"temp.{ext}") - with open(temp_image, "wb") as handler: - handler.write(response.content) - shutil.copyfile(temp_image, os.path.join(overlay_folder, f"{item.ratingKey}.{ext}")) - while util.is_locked(temp_image): - time.sleep(1) - try: - new_poster = Image.open(temp_image).convert("RGBA") - new_poster = new_poster.resize(overlay_image.size, Image.ANTIALIAS) - new_poster.paste(overlay_image, (0, 0), overlay_image) - new_poster.save(temp_image) - self.upload_poster(item, temp_image) - self.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"]) - self.reload(item, force=True) - poster_uploaded = True - logger.info(f"Detail: Overlay: {overlay_name} applied to {item.title}") - except (OSError, BadRequest) as e: - logger.stacktrace() - raise Failed(f"Overlay Error: {e}") - background_uploaded = False if background is not None: try: diff --git a/modules/meta.py b/modules/meta.py index f92b2257..80aa53ff 100644 --- a/modules/meta.py +++ b/modules/meta.py @@ -320,6 +320,7 @@ 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) diff --git a/modules/operations.py b/modules/operations.py index ae079a38..116db4df 100644 --- a/modules/operations.py +++ b/modules/operations.py @@ -1,6 +1,8 @@ import os, re from modules import util from modules.util import Failed +from plexapi.audio import Artist +from plexapi.video import Show from ruamel import yaml logger = util.logger @@ -76,7 +78,53 @@ class Operations: current_labels = [la.tag for la in item.labels] if self.library.assets_for_all or self.library.mass_imdb_parental_labels else [] if self.library.assets_for_all and "Overlay" not in current_labels: - self.library.update_asset(item) + poster, background, item_dir, name = self.library.find_item_assets(item) + + if item_dir: + if poster or background: + self.library.upload_images(item, poster=poster, background=background) + + if isinstance(item, Show): + missing_seasons = "" + missing_episodes = "" + found_season = False + found_episode = False + for season in self.library.query(item.seasons): + season_poster, season_background, _, _ = self.library.find_item_assets(season, item_asset_directory=item_dir) + if season_poster: + found_season = True + elif self.library.show_missing_season_assets and season.seasonNumber > 0: + missing_seasons += f"\nMissing Season {season.seasonNumber} Poster" + if season_poster or season_background: + self.library.upload_images(season, poster=season_poster, background=season_background) + for episode in self.library.query(season.episodes): + if episode.seasonEpisode: + episode_poster, episode_background, _, _ = self.library.find_item_assets(episode, item_asset_directory=item_dir) + if episode_poster or episode_background: + found_episode = True + self.library.upload_images(episode, poster=episode_poster, background=episode_background) + elif self.library.show_missing_episode_assets: + missing_episodes += f"\nMissing {episode.seasonEpisode.upper()} Title Card" + if (found_season and missing_seasons) or (found_episode and missing_episodes): + logger.info(f"Missing Posters for {item.title}{missing_seasons}{missing_episodes}") + if isinstance(item, Artist): + missing_assets = "" + found_album = False + for album in self.library.query(item.albums): + album_poster, album_background, _, _ = self.library.find_item_assets(album, item_asset_directory=item_dir) + if album_poster or album_background: + found_album = True + elif self.library.show_missing_season_assets: + missing_assets += f"\nMissing Album {album.title} Poster" + if album_poster or album_background: + self.library.upload_images(album, poster=album_poster, background=album_background) + if self.library.show_missing_season_assets and found_album and missing_assets: + logger.info(f"Missing Album Posters for {item.title}{missing_assets}") + + elif self.library.asset_folders: + logger.warning(f"Asset Warning: No asset folder found called '{name}'") + elif not poster and not background and self.library.show_missing_assets: + logger.warning(f"Asset Warning: No poster or background found in the assets folder '{item_dir}'") tmdb_id, tvdb_id, imdb_id = self.library.get_ids(item) @@ -381,7 +429,15 @@ class Operations: logger.separator(f"Unmanaged Collection Assets Check for {self.library.name} Library", space=False, border=False) logger.info("") for col in unmanaged_collections: - self.library.update_asset(col) + poster, background, item_dir, name = self.library.find_item_assets(col) + if item_dir: + if poster or background: + self.library.upload_images(col, poster=poster, background=background) + if self.library.asset_folders and item_dir is None: + logger.warning(f"Asset Warning: No asset folder found called '{name}'") + elif not poster and not background and self.library.show_missing_assets: + logger.warning(f"Asset Warning: No poster or background found in an assets folder for '{name}'") + if self.library.mass_collection_mode: logger.info("") logger.separator(f"Unmanaged Mass Collection Mode for {self.library.name} Library", space=False, border=False) diff --git a/modules/overlays.py b/modules/overlays.py index 433c5c09..f658da9e 100644 --- a/modules/overlays.py +++ b/modules/overlays.py @@ -2,6 +2,7 @@ import os, re, time from modules import util from modules.builder import CollectionBuilder from modules.util import Failed +from plexapi.audio import Album from plexapi.exceptions import BadRequest from plexapi.video import Movie, Show, Season, Episode from PIL import Image, ImageFilter @@ -63,13 +64,22 @@ class Overlays: logger.info("") logger.separator(f"Applying Overlays for the {self.library.name} Library") logger.info("") - for i, (over_key, (item, over_names)) in enumerate(sorted(key_to_overlays.items(), key=lambda io: io[1][0].titleSort), 1): + def get_item_sort_title(item_to_sort): + if isinstance(item_to_sort, Album): + return f"{item_to_sort.titleSort} Album {item_to_sort.title}" + elif isinstance(item_to_sort, Season): + return f"{item_to_sort.titleSort} Season {item_to_sort.seasonNumber}" + elif isinstance(item_to_sort, Episode): + return f"{item_to_sort.titleSort} {item_to_sort.seasonEpisode.upper()}" + else: + return item_to_sort.titleSort + for i, (over_key, (item, over_names)) in enumerate(sorted(key_to_overlays.items(), key=lambda io: get_item_sort_title(io[1][0])), 1): try: logger.ghost(f"Overlaying: {i}/{len(key_to_overlays)} {item.title}") image_compare = None overlay_compare = None if self.config.Cache: - image, image_compare, _ = self.config.Cache.query_image_map(item.ratingKey, f"{self.library.image_table_name}_overlays") + image, image_compare, overlay_compare = self.config.Cache.query_image_map(item.ratingKey, f"{self.library.image_table_name}_overlays") overlay_compare = [] if overlay_compare is None else util.get_list(overlay_compare) has_overlay = any([item_tag.tag.lower() == "overlay" for item_tag in item.labels]) @@ -83,8 +93,7 @@ class Overlays: for over_name in over_names: if over_name not in overlay_compare or properties[over_name]["updated"]: overlay_change = True - - poster, _, item_dir = self.find_asset(item) + poster, _, _, _ = self.library.find_item_assets(item) has_original = None changed_image = False @@ -145,7 +154,7 @@ class Overlays: logger.stacktrace() raise Failed(f"Overlay Error: {e}") else: - logger.error(f"Overlay Not Needed for {item.title}") + logger.error(f"Overlay Update Not Needed for {item.title}") if self.config.Cache and poster_compare: self.config.Cache.update_image_map(item.ratingKey, self.library.image_table_name, item.thumb, @@ -249,14 +258,6 @@ class Overlays: key_to_overlays[over_key][1].remove(v) return key_to_overlays, properties - def find_asset(self, item): - clean_asset_name, _ = util.validate_filename(item.title) - return self.library.find_assets( - name="poster" if self.library.asset_folders else clean_asset_name, - folder_name=clean_asset_name if self.library.asset_folders else None, - prefix=f"{item.title}'s " - ) - def find_poster_url(self, item): if isinstance(item, Movie): if item.ratingKey in self.library.movie_rating_key_map: @@ -276,7 +277,7 @@ class Overlays: return items if not ignore else [o for o in items if o.ratingKey not in ignore] def remove_overlay(self, item, label, locations): - poster, _, item_dir = self.find_asset(item) + poster, _, _, _ = self.library.find_item_assets(item) is_url = False original = None if poster: diff --git a/modules/plex.py b/modules/plex.py index 3636caa1..d76b8151 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -1,5 +1,8 @@ import os, plexapi, requests from datetime import datetime + +from plexapi.base import PlexObject + from modules import builder, util from modules.library import Library from modules.util import Failed, ImageData @@ -809,118 +812,101 @@ class Plex(Library): logger.info(final) return final - def update_asset(self, item, folders=None, create=None, asset_directory=None): - if isinstance(item, (Movie, Artist, Show)): - starting = item.show() if isinstance(item, (Episode, Season)) else item - path_test = str(starting.locations[0]) - if not os.path.dirname(path_test): - path_test = path_test.replace("\\", "/") - name = os.path.basename(os.path.dirname(path_test) if isinstance(starting, Movie) else path_test) - elif isinstance(item, (Collection, Playlist)): - name, _ = util.validate_filename(item.title) - else: - return None, None, None - if folders is None: - folders = self.asset_folders - if create is None: - create = self.create_asset_folders + def find_item_assets(self, item, item_asset_directory=None, asset_directory=None): + poster = None + background = None + item_dir = None + folder_name = None + if asset_directory is None: asset_directory = self.asset_directory - poster, background, item_dir = self.find_assets( - name="poster" if folders else name, - folder_name=name if folders else None, - prefix=f"{item.title}'s " - ) - 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 + is_top_level = isinstance(item, (Movie, Artist, Show, Collection, Playlist)) + if isinstance(item, Album): + prefix = f"{item.title} Album {item.title}'s " + file_name = item.title + elif isinstance(item, Season): + prefix = f"{item.title} Season {item.seasonNumber}'s " + file_name = f"Season{'0' if item.seasonNumber < 10 else ''}{item.seasonNumber}" + elif isinstance(item, Episode): + prefix = f"{item.title} {item.seasonEpisode.upper()}'s " + file_name = item.seasonEpisode.upper() + else: + prefix = f"{item.title if is_top_level else item}'s " + file_name = "poster" + + if not item_asset_directory: + if isinstance(item, (Movie, Artist, Album, Show, Episode, Season)): + starting = item.show() if isinstance(item, (Episode, Season)) else item + path_test = str(starting.locations[0]) + if not os.path.dirname(path_test): + path_test = path_test.replace("\\", "/") + folder_name = os.path.basename(os.path.dirname(path_test) if isinstance(starting, Movie) else path_test) + elif isinstance(item, (Collection, Playlist)): + folder_name, _ = util.validate_filename(item.title) + else: + folder_name, _ = util.validate_filename(item) - if poster or background: - self.upload_images(item, poster=poster, background=background) + if not self.asset_folders: + file_name = folder_name if file_name == "poster" else f"{folder_name}_{file_name}" - if isinstance(item, Show): - missing_seasons = "" - missing_episodes = "" - found_season = False - found_episode = False - for season in self.query(item.seasons): - season_name = f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}" - season_poster, season_background, _ = self.find_assets( - name=season_name, - folder_name=name, - item_directory=item_dir, - prefix=f"{item.title} Season {season.seasonNumber}'s " - ) - if season_poster: - found_season = True - elif self.show_missing_season_assets and season.seasonNumber > 0: - missing_seasons += f"\nMissing Season {season.seasonNumber} Poster" - if season_poster or season_background: - self.upload_images(season, poster=season_poster, background=season_background) - for episode in self.query(season.episodes): - if episode.seasonEpisode: - episode_poster, episode_background, _ = self.find_assets( - name=episode.seasonEpisode.upper(), - folder_name=name, - item_directory=item_dir, - prefix=f"{item.title} {episode.seasonEpisode.upper()}'s " - ) - if episode_poster or episode_background: - found_episode = True - self.upload_images(episode, poster=episode_poster, background=episode_background) - elif self.show_missing_episode_assets: - missing_episodes += f"\nMissing {episode.seasonEpisode.upper()} Title Card" - - if (found_season and missing_seasons) or (found_episode and missing_episodes): - output = f"Missing Posters for {item.title}" - if found_season: - output += missing_seasons - if found_episode: - output += missing_episodes - logger.info(output) - if isinstance(item, Artist): - missing_assets = "" - found_album = False - for album in self.query(item.albums): - album_poster, album_background, _ = self.find_assets( - name=album.title, - folder_name=name, - item_directory=item_dir, - prefix=f"{item.title} Album {album.title}'s " - ) - if album_poster: - found_album = True + for ad in asset_directory: + if self.asset_folders: + if os.path.isdir(os.path.join(ad, folder_name)): + item_asset_directory = os.path.join(ad, folder_name) + else: + 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, folder_name)) + if len(matches) > 0: + item_asset_directory = os.path.abspath(matches[0]) else: - missing_assets += f"\nMissing Album {album.title} Poster" - if album_poster or album_background: - self.upload_images(album, poster=album_poster, background=album_background) - if self.show_missing_season_assets and found_album and missing_assets: - logger.info(f"Missing Album Posters for {item.title}{missing_assets}") - - if create and folders and item_dir is None: - filename, _ = util.validate_filename(name) - item_dir = os.path.join(asset_directory[0], filename) - os.makedirs(item_dir, exist_ok=True) - logger.info(f"Asset Directory Created: {item_dir}") - elif folders and item_dir is None: - logger.warning(f"Asset Warning: No asset folder found called '{name}'") - elif not poster and not background and self.show_missing_assets: - logger.warning(f"Asset Warning: No poster or background found in an assets folder for '{name}'") - return poster, background, item_dir + matches = util.glob_filter(os.path.join(ad, f"{file_name}.*")) + if len(matches) > 0: + item_asset_directory = ad + if item_asset_directory: + break + if not item_asset_directory: + if self.create_asset_folders and self.asset_folders: + item_asset_directory = os.path.join(asset_directory[0], folder_name) + os.makedirs(item_asset_directory, exist_ok=True) + logger.info(f"Asset Directory Created: {item_asset_directory}") + raise Failed(f"Asset Error: Unable to find asset {'folder' if self.asset_folders else 'file'}: {folder_name if self.asset_folders else file_name}") + + poster_filter = os.path.join(item_asset_directory, f"{file_name}.*") + background_filter = os.path.join(item_asset_directory, "background.*" if file_name == "poster" else f"{file_name}_background.*") + + poster_matches = util.glob_filter(poster_filter) + if len(poster_matches) > 0: + poster = ImageData("asset_directory", os.path.abspath(poster_matches[0]), prefix=prefix, 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=prefix, is_poster=False, is_url=False) + + if is_top_level and self.asset_folders and self.dimensional_asset_rename and (not poster or not background): + for file in util.glob_filter(os.path.join(item_asset_directory, "*.*")): + if file.lower().endswith((".jpg", ".png", ".jpeg")): + try: + 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 + except OSError: + logger.error(f"Asset Error: Failed to open image: {file}") + + return poster, background, item_dir, folder_name def get_ids(self, item): tmdb_id = None diff --git a/plex_meta_manager.py b/plex_meta_manager.py index 81cfdd12..2af4cac2 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -86,6 +86,9 @@ screen_width = get_arg("PMM_WIDTH", args.width, arg_int=True) debug = get_arg("PMM_DEBUG", args.debug, arg_bool=True) trace = get_arg("PMM_TRACE", args.trace, arg_bool=True) +if collections or metadata_files: + collection_only = True + if screen_width < 90 or screen_width > 300: print(f"Argument Error: width argument invalid: {screen_width} must be an integer between 90 and 300 using the default 100") screen_width = 100