import concurrent.futures import logging import os import shutil from hashlib import sha256 from io import StringIO from threading import Thread from typing import List import httpx from rich.console import Console from rich.panel import Panel from rich.text import Text from ...core.config import AppConfig from ...core.constants import APP_CACHE_DIR, APP_DIR, PLATFORM from ...libs.api.types import MediaItem from . import ansi, formatters logger = logging.getLogger(__name__) # --- Constants for Paths --- PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews" IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images" INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info" FZF_SCRIPTS_DIR = APP_DIR / "libs" / "selectors" / "fzf" / "scripts" PREVIEW_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "preview.sh" INFO_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "info.sh" def _get_cache_hash(text: str) -> str: """Generates a consistent SHA256 hash for a given string to use as a filename.""" return sha256(text.encode("utf-8")).hexdigest() def _save_image_from_url(url: str, hash_id: str): """Downloads an image using httpx and saves it to the cache.""" temp_image_path = IMAGES_CACHE_DIR / f"{hash_id}.png.tmp" image_path = IMAGES_CACHE_DIR / f"{hash_id}.png" try: with httpx.stream("GET", url, follow_redirects=True, timeout=20) as response: response.raise_for_status() with temp_image_path.open("wb") as f: for chunk in response.iter_bytes(): f.write(chunk) temp_image_path.rename(image_path) except Exception as e: logger.error(f"Failed to download image {url}: {e}") if temp_image_path.exists(): temp_image_path.unlink() def _save_info_text(info_text: str, hash_id: str): """Saves pre-formatted text to the info cache.""" try: info_path = INFO_CACHE_DIR / hash_id info_path.write_text(info_text, encoding="utf-8") except IOError as e: logger.error(f"Failed to write info cache for {hash_id}: {e}") def _populate_info_template(item: MediaItem, config: AppConfig) -> str: """ Takes the info.sh template and injects formatted, shell-safe data. """ template = INFO_SCRIPT_TEMPLATE_PATH.read_text(encoding="utf-8") description = formatters.clean_html(item.description or "No description available.") HEADER_COLOR = config.fzf.preview_header_color.split(",") SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") # Escape all variables before injecting them into the script replacements = { "TITLE": formatters.shell_safe(item.title.english or item.title.romaji), "SCORE": formatters.shell_safe( formatters.format_score_stars_full(item.average_score) ), "STATUS": formatters.shell_safe(item.status), "FAVOURITES": formatters.shell_safe( formatters.format_number_with_commas(item.favourites) ), "GENRES": formatters.shell_safe(formatters.format_genres(item.genres)), "SYNOPSIS": formatters.shell_safe(description), # Color codes "C_TITLE": ansi.get_true_fg(HEADER_COLOR, bold=True), "C_KEY": ansi.get_true_fg(HEADER_COLOR, bold=True), "C_VALUE": ansi.get_true_fg(HEADER_COLOR, bold=True), "C_RULE": ansi.get_true_fg(SEPARATOR_COLOR, bold=True), "RESET": ansi.RESET, } for key, value in replacements.items(): template = template.replace(f"{{{key}}}", value) return template def _cache_worker(items: List[MediaItem], titles: List[str], config: AppConfig): """The background task that fetches and saves all necessary preview data.""" with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: for item, title_str in zip(items, titles): hash_id = _get_cache_hash(title_str) if config.general.preview in ("full", "image") and item.cover_image: if not (IMAGES_CACHE_DIR / f"{hash_id}.png").exists(): executor.submit( _save_image_from_url, item.cover_image.large, hash_id ) if config.general.preview in ("full", "text"): if not (INFO_CACHE_DIR / hash_id).exists(): info_text = _populate_info_template(item, config) executor.submit(_save_info_text, info_text, hash_id) # --- THIS IS THE MODIFIED FUNCTION --- def get_anime_preview( items: List[MediaItem], titles: List[str], config: AppConfig ) -> str: """ Starts a background task to cache preview data and returns the fzf preview command by formatting a shell script template. """ # Ensure cache directories exist on startup IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) # Start the non-blocking background Caching Thread(target=_cache_worker, args=(items, titles, config), daemon=True).start() # Read the shell script template from the file system. try: template = PREVIEW_SCRIPT_TEMPLATE_PATH.read_text(encoding="utf-8") except FileNotFoundError: logger.error( f"Preview script template not found at {PREVIEW_SCRIPT_TEMPLATE_PATH}" ) return "echo 'Error: Preview script template not found.'" # Prepare values to inject into the template path_sep = "\\" if PLATFORM == "win32" else "/" # Format the template with the dynamic values final_script = ( template.replace("{preview_mode}", config.general.preview) .replace("{image_cache_path}", str(IMAGES_CACHE_DIR)) .replace("{info_cache_path}", str(INFO_CACHE_DIR)) .replace("{path_sep}", path_sep) .replace("{image_renderer}", config.general.image_renderer) ) # ) # Return the command for fzf to execute. `sh -c` is used to run the script string. # The -- "{}" ensures that the selected item is passed as the first argument ($1) # to the script, even if it contains spaces or special characters. os.environ["SHELL"] = "bash" return final_script