"""Image processing and management utilities for Mail and Packages.""" import datetime import hashlib import logging import os import subprocess # nosec import uuid from pathlib import Path from shutil import copyfile, which from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from PIL import Image, ImageOps from custom_components.mail_and_packages.const import ( CONF_AMAZON_CUSTOM_IMG, CONF_AMAZON_CUSTOM_IMG_FILE, CONF_CUSTOM_IMG, CONF_CUSTOM_IMG_FILE, CONF_FEDEX_CUSTOM_IMG, CONF_FEDEX_CUSTOM_IMG_FILE, CONF_STORAGE, CONF_UPS_CUSTOM_IMG, CONF_UPS_CUSTOM_IMG_FILE, CONF_WALMART_CUSTOM_IMG, CONF_WALMART_CUSTOM_IMG_FILE, DEFAULT_AMAZON_CUSTOM_IMG_FILE, DEFAULT_CUSTOM_IMG_FILE, DEFAULT_FEDEX_CUSTOM_IMG_FILE, DEFAULT_UPS_CUSTOM_IMG_FILE, DEFAULT_WALMART_CUSTOM_IMG_FILE, OVERLAY, ) from .date import get_formatted_date _LOGGER = logging.getLogger(__name__) async def _check_ffmpeg() -> bool: """Check if ffmpeg is installed. Returns boolean """ return which("ffmpeg") def default_image_path( hass: HomeAssistant, # pylint: disable=unused-argument config_entry: ConfigEntry, ) -> str: """Return value of the default image path. Returns the default path based on logic """ storage = None try: storage = config_entry.get(CONF_STORAGE) except AttributeError: storage = config_entry.data[CONF_STORAGE] if storage: return storage return "custom_components/mail_and_packages/images/" def hash_file(filename: str) -> str: """Return the SHA-1 hash of the file passed into it. Returns hash of file as string """ # make a hash object the_hash = hashlib.sha1() # nosec # open file for reading in binary mode with Path(filename).open("rb") as file: # loop till the end of the file chunk = 0 while chunk != b"": # read only 1024 bytes at a time chunk = file.read(1024) the_hash.update(chunk) # return the hex representation of digest return the_hash.hexdigest() def _delete_file(file_path: Path, context: str) -> bool: """Delete a single file with error handling.""" try: if file_path.exists(): file_path.unlink() _LOGGER.debug("%s - Successfully removed: %s", context, file_path) return True _LOGGER.debug("%s - File does not exist: %s", context, file_path) except OSError as err: _LOGGER.error("Error attempting to remove image in %s: %s", context, err) return False def _cleanup_directory(path: str) -> None: """Handle directory-wide cleanup.""" if not Path(path).is_dir(): _LOGGER.debug("cleanup_images - Directory does not exist: %s", path) return try: files_before = [x.name for x in Path(path).iterdir()] _LOGGER.debug( "cleanup_images - Files in directory BEFORE cleanup: %s", files_before, ) for file in files_before: if file.endswith((".gif", ".mp4", ".jpg", ".png")): full_path = Path(path) / file _delete_file(full_path, "cleanup_images") files_after = [] if Path(path).is_dir(): files_after = [f.name for f in Path(path).iterdir()] _LOGGER.debug( "cleanup_images - Files in directory AFTER cleanup: %s", files_after, ) except FileNotFoundError: _LOGGER.debug("cleanup_images - Directory removed during cleanup: %s", path) except OSError as err: _LOGGER.error("Error listing directory for cleanup: %s", err) def cleanup_images(path: str, image: str | None = None) -> None: """Clean up image storage directory. Only suppose to delete .gif, .mp4, and .jpg files """ _LOGGER.debug("=== cleanup_images CALLED === path: %s, image: %s", path, image) if isinstance(path, tuple): path, image = path if image is not None: full_path = Path(path) / image _delete_file(full_path, "cleanup_images") return _cleanup_directory(path) def copy_overlays(path: str) -> None: """Copy overlay images to image output path.""" overlays = OVERLAY check = all(item.name in overlays for item in Path(path).iterdir()) # Copy files if they are missing if not check: for file in overlays: _LOGGER.debug("Copying file to: %s", path + file) copyfile( Path(__file__).parent.parent / file, path + file, ) def resize_images(images: list, width: int, height: int) -> list: """Resize images.""" all_images = [] for image_path in images: try: img_path = Path(image_path) with img_path.open("rb") as fd_img: img = Image.open(fd_img) img.thumbnail((width, height), resample=Image.Resampling.LANCZOS) img = ImageOps.pad( img, (width, height), method=Image.Resampling.LANCZOS, ) img = img.crop((0, 0, width, height)) new_image_path = img_path.parent / f"{img_path.stem}_resized.gif" img.save(new_image_path, "GIF") all_images.append(str(new_image_path)) except (OSError, ValueError) as err: _LOGGER.error("Error processing image %s: %s", image_path, err) continue return all_images def random_filename(ext: str = ".jpg") -> str: """Generate random filename.""" return f"{uuid.uuid4()!s}{ext}" def io_save_file(path: str | Path, data: bytes) -> None: """Write bytes to a file synchronously (for use in executor).""" with Path(path).open("wb") as the_file: the_file.write(data) def _generate_mp4(path: str, image_file: str) -> None: """Generate mp4 from gif. use a subprocess so we don't lock up the thread command: ffmpeg -f gif -i infile.gif outfile.mp4 """ base_path = Path(path) gif_image = base_path / image_file mp4_file = base_path / image_file.replace(".gif", ".mp4") filecheck = mp4_file.is_file() _LOGGER.debug("Generating mp4: %s", mp4_file) if filecheck: # Construct path string with trailing slash to ensure cleanup_images concatenates correctly cleanup_images(str(mp4_file.parent) + "/", mp4_file.name) _LOGGER.debug("Removing old mp4: %s", mp4_file) try: cmd = [ "ffmpeg", "-y", "-i", str(gif_image), "-pix_fmt", "yuv420p", str(mp4_file), ] subprocess.run( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, ) except subprocess.CalledProcessError as err: _LOGGER.error("FFmpeg failed to generate MP4: %s", err) def generate_grid_img(path: str, image_file: str, count: int) -> None: """Generate png grid from gif. use a subprocess so we don't lock up the thread command: ffmpeg -f gif -i infile.gif outfile.mp4 """ count = max(count, 1) if count % 2 == 0: length = int(count / 2) else: length = int(count / 2) + count % 2 gif_image = Path(path + image_file) png_file = image_file.replace(".gif", "_grid.png") png_image = Path(path).joinpath(png_file) filecheck = png_image.is_file() _LOGGER.debug("Generating png image grid %s from %s", png_image, gif_image) if filecheck: # cleanup_images expects a tuple or string path, so we use string parts here # or we could update cleanup_images to handle Path objects natively later. cleanup_images(str(png_image.parent) + "/", png_image.name) _LOGGER.debug("Removing old png grid: %s", png_image) try: subprocess.run( [ "ffmpeg", "-i", str(gif_image), "-r", "0.20", "-filter_complex", f"tile=2x{length}:padding=10:color=black", str(png_image), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, ) except subprocess.CalledProcessError as err: _LOGGER.error("FFmpeg failed to generate grid image: %s", err) def generate_delivery_gif( delivery_images: list, gif_path: str, duration: int = 3000, ) -> bool: """Generate an animated GIF from delivery images. Args: delivery_images: List of image file paths gif_path: Path where the GIF should be saved duration: Duration for each frame in milliseconds (default: 3000) Returns: bool: True if GIF was created successfully, False otherwise """ try: # Open all images corrected_images = [] for img_path in delivery_images: img = Image.open(img_path) img = ImageOps.exif_transpose(img) # auto-rotates according to EXIF corrected_images.append(img) # Create animated GIF corrected_images[0].save( gif_path, format="GIF", append_images=corrected_images[1:], save_all=True, duration=duration, loop=0, # Infinite loop ) _LOGGER.debug( "Generated animated GIF with %d delivery images at %s", len(delivery_images), gif_path, ) except (OSError, ValueError, Image.UnidentifiedImageError) as e: _LOGGER.error("Error creating animated GIF: %s", e) return False else: return True def _get_courier_info( hass: HomeAssistant, config: ConfigEntry, amazon: bool, ups: bool, walmart: bool, fedex: bool, ) -> tuple[str, str]: """Determine the path and default image for the active courier.""" configs = [ ( amazon, CONF_AMAZON_CUSTOM_IMG, CONF_AMAZON_CUSTOM_IMG_FILE, DEFAULT_AMAZON_CUSTOM_IMG_FILE, "no_deliveries_amazon.jpg", "amazon", ), ( ups, CONF_UPS_CUSTOM_IMG, CONF_UPS_CUSTOM_IMG_FILE, DEFAULT_UPS_CUSTOM_IMG_FILE, "no_deliveries_ups.jpg", "ups", ), ( walmart, CONF_WALMART_CUSTOM_IMG, CONF_WALMART_CUSTOM_IMG_FILE, DEFAULT_WALMART_CUSTOM_IMG_FILE, "no_deliveries_walmart.jpg", "walmart", ), ( fedex, CONF_FEDEX_CUSTOM_IMG, CONF_FEDEX_CUSTOM_IMG_FILE, DEFAULT_FEDEX_CUSTOM_IMG_FILE, "no_deliveries_fedex.jpg", "fedex", ), ] base_path = hass.config.path(default_image_path(hass, config)) for ( active, img_conf, file_conf, default_file_conf, local_default, sub_dir, ) in configs: if active: _LOGGER.debug("Processing %s image file name", sub_dir.title()) if config.get(img_conf): mail_none = config.get(file_conf) or default_file_conf _LOGGER.debug("Using custom %s image: %s", sub_dir.title(), mail_none) else: mail_none = str(Path(__file__).parent.parent / local_default) _LOGGER.debug("Using default %s image: %s", sub_dir.title(), mail_none) return f"{base_path}{sub_dir}", mail_none # Standard mail case path = base_path.rstrip("/") if config.get(CONF_CUSTOM_IMG): mail_none = config.get(CONF_CUSTOM_IMG_FILE) or DEFAULT_CUSTOM_IMG_FILE else: mail_none = str(Path(__file__).parent.parent / "mail_none.gif") return path, mail_none def _get_image_name_from_directory( path: str, mail_none: str, sha1: str, ext: str ) -> str: """Check existing images and return a filename.""" image_name = os.path.split(mail_none)[1] today = get_formatted_date() try: for file_path in Path(path).iterdir(): if not file_path.is_file(): continue filename = file_path.name if filename == "generic_deliveries.gif": continue is_image_file = filename.endswith(".gif") or ( filename.endswith(".jpg") and ext == ".jpg" ) if is_image_file: try: created = datetime.datetime.fromtimestamp( file_path.stat().st_ctime, ).strftime("%d-%b-%Y") # If it's the correct hash OR created today, we can reuse it if sha1 == hash_file(str(file_path)) or today == created: image_name = filename break image_name = f"{uuid.uuid4()!s}{ext}" except OSError as err: _LOGGER.error("Problem accessing file %s: %s", filename, err) except OSError as err: _LOGGER.error("Error accessing directory %s: %s", path, err) if image_name in mail_none: image_name = f"{uuid.uuid4()!s}{ext}" _LOGGER.debug("=== image_file_name GENERATED NEW UUID: %s ===", image_name) else: _LOGGER.debug("=== image_file_name USING EXISTING: %s ===", image_name) return image_name def image_file_name( hass: HomeAssistant, config: ConfigEntry, amazon: bool = False, ups: bool = False, walmart: bool = False, fedex: bool = False, ) -> str: """Determine if filename is to be changed or not. Returns filename """ _LOGGER.debug( "=== image_file_name CALLED === - amazon: %s, ups: %s, walmart: %s, fedex: %s", amazon, ups, walmart, fedex, ) path, mail_none = _get_courier_info(hass, config, amazon, ups, walmart, fedex) image_name = os.path.split(mail_none)[1] # Path check try: Path(path).mkdir(parents=True, exist_ok=True) except OSError as err: _LOGGER.error("Error creating directory: %s", err) return image_name # SHA1 file hash check try: sha1 = hash_file(mail_none) except OSError as err: _LOGGER.error("Problem accessing file: %s, error returned: %s", mail_none, err) return image_name ext = ".jpg" if ups or walmart or fedex else ".gif" return _get_image_name_from_directory(path, mail_none, sha1, ext)