From 9f5c895bf5b58a4c2ff0473d8ea59b26cfb73032 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 22:31:08 +0300 Subject: [PATCH 01/48] chore: temporarily relocate initial bash preview scripts to old folder --- .../assets/scripts/fzf/{ => old}/airing-schedule-info.template.sh | 0 .../scripts/fzf/{ => old}/airing-schedule-preview.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/character-info.template.sh | 0 .../assets/scripts/fzf/{ => old}/character-preview.template.sh | 0 .../assets/scripts/fzf/{ => old}/dynamic-preview.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/episode-info.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/info.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/preview.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/review-info.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/review-preview.template.sh | 0 viu_media/assets/scripts/fzf/{ => old}/search.template.sh | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename viu_media/assets/scripts/fzf/{ => old}/airing-schedule-info.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/airing-schedule-preview.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/character-info.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/character-preview.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/dynamic-preview.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/episode-info.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/info.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/preview.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/review-info.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/review-preview.template.sh (100%) rename viu_media/assets/scripts/fzf/{ => old}/search.template.sh (100%) diff --git a/viu_media/assets/scripts/fzf/airing-schedule-info.template.sh b/viu_media/assets/scripts/fzf/old/airing-schedule-info.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/airing-schedule-info.template.sh rename to viu_media/assets/scripts/fzf/old/airing-schedule-info.template.sh diff --git a/viu_media/assets/scripts/fzf/airing-schedule-preview.template.sh b/viu_media/assets/scripts/fzf/old/airing-schedule-preview.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/airing-schedule-preview.template.sh rename to viu_media/assets/scripts/fzf/old/airing-schedule-preview.template.sh diff --git a/viu_media/assets/scripts/fzf/character-info.template.sh b/viu_media/assets/scripts/fzf/old/character-info.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/character-info.template.sh rename to viu_media/assets/scripts/fzf/old/character-info.template.sh diff --git a/viu_media/assets/scripts/fzf/character-preview.template.sh b/viu_media/assets/scripts/fzf/old/character-preview.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/character-preview.template.sh rename to viu_media/assets/scripts/fzf/old/character-preview.template.sh diff --git a/viu_media/assets/scripts/fzf/dynamic-preview.template.sh b/viu_media/assets/scripts/fzf/old/dynamic-preview.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/dynamic-preview.template.sh rename to viu_media/assets/scripts/fzf/old/dynamic-preview.template.sh diff --git a/viu_media/assets/scripts/fzf/episode-info.template.sh b/viu_media/assets/scripts/fzf/old/episode-info.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/episode-info.template.sh rename to viu_media/assets/scripts/fzf/old/episode-info.template.sh diff --git a/viu_media/assets/scripts/fzf/info.template.sh b/viu_media/assets/scripts/fzf/old/info.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/info.template.sh rename to viu_media/assets/scripts/fzf/old/info.template.sh diff --git a/viu_media/assets/scripts/fzf/preview.template.sh b/viu_media/assets/scripts/fzf/old/preview.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/preview.template.sh rename to viu_media/assets/scripts/fzf/old/preview.template.sh diff --git a/viu_media/assets/scripts/fzf/review-info.template.sh b/viu_media/assets/scripts/fzf/old/review-info.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/review-info.template.sh rename to viu_media/assets/scripts/fzf/old/review-info.template.sh diff --git a/viu_media/assets/scripts/fzf/review-preview.template.sh b/viu_media/assets/scripts/fzf/old/review-preview.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/review-preview.template.sh rename to viu_media/assets/scripts/fzf/old/review-preview.template.sh diff --git a/viu_media/assets/scripts/fzf/search.template.sh b/viu_media/assets/scripts/fzf/old/search.template.sh similarity index 100% rename from viu_media/assets/scripts/fzf/search.template.sh rename to viu_media/assets/scripts/fzf/old/search.template.sh From 515660b0f65bfbe39d377ea8c64ccdfc99c7a8b6 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 22:32:51 +0300 Subject: [PATCH 02/48] feat: implement the main preview text logic in python --- .../scripts/fzf/airing_schedule_info.py | 0 .../assets/scripts/fzf/character_info.py | 0 viu_media/assets/scripts/fzf/episode_info.py | 0 viu_media/assets/scripts/fzf/info.py | 87 +++++++++++++++++++ viu_media/assets/scripts/fzf/preview.py | 46 ++++++++++ viu_media/assets/scripts/fzf/review_info.py | 0 viu_media/cli/utils/preview.py | 50 ++++------- viu_media/cli/utils/preview_workers.py | 38 ++++---- 8 files changed, 170 insertions(+), 51 deletions(-) create mode 100644 viu_media/assets/scripts/fzf/airing_schedule_info.py create mode 100644 viu_media/assets/scripts/fzf/character_info.py create mode 100644 viu_media/assets/scripts/fzf/episode_info.py create mode 100644 viu_media/assets/scripts/fzf/info.py create mode 100644 viu_media/assets/scripts/fzf/preview.py create mode 100644 viu_media/assets/scripts/fzf/review_info.py diff --git a/viu_media/assets/scripts/fzf/airing_schedule_info.py b/viu_media/assets/scripts/fzf/airing_schedule_info.py new file mode 100644 index 0000000..e69de29 diff --git a/viu_media/assets/scripts/fzf/character_info.py b/viu_media/assets/scripts/fzf/character_info.py new file mode 100644 index 0000000..e69de29 diff --git a/viu_media/assets/scripts/fzf/episode_info.py b/viu_media/assets/scripts/fzf/episode_info.py new file mode 100644 index 0000000..e69de29 diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/info.py new file mode 100644 index 0000000..5cb08ca --- /dev/null +++ b/viu_media/assets/scripts/fzf/info.py @@ -0,0 +1,87 @@ +import sys +from rich.console import Console +from rich.table import Table +from rich.rule import Rule +from rich.markdown import Markdown + +console = Console(force_terminal=True, color_system="truecolor") + +HEADER_COLOR = sys.argv[1] +SEPARATOR_COLOR = sys.argv[2] + +console.print("{TITLE}", justify="center") + +left = [ + ( + "Score", + "Favorites", + "Popularity", + "Status", + ), + ( + "Episodes", + "Next Episode", + "Duration", + ), + ( + "Genres", + "Format", + ), + ( + "List Status", + "Progress", + ), + ( + "Start Date", + "End Date", + ), + ( + "Studios", + "Synonymns", + "Tags", + ), +] +right = [ + ( + "{SCORE}", + "{FAVOURITES}", + "{POPULARITY}", + "{STATUS}", + ), + ( + "{EPISODES}", + "{NEXT_EPISODE}", + "{DURATION}", + ), + ( + "{GENRES}", + "{FORMAT}", + ), + ( + "{USER_STATUS}", + "{USER_PROGRESS}", + ), + ( + "{START_DATE}", + "{END_DATE}", + ), + ( + "{STUDIOS}", + "{SYNONYMNS}", + "{TAGS}", + ), +] + + +for L_grp, R_grp in zip(left, right): + table = Table.grid(expand=True) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="right", overflow="fold") + for L, R in zip(L_grp, R_grp): + table.add_row(f"[bold rgb({HEADER_COLOR})]{L}: [/]", f"{R}") + + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + console.print(table) + +console.print(Rule(title="Description", style=f"rgb({SEPARATOR_COLOR})")) +console.print(Markdown("""{SYNOPSIS}""")) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py new file mode 100644 index 0000000..6a2665e --- /dev/null +++ b/viu_media/assets/scripts/fzf/preview.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# +# FZF Preview Script Template +# +# This script is a template. The placeholders in curly braces, like {NAME} +# are dynamically filled by python using .replace() + +from pathlib import Path +from hashlib import sha256 +import subprocess +import sys +from rich.console import Console +from rich.rule import Rule + +# dynamically filled variables +PREVIEW_MODE = "{PREVIEW_MODE}" +IMAGE_CACHE_DIR = Path("{IMAGE_CACHE_DIR}") +INFO_CACHE_DIR = Path("{INFO_CACHE_DIR}") +IMAGE_RENDERER = "{IMAGE_RENDERER}" +HEADER_COLOR = "{HEADER_COLOR}" +SEPARATOR_COLOR = "{SEPARATOR_COLOR}" +PREFIX = "{PREFIX}" +SCALE_UP = "{SCALE_UP}" == "True" + +# fzf passes the title with quotes, so we need to trim them +TITLE = sys.argv[1] + +hash = f"{PREFIX}-{sha256(TITLE.encode('utf-8')).hexdigest()}" + +console = Console(force_terminal=True, color_system="truecolor") +if PREVIEW_MODE == "image" or PREVIEW_MODE == "full": + preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png" + if preview_image_path.exists(): + print("rendering image") + else: + print("🖼️ Loading image...") + +console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) +if PREVIEW_MODE == "info" or PREVIEW_MODE == "full": + preview_info_path = INFO_CACHE_DIR / f"{hash}.py" + if preview_info_path.exists(): + subprocess.run( + [sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR] + ) + else: + console.print("📝 Loading details...") diff --git a/viu_media/assets/scripts/fzf/review_info.py b/viu_media/assets/scripts/fzf/review_info.py new file mode 100644 index 0000000..e69de29 diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 57bb044..226ef00 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -1,7 +1,7 @@ import logging -import os import re from hashlib import sha256 +import sys from typing import Dict, List, Optional import httpx @@ -117,7 +117,7 @@ def _get_episode_image(episode: str, media_item: MediaItem) -> str: logger = logging.getLogger(__name__) -os.environ["SHELL"] = "bash" +# os.environ["SHELL"] = sys.executable PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews" IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images" @@ -127,21 +127,11 @@ CHARACTERS_CACHE_DIR = PREVIEWS_CACHE_DIR / "characters" AIRING_SCHEDULE_CACHE_DIR = PREVIEWS_CACHE_DIR / "airing_schedule" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" -TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.template.sh").read_text( - encoding="utf-8" -) -TEMPLATE_REVIEW_PREVIEW_SCRIPT = ( - FZF_SCRIPTS_DIR / "review-preview.template.sh" -).read_text(encoding="utf-8") -TEMPLATE_CHARACTER_PREVIEW_SCRIPT = ( - FZF_SCRIPTS_DIR / "character-preview.template.sh" -).read_text(encoding="utf-8") -TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = ( - FZF_SCRIPTS_DIR / "airing-schedule-preview.template.sh" -).read_text(encoding="utf-8") -DYNAMIC_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "dynamic-preview.template.sh").read_text( - encoding="utf-8" -) +TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8") +TEMPLATE_REVIEW_PREVIEW_SCRIPT = "" +TEMPLATE_CHARACTER_PREVIEW_SCRIPT = "" +TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = "" +DYNAMIC_PREVIEW_SCRIPT = "" EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*") @@ -300,30 +290,28 @@ def get_anime_preview( logger.error(f"Failed to start background caching: {e}") # Continue with script generation even if caching fails - # Prepare values to inject into the template - path_sep = "\\" if PLATFORM == "win32" else "/" - # Format the template with the dynamic values replacements = { "PREVIEW_MODE": config.general.preview, - "IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR), - "INFO_CACHE_PATH": str(INFO_CACHE_DIR), - "PATH_SEP": path_sep, + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), + "INFO_CACHE_DIR": str(INFO_CACHE_DIR), "IMAGE_RENDERER": config.general.image_renderer, # 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, - "PREFIX": "", - "SCALE_UP": " --scale-up" if config.general.preview_scale_up else "", + "HEADER_COLOR": ",".join(HEADER_COLOR), + "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), + "PREFIX": "search-results", + "SCALE_UP": str(config.general.preview_scale_up), } for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - return preview_script + (APP_CACHE_DIR / "preview_script.py").write_text(preview_script, encoding="utf-8") + + preview_script_final = ( + f"{sys.executable} {APP_CACHE_DIR / 'preview_script.py'} {{}}" + ) + return preview_script_final def get_episode_preview( diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 7967ab5..46a6935 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -31,20 +31,18 @@ logger = logging.getLogger(__name__) FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" -TEMPLATE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "info.template.sh").read_text( +TEMPLATE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "info.py").read_text(encoding="utf-8") +TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode_info.py").read_text( encoding="utf-8" ) -TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode-info.template.sh").read_text( +TEMPLATE_REVIEW_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "review_info.py").read_text( encoding="utf-8" ) -TEMPLATE_REVIEW_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "review-info.template.sh").read_text( +TEMPLATE_CHARACTER_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "character_info.py").read_text( encoding="utf-8" ) -TEMPLATE_CHARACTER_INFO_SCRIPT = ( - FZF_SCRIPTS_DIR / "character-info.template.sh" -).read_text(encoding="utf-8") TEMPLATE_AIRING_SCHEDULE_INFO_SCRIPT = ( - FZF_SCRIPTS_DIR / "airing-schedule-info.template.sh" + FZF_SCRIPTS_DIR / "airing_schedule_info.py" ).read_text(encoding="utf-8") @@ -103,29 +101,29 @@ class PreviewCacheWorker(ManagedBackgroundWorker): raise RuntimeError("PreviewCacheWorker is not running") for media_item, title_str in zip(media_items, titles): - hash_id = self._get_cache_hash(title_str) + selection_title = self._get_selection_title(title_str) # Submit image download task if needed if config.general.preview in ("full", "image") and media_item.cover_image: - image_path = self.images_cache_dir / f"{hash_id}.png" + image_path = self.images_cache_dir / f"{selection_title}.png" if not image_path.exists(): self.submit_function( self._download_and_save_image, media_item.cover_image.large, - hash_id, + selection_title, ) # Submit info generation task if needed if config.general.preview in ("full", "text"): info_text = self._generate_info_text(media_item, config) - self.submit_function(self._save_info_text, info_text, hash_id) + self.submit_function(self._save_info_text, info_text, selection_title) - def _download_and_save_image(self, url: str, hash_id: str) -> None: + def _download_and_save_image(self, url: str, selection_title: str) -> None: """Download an image and save it to cache.""" if not self._http_client: raise RuntimeError("HTTP client not initialized") - image_path = self.images_cache_dir / f"{hash_id}.png" + image_path = self.images_cache_dir / f"{selection_title}.png" try: with self._http_client.stream("GET", url) as response: @@ -135,7 +133,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker): for chunk in response.iter_bytes(): f.write(chunk) - logger.debug(f"Successfully cached image: {hash_id}") + logger.debug(f"Successfully cached image: {selection_title}") except Exception as e: logger.error(f"Failed to download image {url}: {e}") @@ -216,22 +214,22 @@ class PreviewCacheWorker(ManagedBackgroundWorker): return info_script - def _save_info_text(self, info_text: str, hash_id: str) -> None: + def _save_info_text(self, info_text: str, selection_title: str) -> None: """Save info text to cache.""" try: - info_path = self.info_cache_dir / hash_id + info_path = self.info_cache_dir / f"{selection_title}.py" with AtomicWriter(info_path) as f: f.write(info_text) - logger.debug(f"Successfully cached info: {hash_id}") + logger.debug(f"Successfully cached info: {selection_title}") except IOError as e: - logger.error(f"Failed to write info cache for {hash_id}: {e}") + logger.error(f"Failed to write info cache for {selection_title}: {e}") raise - def _get_cache_hash(self, text: str) -> str: + def _get_selection_title(self, text: str) -> str: """Generate a cache hash for the given text.""" from hashlib import sha256 - return sha256(text.encode("utf-8")).hexdigest() + return f"search-results-{sha256(text.encode('utf-8')).hexdigest()}" def _on_task_completed(self, task: WorkerTask, future) -> None: """Handle task completion with enhanced logging.""" From 1d129a5771505ff1c6274eb714123849892d350f Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 22:37:36 +0300 Subject: [PATCH 03/48] fix: remove extra bracket --- viu_media/cli/utils/preview_workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 46a6935..110f186 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -157,7 +157,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker): media_item.format.value if media_item.format else "UNKNOWN" ), "NEXT_EPISODE": formatter.shell_safe( - f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}" + f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X')}" if media_item.next_airing else "N/A" ), From 9a0bb65e52eb8c8ceeb9cc694fdd335c87ba975b Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 22:49:41 +0300 Subject: [PATCH 04/48] feat: implement image preview --- viu_media/assets/scripts/fzf/preview.py | 139 +++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index 6a2665e..df20b73 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -8,6 +8,8 @@ from pathlib import Path from hashlib import sha256 import subprocess +import os +import shutil import sys from rich.console import Console from rich.rule import Rule @@ -27,11 +29,146 @@ TITLE = sys.argv[1] hash = f"{PREFIX}-{sha256(TITLE.encode('utf-8')).hexdigest()}" + +def fzf_image_preview(file_path: str): + # Environment variables from fzf + FZF_PREVIEW_COLUMNS = os.environ.get("FZF_PREVIEW_COLUMNS") + FZF_PREVIEW_LINES = os.environ.get("FZF_PREVIEW_LINES") + FZF_PREVIEW_TOP = os.environ.get("FZF_PREVIEW_TOP") + KITTY_WINDOW_ID = os.environ.get("KITTY_WINDOW_ID") + GHOSTTY_BIN_DIR = os.environ.get("GHOSTTY_BIN_DIR") + PLATFORM = os.environ.get("PLATFORM") + + # Compute terminal dimensions + dim = ( + f"{FZF_PREVIEW_COLUMNS}x{FZF_PREVIEW_LINES}" + if FZF_PREVIEW_COLUMNS and FZF_PREVIEW_LINES + else "x" + ) + + if dim == "x": + try: + rows, cols = ( + subprocess.check_output( + ["stty", "size"], text=True, stderr=subprocess.DEVNULL + ) + .strip() + .split() + ) + dim = f"{cols}x{rows}" + except Exception: + dim = "80x24" + + # Adjust dimension if icat not used and preview area fills bottom of screen + if ( + IMAGE_RENDERER != "icat" + and not KITTY_WINDOW_ID + and FZF_PREVIEW_TOP + and FZF_PREVIEW_LINES + ): + try: + term_rows = int( + subprocess.check_output(["stty", "size"], text=True).split()[0] + ) + if int(FZF_PREVIEW_TOP) + int(FZF_PREVIEW_LINES) == term_rows: + dim = f"{FZF_PREVIEW_COLUMNS}x{int(FZF_PREVIEW_LINES) - 1}" + except Exception: + pass + + # Helper to run commands + def run(cmd): + subprocess.run(cmd, stdout=sys.stdout, stderr=sys.stderr) + + def command_exists(cmd): + return shutil.which(cmd) is not None + + # ICAT / KITTY path + if IMAGE_RENDERER == "icat" and not GHOSTTY_BIN_DIR: + icat_cmd = None + if command_exists("kitten"): + icat_cmd = ["kitten", "icat"] + elif command_exists("icat"): + icat_cmd = ["icat"] + elif command_exists("kitty"): + icat_cmd = ["kitty", "icat"] + + if icat_cmd: + run( + icat_cmd + + [ + "--clear", + "--transfer-mode=memory", + "--unicode-placeholder", + "--stdin=no", + f"--place={dim}@0x0", + file_path, + ] + ) + else: + print("No icat-compatible viewer found (kitten/icat/kitty)") + + elif GHOSTTY_BIN_DIR: + try: + cols = int(FZF_PREVIEW_COLUMNS or "80") - 1 + lines = FZF_PREVIEW_LINES or "24" + dim = f"{cols}x{lines}" + except Exception: + pass + + if command_exists("kitten"): + run( + [ + "kitten", + "icat", + "--clear", + "--transfer-mode=memory", + "--unicode-placeholder", + "--stdin=no", + f"--place={dim}@0x0", + file_path, + ] + ) + elif command_exists("icat"): + run( + [ + "icat", + "--clear", + "--transfer-mode=memory", + "--unicode-placeholder", + "--stdin=no", + f"--place={dim}@0x0", + file_path, + ] + ) + elif command_exists("chafa"): + run(["chafa", "-s", dim, file_path]) + + elif command_exists("chafa"): + # Platform specific rendering + if PLATFORM == "android": + run(["chafa", "-s", dim, file_path]) + elif PLATFORM == "windows": + run(["chafa", "-f", "sixel", "-s", dim, file_path]) + else: + run(["chafa", "-s", dim, file_path]) + print() + + elif command_exists("imgcat"): + width, height = dim.split("x") + run(["imgcat", "-W", width, "-H", height, file_path]) + + else: + print( + "⚠️ Please install a terminal image viewer (icat, kitten, imgcat, or chafa)." + ) + + console = Console(force_terminal=True, color_system="truecolor") if PREVIEW_MODE == "image" or PREVIEW_MODE == "full": preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png" if preview_image_path.exists(): - print("rendering image") + fzf_image_preview(str(preview_image_path)) + print() else: print("🖼️ Loading image...") From 7401a1ad8fd11b5a2e2565c34969fc09536ba7c6 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:04:56 +0300 Subject: [PATCH 05/48] feat: prefer to use direct implementation of graphics protocol over external tools --- viu_media/assets/scripts/fzf/preview.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index df20b73..d1d778c 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -163,6 +163,35 @@ def fzf_image_preview(file_path: str): ) +def fzf_text_preview(file_path: str): + from base64 import standard_b64encode + + def serialize_gr_command(**cmd): + payload = cmd.pop("payload", None) + cmd = ",".join(f"{k}={v}" for k, v in cmd.items()) + ans = [] + w = ans.append + w(b"\033_G") + w(cmd.encode("ascii")) + if payload: + w(b";") + w(payload) + w(b"\033\\") + return b"".join(ans) + + def write_chunked(**cmd): + data = standard_b64encode(cmd.pop("data")) + while data: + chunk, data = data[:4096], data[4096:] + m = 1 if data else 0 + sys.stdout.buffer.write(serialize_gr_command(payload=chunk, m=m, **cmd)) + sys.stdout.flush() + cmd.clear() + + with open(file_path, "rb") as f: + write_chunked(a="T", f=100, data=f.read()) + + console = Console(force_terminal=True, color_system="truecolor") if PREVIEW_MODE == "image" or PREVIEW_MODE == "full": preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png" From 925c30c06e341f845c503a8a62867e452ff861b9 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:23:03 +0300 Subject: [PATCH 06/48] fix: typo should be text not info --- viu_media/assets/scripts/fzf/preview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index d1d778c..46159cb 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -202,7 +202,7 @@ if PREVIEW_MODE == "image" or PREVIEW_MODE == "full": print("🖼️ Loading image...") console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) -if PREVIEW_MODE == "info" or PREVIEW_MODE == "full": +if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": preview_info_path = INFO_CACHE_DIR / f"{hash}.py" if preview_info_path.exists(): subprocess.run( From 44b3663644cd54861754155066a59e6cad167eb8 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:23:33 +0300 Subject: [PATCH 07/48] feat: grp studio, synonymns and tags separately for better ui / ux --- viu_media/assets/scripts/fzf/info.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/info.py index 5cb08ca..c0ff618 100644 --- a/viu_media/assets/scripts/fzf/info.py +++ b/viu_media/assets/scripts/fzf/info.py @@ -35,11 +35,9 @@ left = [ "Start Date", "End Date", ), - ( - "Studios", - "Synonymns", - "Tags", - ), + ("Studios",), + ("Synonymns",), + ("Tags",), ] right = [ ( @@ -65,11 +63,9 @@ right = [ "{START_DATE}", "{END_DATE}", ), - ( - "{STUDIOS}", - "{SYNONYMNS}", - "{TAGS}", - ), + ("{STUDIOS}",), + ("{SYNONYMNS}",), + ("{TAGS}",), ] From 106278e38600737526870c35ba690ad553f3feb8 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:35:17 +0300 Subject: [PATCH 08/48] feat: improve synopsis separator styling --- viu_media/assets/scripts/fzf/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/info.py index c0ff618..5a6dbe9 100644 --- a/viu_media/assets/scripts/fzf/info.py +++ b/viu_media/assets/scripts/fzf/info.py @@ -79,5 +79,5 @@ for L_grp, R_grp in zip(left, right): console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) console.print(table) -console.print(Rule(title="Description", style=f"rgb({SEPARATOR_COLOR})")) +console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) console.print(Markdown("""{SYNOPSIS}""")) From 097db713bc30cfc0f595c4560c0e3da9d6a0fcf7 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:37:45 +0300 Subject: [PATCH 09/48] feat: refactor ruling logic to function --- viu_media/assets/scripts/fzf/info.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/info.py index 5a6dbe9..fb92de6 100644 --- a/viu_media/assets/scripts/fzf/info.py +++ b/viu_media/assets/scripts/fzf/info.py @@ -9,6 +9,11 @@ console = Console(force_terminal=True, color_system="truecolor") HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] + +def rule(title: str | None = None): + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + console.print("{TITLE}", justify="center") left = [ @@ -76,8 +81,9 @@ for L_grp, R_grp in zip(left, right): for L, R in zip(L_grp, R_grp): table.add_row(f"[bold rgb({HEADER_COLOR})]{L}: [/]", f"{R}") - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + rule() console.print(table) -console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + +rule() console.print(Markdown("""{SYNOPSIS}""")) From e37f9213f6d0459cf6d4568cc623d095dc266c9a Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:44:11 +0300 Subject: [PATCH 10/48] feat: include romaji title in synonymns if not already there --- viu_media/cli/utils/preview_workers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 110f186..685018c 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -188,7 +188,12 @@ class PreviewCacheWorker(ManagedBackgroundWorker): ) ), "SYNONYMNS": formatter.shell_safe( - formatter.format_list_with_commas(media_item.synonymns) + formatter.format_list_with_commas( + [media_item.title.romaji] + media_item.synonymns + if media_item.title.romaji + and media_item.title.romaji not in media_item.synonymns + else media_item.synonymns + ) ), "USER_STATUS": formatter.shell_safe( media_item.user_status.status.value From 2d8c1d356965593633c1c481d2d246565c137654 Mon Sep 17 00:00:00 2001 From: Benexl Date: Fri, 31 Oct 2025 23:50:12 +0300 Subject: [PATCH 11/48] feat: remove colon for better ui --- viu_media/assets/scripts/fzf/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/info.py index fb92de6..9cbcc20 100644 --- a/viu_media/assets/scripts/fzf/info.py +++ b/viu_media/assets/scripts/fzf/info.py @@ -79,7 +79,7 @@ for L_grp, R_grp in zip(left, right): table.add_column(justify="left", no_wrap=True) table.add_column(justify="right", overflow="fold") for L, R in zip(L_grp, R_grp): - table.add_row(f"[bold rgb({HEADER_COLOR})]{L}: [/]", f"{R}") + table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") rule() console.print(table) From 192818362b28dfdf2d618c6cf1dd1cbd91d21928 Mon Sep 17 00:00:00 2001 From: Benexl Date: Sat, 1 Nov 2025 00:04:05 +0300 Subject: [PATCH 12/48] feat: next episode should come last in its grp for better ui ux --- viu_media/assets/scripts/fzf/info.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/info.py index 9cbcc20..6d575ac 100644 --- a/viu_media/assets/scripts/fzf/info.py +++ b/viu_media/assets/scripts/fzf/info.py @@ -25,8 +25,8 @@ left = [ ), ( "Episodes", - "Next Episode", "Duration", + "Next Episode", ), ( "Genres", @@ -53,8 +53,8 @@ right = [ ), ( "{EPISODES}", - "{NEXT_EPISODE}", "{DURATION}", + "{NEXT_EPISODE}", ), ( "{GENRES}", From 0c3a963cc491c986fd6a5eb8e10786b34b1cd950 Mon Sep 17 00:00:00 2001 From: Benexl Date: Sat, 1 Nov 2025 00:50:45 +0300 Subject: [PATCH 13/48] feat: use ?? where episodes are unknown --- viu_media/cli/utils/preview_workers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 685018c..f821c12 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -161,7 +161,9 @@ class PreviewCacheWorker(ManagedBackgroundWorker): if media_item.next_airing else "N/A" ), - "EPISODES": formatter.shell_safe(str(media_item.episodes)), + "EPISODES": formatter.shell_safe( + str(media_item.episodes) if media_item.episodes else "??" + ), "DURATION": formatter.shell_safe( formatter.format_media_duration(media_item.duration) ), From 9a619b41f483cf3468e0a4e4c1ed4247bc8591dc Mon Sep 17 00:00:00 2001 From: Benexl Date: Sat, 1 Nov 2025 00:55:19 +0300 Subject: [PATCH 14/48] feat: use prefix in preview-script.py filename --- viu_media/cli/utils/preview.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 226ef00..3ce1afc 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -306,10 +306,12 @@ def get_anime_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - (APP_CACHE_DIR / "preview_script.py").write_text(preview_script, encoding="utf-8") + (APP_CACHE_DIR / "search-results-preview-script.py").write_text( + preview_script, encoding="utf-8" + ) preview_script_final = ( - f"{sys.executable} {APP_CACHE_DIR / 'preview_script.py'} {{}}" + f"{sys.executable} {APP_CACHE_DIR / 'search-results-preview-script.py'} {{}}" ) return preview_script_final From 1519c8be17b74de3b315a83f0eb72900c0cf8bed Mon Sep 17 00:00:00 2001 From: Benexl Date: Sat, 1 Nov 2025 00:59:38 +0300 Subject: [PATCH 15/48] feat: create the preview script in the cache/preview dir --- viu_media/cli/utils/preview.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 3ce1afc..5cf0a97 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -306,13 +306,11 @@ def get_anime_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - (APP_CACHE_DIR / "search-results-preview-script.py").write_text( + (PREVIEWS_CACHE_DIR / "search-results-preview-script.py").write_text( preview_script, encoding="utf-8" ) - preview_script_final = ( - f"{sys.executable} {APP_CACHE_DIR / 'search-results-preview-script.py'} {{}}" - ) + preview_script_final = f"{sys.executable} {PREVIEWS_CACHE_DIR / 'search-results-preview-script.py'} {{}}" return preview_script_final From a7b0f21debec9ee58f205f92d78d651665ab3bd8 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 18 Nov 2025 13:44:20 +0300 Subject: [PATCH 16/48] feat: rename info.py to media_info.py --- viu_media/assets/scripts/fzf/{info.py => media_info.py} | 0 viu_media/cli/utils/preview_workers.py | 6 ++++-- 2 files changed, 4 insertions(+), 2 deletions(-) rename viu_media/assets/scripts/fzf/{info.py => media_info.py} (100%) diff --git a/viu_media/assets/scripts/fzf/info.py b/viu_media/assets/scripts/fzf/media_info.py similarity index 100% rename from viu_media/assets/scripts/fzf/info.py rename to viu_media/assets/scripts/fzf/media_info.py diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index f821c12..7f084b3 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -31,7 +31,9 @@ logger = logging.getLogger(__name__) FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" -TEMPLATE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "info.py").read_text(encoding="utf-8") +TEMPLATE_MEDIA_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "media_info.py").read_text( + encoding="utf-8" +) TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode_info.py").read_text( encoding="utf-8" ) @@ -142,7 +144,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker): def _generate_info_text(self, media_item: MediaItem, config: AppConfig) -> str: """Generate formatted info text for a media item.""" # Import here to avoid circular imports - info_script = TEMPLATE_INFO_SCRIPT + info_script = TEMPLATE_MEDIA_INFO_SCRIPT description = formatter.clean_html( media_item.description or "No description available." ) From 6e287d320de6d2fbdc9a0230468704a3aa6d86a4 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 18 Nov 2025 13:59:40 +0300 Subject: [PATCH 17/48] feat: rewrite episode info script in python --- viu_media/assets/scripts/fzf/episode_info.py | 44 ++++++++++++++++++++ viu_media/cli/utils/preview.py | 16 +++---- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/viu_media/assets/scripts/fzf/episode_info.py b/viu_media/assets/scripts/fzf/episode_info.py index e69de29..471be00 100644 --- a/viu_media/assets/scripts/fzf/episode_info.py +++ b/viu_media/assets/scripts/fzf/episode_info.py @@ -0,0 +1,44 @@ +import sys +from rich.console import Console +from rich.table import Table +from rich.rule import Rule +from rich.markdown import Markdown + +console = Console(force_terminal=True, color_system="truecolor") + +HEADER_COLOR = sys.argv[1] +SEPARATOR_COLOR = sys.argv[2] + + +def rule(title: str | None = None): + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + +console.print("{TITLE}", justify="center") + +left = [ + ("Duration", "Status"), + ("Total Episodes", "Next Episode"), + ("Progress", "List Status"), + ("Start Date", "End Date"), +] +right = [ + ("{DURATION}", "{STATUS}"), + ("{EPISODES}", "{NEXT_EPISODE}"), + ("{USER_PROGRESS}", "{USER_STATUS}"), + ("{START_DATE}", "{END_DATE}"), +] + + +for L_grp, R_grp in zip(left, right): + table = Table.grid(expand=True) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="right", overflow="fold") + for L, R in zip(L_grp, R_grp): + table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") + + rule() + console.print(table) + + +rule() diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 5cf0a97..ed256f1 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -354,18 +354,14 @@ def get_episode_preview( # Format the template with the dynamic values replacements = { "PREVIEW_MODE": config.general.preview, - "IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR), - "INFO_CACHE_PATH": str(INFO_CACHE_DIR), - "PATH_SEP": path_sep, + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), + "INFO_CACHE_DIR": str(INFO_CACHE_DIR), "IMAGE_RENDERER": config.general.image_renderer, # 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, - "PREFIX": f"{media_item.title.english}_Episode_", - "SCALE_UP": " --scale-up" if config.general.preview_scale_up else "", + "HEADER_COLOR": ",".join(HEADER_COLOR), + "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), + "PREFIX": f"episode-{media_item.title.english}", + "SCALE_UP": str(config.general.preview_scale_up), } for key, value in replacements.items(): From 8440ffb5e511103244e43c2a93e9fc5bb627d9a0 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 18 Nov 2025 14:20:07 +0300 Subject: [PATCH 18/48] feat: add a key for extra uniqueness --- viu_media/assets/scripts/fzf/preview.py | 4 +++- viu_media/cli/utils/preview.py | 9 ++++----- viu_media/cli/utils/preview_workers.py | 8 +++----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index 46159cb..eb163e0 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -26,8 +26,10 @@ SCALE_UP = "{SCALE_UP}" == "True" # fzf passes the title with quotes, so we need to trim them TITLE = sys.argv[1] +KEY = "{KEY}" +KEY = KEY + "-" if KEY else KEY -hash = f"{PREFIX}-{sha256(TITLE.encode('utf-8')).hexdigest()}" +hash = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}" def fzf_image_preview(file_path: str): diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index ed256f1..cf97422 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -299,7 +299,8 @@ def get_anime_preview( # Color codes "HEADER_COLOR": ",".join(HEADER_COLOR), "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), - "PREFIX": "search-results", + "PREFIX": "search-result", + "KEY": "", "SCALE_UP": str(config.general.preview_scale_up), } @@ -348,9 +349,6 @@ def get_episode_preview( logger.error(f"Failed to start episode background caching: {e}") # Continue with script generation even if caching fails - # Prepare values to inject into the template - path_sep = "\\" if PLATFORM == "win32" else "/" - # Format the template with the dynamic values replacements = { "PREVIEW_MODE": config.general.preview, @@ -360,7 +358,8 @@ def get_episode_preview( # Color codes "HEADER_COLOR": ",".join(HEADER_COLOR), "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), - "PREFIX": f"episode-{media_item.title.english}", + "PREFIX": "episode", + "KEY": f"{media_item.title.english}", "SCALE_UP": str(config.general.preview_scale_up), } diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 7f084b3..aac7039 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -238,7 +238,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker): """Generate a cache hash for the given text.""" from hashlib import sha256 - return f"search-results-{sha256(text.encode('utf-8')).hexdigest()}" + return f"search-result-{sha256(text.encode('utf-8')).hexdigest()}" def _on_task_completed(self, task: WorkerTask, future) -> None: """Handle task completion with enhanced logging.""" @@ -307,9 +307,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): streaming_episodes = media_item.streaming_episodes for episode_str in episodes: - hash_id = self._get_cache_hash( - f"{media_item.title.english}_Episode_{episode_str}" - ) + hash_id = self._get_cache_hash(f"{media_item.title.english}-{episode_str}") # Find episode data episode_data = streaming_episodes.get(episode_str) @@ -404,7 +402,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): """Generate a cache hash for the given text.""" from hashlib import sha256 - return sha256(text.encode("utf-8")).hexdigest() + return "episode-" + sha256(text.encode("utf-8")).hexdigest() def _on_task_completed(self, task: WorkerTask, future) -> None: """Handle task completion with enhanced logging.""" From 64093204adcb9de603869553c02f77128ade400b Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 18 Nov 2025 14:28:54 +0300 Subject: [PATCH 19/48] feat: create temp episode preview script --- viu_media/cli/utils/preview.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index cf97422..cba0123 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -307,11 +307,11 @@ def get_anime_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - (PREVIEWS_CACHE_DIR / "search-results-preview-script.py").write_text( + (PREVIEWS_CACHE_DIR / "search-result-preview-script.py").write_text( preview_script, encoding="utf-8" ) - preview_script_final = f"{sys.executable} {PREVIEWS_CACHE_DIR / 'search-results-preview-script.py'} {{}}" + preview_script_final = f"{sys.executable} {PREVIEWS_CACHE_DIR / 'search-result-preview-script.py'} {{}}" return preview_script_final @@ -366,7 +366,13 @@ def get_episode_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - return preview_script + (PREVIEWS_CACHE_DIR / "episode-preview-script.py").write_text( + preview_script, encoding="utf-8" + ) + preview_script_final = ( + f"{sys.executable} {PREVIEWS_CACHE_DIR / 'episode-preview-script.py'} {{}}" + ) + return preview_script_final def get_dynamic_anime_preview(config: AppConfig) -> str: From 08ae8786c3aa10eb8363a1b83877fda0230d7340 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 18 Nov 2025 14:48:00 +0300 Subject: [PATCH 20/48] feat: sanitize " in key --- viu_media/assets/scripts/fzf/preview.py | 2 +- viu_media/cli/utils/preview.py | 4 +++- viu_media/cli/utils/preview_workers.py | 4 +++- viu_media/core/utils/formatter.py | 2 ++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index eb163e0..5a9e3c8 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -26,7 +26,7 @@ SCALE_UP = "{SCALE_UP}" == "True" # fzf passes the title with quotes, so we need to trim them TITLE = sys.argv[1] -KEY = "{KEY}" +KEY = """{KEY}""" KEY = KEY + "-" if KEY else KEY hash = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}" diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index cba0123..84b4936 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -6,6 +6,8 @@ from typing import Dict, List, Optional import httpx +from viu_media.core.utils import formatter + from ...core.config import AppConfig from ...core.constants import APP_CACHE_DIR, PLATFORM, SCRIPTS_DIR from ...core.utils.file import AtomicWriter @@ -359,7 +361,7 @@ def get_episode_preview( "HEADER_COLOR": ",".join(HEADER_COLOR), "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), "PREFIX": "episode", - "KEY": f"{media_item.title.english}", + "KEY": f"{media_item.title.english.replace(formatter.DOUBLE_QUOTE, formatter.SINGLE_QUOTE)}", "SCALE_UP": str(config.general.preview_scale_up), } diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index aac7039..67eed3c 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -307,7 +307,9 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): streaming_episodes = media_item.streaming_episodes for episode_str in episodes: - hash_id = self._get_cache_hash(f"{media_item.title.english}-{episode_str}") + hash_id = self._get_cache_hash( + f"{media_item.title.english.replace(formatter.DOUBLE_QUOTE, formatter.SINGLE_QUOTE)}-{episode_str}" + ) # Find episode data episode_data = streaming_episodes.get(episode_str) diff --git a/viu_media/core/utils/formatter.py b/viu_media/core/utils/formatter.py index 1004463..cdc0778 100644 --- a/viu_media/core/utils/formatter.py +++ b/viu_media/core/utils/formatter.py @@ -5,6 +5,8 @@ from typing import Dict, List, Optional, Union from ...libs.media_api.types import AiringSchedule COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)") +SINGLE_QUOTE = "'" +DOUBLE_QUOTE = '"' def format_media_duration(total_minutes: Optional[int]) -> str: From 23ebff3f4221a898e47d5c86866962f38683465d Mon Sep 17 00:00:00 2001 From: Benexl Date: Sun, 30 Nov 2025 14:41:17 +0300 Subject: [PATCH 21/48] fix: add .py extension to final path --- viu_media/cli/utils/preview_workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 67eed3c..59eb466 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -404,7 +404,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): """Generate a cache hash for the given text.""" from hashlib import sha256 - return "episode-" + sha256(text.encode("utf-8")).hexdigest() + return "episode-" + sha256(text.encode("utf-8")).hexdigest() + ".py" def _on_task_completed(self, task: WorkerTask, future) -> None: """Handle task completion with enhanced logging.""" From e8387f3db9b540b59529495c8f93ef4c02d54cd3 Mon Sep 17 00:00:00 2001 From: Benexl Date: Sun, 30 Nov 2025 15:03:48 +0300 Subject: [PATCH 22/48] feat: character previews in python --- .../assets/scripts/fzf/character_info.py | 43 ++++++++++ viu_media/cli/utils/preview.py | 80 ++++++++++--------- viu_media/cli/utils/preview_workers.py | 2 +- 3 files changed, 86 insertions(+), 39 deletions(-) diff --git a/viu_media/assets/scripts/fzf/character_info.py b/viu_media/assets/scripts/fzf/character_info.py index e69de29..3880fc6 100644 --- a/viu_media/assets/scripts/fzf/character_info.py +++ b/viu_media/assets/scripts/fzf/character_info.py @@ -0,0 +1,43 @@ +import sys +from rich.console import Console +from rich.table import Table +from rich.rule import Rule +from rich.markdown import Markdown + +console = Console(force_terminal=True, color_system="truecolor") + +HEADER_COLOR = sys.argv[1] +SEPARATOR_COLOR = sys.argv[2] + + +def rule(title: str | None = None): + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + +console.print("{CHARACTER_NAME}", justify="center") + +left = [ + ("Native Name", "Gender"), + ("Age", "Blood Type"), + ("Birthday", "Favourites"), +] +right = [ + ("{CHARACTER_NATIVE_NAME}", "{CHARACTER_GENDER}"), + ("{CHARACTER_AGE}", "{CHARACTER_BLOOD_TYPE}"), + ("{CHARACTER_BIRTHDAY}", "{CHARACTER_FAVOURITES}"), +] + + +for L_grp, R_grp in zip(left, right): + table = Table.grid(expand=True) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="right", overflow="fold") + for L, R in zip(L_grp, R_grp): + table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") + + rule() + console.print(table) + + +rule() +console.print(Markdown("""{CHARACTER_DESCRIPTION}""")) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 84b4936..4077a26 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -125,13 +125,11 @@ PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews" IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images" INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info" REVIEWS_CACHE_DIR = PREVIEWS_CACHE_DIR / "reviews" -CHARACTERS_CACHE_DIR = PREVIEWS_CACHE_DIR / "characters" AIRING_SCHEDULE_CACHE_DIR = PREVIEWS_CACHE_DIR / "airing_schedule" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8") TEMPLATE_REVIEW_PREVIEW_SCRIPT = "" -TEMPLATE_CHARACTER_PREVIEW_SCRIPT = "" TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = "" DYNAMIC_PREVIEW_SCRIPT = "" @@ -377,6 +375,48 @@ def get_episode_preview( return preview_script_final +def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) -> str: + """ + Generate the generic loader script for character previews and start background caching. + """ + + IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) + INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + HEADER_COLOR = config.fzf.preview_header_color.split(",") + SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") + + # Start managed background caching for episodes + try: + preview_manager = _get_preview_manager() + worker = preview_manager.get_character_worker() + worker.cache_character_previews(choice_map, config) + logger.debug("Started background caching for character previews") + except Exception as e: + logger.error(f"Failed to start episode background caching: {e}") + + # Use the generic loader script + preview_script = TEMPLATE_PREVIEW_SCRIPT + + replacements = { + "PREVIEW_MODE": config.general.preview, + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), + "INFO_CACHE_DIR": str(INFO_CACHE_DIR), + "IMAGE_RENDERER": config.general.image_renderer, + # Color codes + "HEADER_COLOR": ",".join(HEADER_COLOR), + "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), + "PREFIX": "character", + "KEY": "", + "SCALE_UP": str(config.general.preview_scale_up), + } + + for key, value in replacements.items(): + preview_script = preview_script.replace(f"{{{key}}}", value) + + return preview_script + + def get_dynamic_anime_preview(config: AppConfig) -> str: """ Generate dynamic anime preview script for search functionality. @@ -498,42 +538,6 @@ def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> return preview_script -def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) -> str: - """ - Generate the generic loader script for character previews and start background caching. - """ - - INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - preview_manager = _get_preview_manager() - worker = preview_manager.get_character_worker() - worker.cache_character_previews(choice_map, config) - logger.debug("Started background caching for character previews") - - # Use the generic loader script - preview_script = TEMPLATE_CHARACTER_PREVIEW_SCRIPT - path_sep = "\\" if PLATFORM == "win32" else "/" - - # Inject the correct cache path and color codes - replacements = { - "PREVIEW_MODE": config.general.preview, - "INFO_CACHE_DIR": str(INFO_CACHE_DIR), - "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), - "PATH_SEP": path_sep, - "C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_VALUE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_RULE": ansi.get_true_fg( - config.fzf.preview_separator_color.split(","), bold=True - ), - "RESET": ansi.RESET, - } - - for key, value in replacements.items(): - preview_script = preview_script.replace(f"{{{key}}}", value) - - return preview_script - - def get_airing_schedule_preview( schedule_result: AiringScheduleResult, config: AppConfig, anime_title: str = "Anime" ) -> str: diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 59eb466..aa2a90b 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -617,7 +617,7 @@ class CharacterCacheWorker(ManagedBackgroundWorker): def _get_cache_hash(self, text: str) -> str: from hashlib import sha256 - return sha256(text.encode("utf-8")).hexdigest() + return "character-" + sha256(text.encode("utf-8")).hexdigest() + ".py" def _on_task_completed(self, task: WorkerTask, future) -> None: super()._on_task_completed(task, future) From 6ccd96d2524f79cf4dc8e75072cf3a8b2a3b6559 Mon Sep 17 00:00:00 2001 From: Benexl Date: Sun, 30 Nov 2025 15:15:11 +0300 Subject: [PATCH 23/48] feat: review previews in python --- viu_media/assets/scripts/fzf/review_info.py | 39 ++++++++++ viu_media/cli/utils/preview.py | 83 +++++++++++---------- viu_media/cli/utils/preview_workers.py | 19 +++-- 3 files changed, 94 insertions(+), 47 deletions(-) diff --git a/viu_media/assets/scripts/fzf/review_info.py b/viu_media/assets/scripts/fzf/review_info.py index e69de29..fb13de7 100644 --- a/viu_media/assets/scripts/fzf/review_info.py +++ b/viu_media/assets/scripts/fzf/review_info.py @@ -0,0 +1,39 @@ +import sys +from rich.console import Console +from rich.table import Table +from rich.rule import Rule +from rich.markdown import Markdown + +console = Console(force_terminal=True, color_system="truecolor") + +HEADER_COLOR = sys.argv[1] +SEPARATOR_COLOR = sys.argv[2] + + +def rule(title: str | None = None): + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + +console.print("{REVIEWER_NAME}", justify="center") + +left = [ + ("Summary",), +] +right = [ + ("{REVIEW_SUMMARY}",), +] + + +for L_grp, R_grp in zip(left, right): + table = Table.grid(expand=True) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="right", overflow="fold") + for L, R in zip(L_grp, R_grp): + table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") + + rule() + console.print(table) + + +rule() +console.print(Markdown("""{REVIEW_BODY}""")) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 4077a26..42acd00 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -124,12 +124,10 @@ logger = logging.getLogger(__name__) PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews" IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images" INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info" -REVIEWS_CACHE_DIR = PREVIEWS_CACHE_DIR / "reviews" AIRING_SCHEDULE_CACHE_DIR = PREVIEWS_CACHE_DIR / "airing_schedule" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8") -TEMPLATE_REVIEW_PREVIEW_SCRIPT = "" TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = "" DYNAMIC_PREVIEW_SCRIPT = "" @@ -417,6 +415,48 @@ def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) - return preview_script +def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> str: + """ + Generate the generic loader script for review previews and start background caching. + """ + + IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) + INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + HEADER_COLOR = config.fzf.preview_header_color.split(",") + SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") + + # Start managed background caching for episodes + try: + preview_manager = _get_preview_manager() + worker = preview_manager.get_review_worker() + worker.cache_review_previews(choice_map, config) + logger.debug("Started background caching for review previews") + except Exception as e: + logger.error(f"Failed to start episode background caching: {e}") + + # Use the generic loader script + preview_script = TEMPLATE_PREVIEW_SCRIPT + + replacements = { + "PREVIEW_MODE": config.general.preview, + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), + "INFO_CACHE_DIR": str(INFO_CACHE_DIR), + "IMAGE_RENDERER": config.general.image_renderer, + # Color codes + "HEADER_COLOR": ",".join(HEADER_COLOR), + "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), + "PREFIX": "review", + "KEY": "", + "SCALE_UP": str(config.general.preview_scale_up), + } + + for key, value in replacements.items(): + preview_script = preview_script.replace(f"{{{key}}}", value) + + return preview_script + + def get_dynamic_anime_preview(config: AppConfig) -> str: """ Generate dynamic anime preview script for search functionality. @@ -475,9 +515,7 @@ def _get_preview_manager() -> PreviewWorkerManager: """Get or create the global preview worker manager.""" global _preview_manager if _preview_manager is None: - _preview_manager = PreviewWorkerManager( - IMAGES_CACHE_DIR, INFO_CACHE_DIR, REVIEWS_CACHE_DIR - ) + _preview_manager = PreviewWorkerManager(IMAGES_CACHE_DIR, INFO_CACHE_DIR) return _preview_manager @@ -503,41 +541,6 @@ def get_preview_worker_status() -> dict: return {"preview_worker": None, "episode_worker": None} -def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> str: - """ - Generate the generic loader script for review previews and start background caching. - """ - - REVIEWS_CACHE_DIR.mkdir(parents=True, exist_ok=True) - preview_manager = _get_preview_manager() - worker = preview_manager.get_review_worker() - worker.cache_review_previews(choice_map, config) - logger.debug("Started background caching for review previews") - - # Use the generic loader script - preview_script = TEMPLATE_REVIEW_PREVIEW_SCRIPT - path_sep = "\\" if PLATFORM == "win32" else "/" - - # Inject the correct cache path and color codes - replacements = { - "PREVIEW_MODE": config.general.preview, - "INFO_CACHE_DIR": str(REVIEWS_CACHE_DIR), - "PATH_SEP": path_sep, - "C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_VALUE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_RULE": ansi.get_true_fg( - config.fzf.preview_separator_color.split(","), bold=True - ), - "RESET": ansi.RESET, - } - - for key, value in replacements.items(): - preview_script = preview_script.replace(f"{{{key}}}", value) - - return preview_script - - def get_airing_schedule_preview( schedule_result: AiringScheduleResult, config: AppConfig, anime_title: str = "Anime" ) -> str: diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index aa2a90b..42ff368 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -6,6 +6,7 @@ including image downloads and info text generation with proper lifecycle managem """ import logging +from pathlib import Path from typing import Dict, List, Optional import httpx @@ -421,9 +422,12 @@ class ReviewCacheWorker(ManagedBackgroundWorker): Specialized background worker for caching fully-rendered media review previews. """ - def __init__(self, reviews_cache_dir, max_workers: int = 10): + def __init__( + self, images_cache_dir: Path, info_cache_dir: Path, max_workers: int = 10 + ): super().__init__(max_workers=max_workers, name="ReviewCacheWorker") - self.reviews_cache_dir = reviews_cache_dir + self.images_cache_dir = images_cache_dir + self.info_cache_dir = info_cache_dir def cache_review_previews( self, choice_map: Dict[str, MediaReview], config: AppConfig @@ -471,7 +475,7 @@ class ReviewCacheWorker(ManagedBackgroundWorker): def _save_preview_content(self, content: str, hash_id: str) -> None: """Saves the final preview content to the cache.""" try: - info_path = self.reviews_cache_dir / hash_id + info_path = self.info_cache_dir / hash_id with AtomicWriter(info_path) as f: f.write(content) logger.debug(f"Successfully cached review preview: {hash_id}") @@ -482,7 +486,7 @@ class ReviewCacheWorker(ManagedBackgroundWorker): def _get_cache_hash(self, text: str) -> str: from hashlib import sha256 - return sha256(text.encode("utf-8")).hexdigest() + return "review-" + sha256(text.encode("utf-8")).hexdigest() + ".py" def _on_task_completed(self, task: WorkerTask, future) -> None: super()._on_task_completed(task, future) @@ -757,7 +761,7 @@ class PreviewWorkerManager: caching workers with automatic lifecycle management. """ - def __init__(self, images_cache_dir, info_cache_dir, reviews_cache_dir): + def __init__(self, images_cache_dir, info_cache_dir): """ Initialize the preview worker manager. @@ -768,7 +772,6 @@ class PreviewWorkerManager: """ self.images_cache_dir = images_cache_dir self.info_cache_dir = info_cache_dir - self.reviews_cache_dir = reviews_cache_dir self._preview_worker: Optional[PreviewCacheWorker] = None self._episode_worker: Optional[EpisodeCacheWorker] = None self._review_worker: Optional[ReviewCacheWorker] = None @@ -812,7 +815,9 @@ class PreviewWorkerManager: # Clean up old worker thread_manager.shutdown_worker("review_cache_worker") - self._review_worker = ReviewCacheWorker(self.reviews_cache_dir) + self._review_worker = ReviewCacheWorker( + self.images_cache_dir, self.info_cache_dir + ) self._review_worker.start() thread_manager.register_worker("review_cache_worker", self._review_worker) From 5193df219757d079910af87a2c45f33d84a70831 Mon Sep 17 00:00:00 2001 From: Benexl Date: Sun, 30 Nov 2025 15:33:34 +0300 Subject: [PATCH 24/48] feat: airing schedule previews in python --- .../scripts/fzf/airing_schedule_info.py | 41 +++++++++ viu_media/cli/utils/preview.py | 83 ++++++++++--------- viu_media/cli/utils/preview_workers.py | 2 +- 3 files changed, 86 insertions(+), 40 deletions(-) diff --git a/viu_media/assets/scripts/fzf/airing_schedule_info.py b/viu_media/assets/scripts/fzf/airing_schedule_info.py index e69de29..e11ff94 100644 --- a/viu_media/assets/scripts/fzf/airing_schedule_info.py +++ b/viu_media/assets/scripts/fzf/airing_schedule_info.py @@ -0,0 +1,41 @@ +import sys +from rich.console import Console +from rich.table import Table +from rich.rule import Rule +from rich.markdown import Markdown + +console = Console(force_terminal=True, color_system="truecolor") + +HEADER_COLOR = sys.argv[1] +SEPARATOR_COLOR = sys.argv[2] + + +def rule(title: str | None = None): + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + +console.print("{ANIME_TITLE}", justify="center") + +left = [ + ("Total Episodes",), + ("Upcoming Episodes",), +] +right = [ + ("{TOTAL_EPISODES}",), + ("{UPCOMING_EPISODES}",), +] + + +for L_grp, R_grp in zip(left, right): + table = Table.grid(expand=True) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="right", overflow="fold") + for L, R in zip(L_grp, R_grp): + table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") + + rule() + console.print(table) + + +rule() +console.print(Markdown("""{SCHEDULE_TABLE}""")) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 42acd00..871717d 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -124,11 +124,9 @@ logger = logging.getLogger(__name__) PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews" IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images" INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info" -AIRING_SCHEDULE_CACHE_DIR = PREVIEWS_CACHE_DIR / "airing_schedule" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8") -TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = "" DYNAMIC_PREVIEW_SCRIPT = "" EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*") @@ -457,6 +455,50 @@ def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> return preview_script +def get_airing_schedule_preview( + schedule_result: AiringScheduleResult, config: AppConfig, anime_title: str = "Anime" +) -> str: + """ + Generate the generic loader script for airing schedule previews and start background caching. + """ + + IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) + INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + HEADER_COLOR = config.fzf.preview_header_color.split(",") + SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") + + # Start managed background caching for episodes + try: + preview_manager = _get_preview_manager() + worker = preview_manager.get_airing_schedule_worker() + worker.cache_airing_schedule_preview(anime_title, schedule_result, config) + logger.debug("Started background caching for airing schedule previews") + except Exception as e: + logger.error(f"Failed to start episode background caching: {e}") + + # Use the generic loader script + preview_script = TEMPLATE_PREVIEW_SCRIPT + + replacements = { + "PREVIEW_MODE": config.general.preview, + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), + "INFO_CACHE_DIR": str(INFO_CACHE_DIR), + "IMAGE_RENDERER": config.general.image_renderer, + # Color codes + "HEADER_COLOR": ",".join(HEADER_COLOR), + "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), + "PREFIX": "airing-schedule", + "KEY": "", + "SCALE_UP": str(config.general.preview_scale_up), + } + + for key, value in replacements.items(): + preview_script = preview_script.replace(f"{{{key}}}", value) + + return preview_script + + def get_dynamic_anime_preview(config: AppConfig) -> str: """ Generate dynamic anime preview script for search functionality. @@ -539,40 +581,3 @@ def get_preview_worker_status() -> dict: if _preview_manager: return _preview_manager.get_status() return {"preview_worker": None, "episode_worker": None} - - -def get_airing_schedule_preview( - schedule_result: AiringScheduleResult, config: AppConfig, anime_title: str = "Anime" -) -> str: - """ - Generate the generic loader script for airing schedule previews and start background caching. - """ - - INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - preview_manager = _get_preview_manager() - worker = preview_manager.get_airing_schedule_worker() - worker.cache_airing_schedule_preview(anime_title, schedule_result, config) - logger.debug("Started background caching for airing schedule previews") - - # Use the generic loader script - preview_script = TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT - path_sep = "\\" if PLATFORM == "win32" else "/" - - # Inject the correct cache path and color codes - replacements = { - "PREVIEW_MODE": config.general.preview, - "INFO_CACHE_DIR": str(INFO_CACHE_DIR), - "PATH_SEP": path_sep, - "C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_VALUE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), - "C_RULE": ansi.get_true_fg( - config.fzf.preview_separator_color.split(","), bold=True - ), - "RESET": ansi.RESET, - } - - for key, value in replacements.items(): - preview_script = preview_script.replace(f"{{{key}}}", value) - - return preview_script diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 42ff368..d2ad5c4 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -745,7 +745,7 @@ class AiringScheduleCacheWorker(ManagedBackgroundWorker): def _get_cache_hash(self, text: str) -> str: from hashlib import sha256 - return sha256(text.encode("utf-8")).hexdigest() + return "airing-schedule-" + sha256(text.encode("utf-8")).hexdigest() + ".py" def _on_task_completed(self, task: WorkerTask, future) -> None: super()._on_task_completed(task, future) From 393b9e6ed668c3e3d436b3e78589062d8a13e66a Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:00:15 +0300 Subject: [PATCH 25/48] feat: use actual file for preview script --- viu_media/cli/utils/preview.py | 35 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 871717d..93e84d5 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -303,11 +303,10 @@ def get_anime_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - (PREVIEWS_CACHE_DIR / "search-result-preview-script.py").write_text( - preview_script, encoding="utf-8" - ) + preview_file = PREVIEWS_CACHE_DIR / "search-result-preview-script.py" + preview_file.write_text(preview_script, encoding="utf-8") - preview_script_final = f"{sys.executable} {PREVIEWS_CACHE_DIR / 'search-result-preview-script.py'} {{}}" + preview_script_final = f"{sys.executable} {preview_file} {{}}" return preview_script_final @@ -362,12 +361,10 @@ def get_episode_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - (PREVIEWS_CACHE_DIR / "episode-preview-script.py").write_text( - preview_script, encoding="utf-8" - ) - preview_script_final = ( - f"{sys.executable} {PREVIEWS_CACHE_DIR / 'episode-preview-script.py'} {{}}" - ) + preview_file = PREVIEWS_CACHE_DIR / "episode-preview-script.py" + preview_file.write_text(preview_script, encoding="utf-8") + + preview_script_final = f"{sys.executable} {preview_file} {{}}" return preview_script_final @@ -410,7 +407,11 @@ def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) - for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - return preview_script + preview_file = PREVIEWS_CACHE_DIR / "character-preview-script.py" + preview_file.write_text(preview_script, encoding="utf-8") + + preview_script_final = f"{sys.executable} {preview_file} {{}}" + return preview_script_final def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> str: @@ -452,7 +453,11 @@ def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - return preview_script + preview_file = PREVIEWS_CACHE_DIR / "review-preview-script.py" + preview_file.write_text(preview_script, encoding="utf-8") + + preview_script_final = f"{sys.executable} {preview_file} {{}}" + return preview_script_final def get_airing_schedule_preview( @@ -496,7 +501,11 @@ def get_airing_schedule_preview( for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - return preview_script + preview_file = PREVIEWS_CACHE_DIR / "airing-schedule-preview-script.py" + preview_file.write_text(preview_script, encoding="utf-8") + + preview_script_final = f"{sys.executable} {preview_file} {{}}" + return preview_script_final def get_dynamic_anime_preview(config: AppConfig) -> str: From 9050dd7787977ee4866f320f25176d77067fd44b Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:05:40 +0300 Subject: [PATCH 26/48] feat: disable image for character, review, airing-schedule --- viu_media/assets/scripts/fzf/preview.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index 5a9e3c8..472f7ba 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -195,7 +195,9 @@ def fzf_text_preview(file_path: str): console = Console(force_terminal=True, color_system="truecolor") -if PREVIEW_MODE == "image" or PREVIEW_MODE == "full": +if (PREVIEW_MODE == "image" or PREVIEW_MODE == "full") and ( + PREFIX not in ("character", "review", "airing-schedule") +): preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png" if preview_image_path.exists(): fzf_image_preview(str(preview_image_path)) From 091edb3a9b0548e55970e41cc124289382007d98 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:06:58 +0300 Subject: [PATCH 27/48] fix: remove extra bracket --- viu_media/cli/utils/preview_workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index d2ad5c4..732c386 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -360,7 +360,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): replacements = { "TITLE": formatter.shell_safe(title), "NEXT_EPISODE": formatter.shell_safe( - f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}" + f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X')}" if media_item.next_airing else "N/A" ), From a70db611f74be9cd0a5a313e372da7a42dbcdd40 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:19:11 +0300 Subject: [PATCH 28/48] style: remove unnecessary comment --- viu_media/assets/scripts/fzf/preview.py | 1 - 1 file changed, 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index 472f7ba..eb692fd 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -24,7 +24,6 @@ SEPARATOR_COLOR = "{SEPARATOR_COLOR}" PREFIX = "{PREFIX}" SCALE_UP = "{SCALE_UP}" == "True" -# fzf passes the title with quotes, so we need to trim them TITLE = sys.argv[1] KEY = """{KEY}""" KEY = KEY + "-" if KEY else KEY From 25a46bd2423069ac11cd53a76359e3136bd694eb Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:19:33 +0300 Subject: [PATCH 29/48] feat: disable airing schedule preview --- viu_media/cli/utils/preview.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 93e84d5..e0dd5bc 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -505,7 +505,8 @@ def get_airing_schedule_preview( preview_file.write_text(preview_script, encoding="utf-8") preview_script_final = f"{sys.executable} {preview_file} {{}}" - return preview_script_final + # NOTE: disabled cause not very useful + return "" def get_dynamic_anime_preview(config: AppConfig) -> str: From 76c1dcd5ac2425383eff395fc8a1a03b52ddd6db Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:19:55 +0300 Subject: [PATCH 30/48] fix: specifying extension when saving file --- viu_media/cli/utils/preview_workers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 732c386..1df0ef2 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -393,7 +393,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): def _save_info_text(self, info_text: str, hash_id: str) -> None: """Save episode info text to cache.""" try: - info_path = self.info_cache_dir / hash_id + info_path = self.info_cache_dir / hash_id + ".py" with AtomicWriter(info_path) as f: f.write(info_text) logger.debug(f"Successfully cached episode info: {hash_id}") @@ -405,7 +405,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): """Generate a cache hash for the given text.""" from hashlib import sha256 - return "episode-" + sha256(text.encode("utf-8")).hexdigest() + ".py" + return "episode-" + sha256(text.encode("utf-8")).hexdigest() def _on_task_completed(self, task: WorkerTask, future) -> None: """Handle task completion with enhanced logging.""" From f27c0b8548f4a69d62da530d3d3e62c22d4c001c Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:25:21 +0300 Subject: [PATCH 31/48] fix: order of operations --- viu_media/cli/utils/preview_workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview_workers.py b/viu_media/cli/utils/preview_workers.py index 1df0ef2..d1ef848 100644 --- a/viu_media/cli/utils/preview_workers.py +++ b/viu_media/cli/utils/preview_workers.py @@ -393,7 +393,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker): def _save_info_text(self, info_text: str, hash_id: str) -> None: """Save episode info text to cache.""" try: - info_path = self.info_cache_dir / hash_id + ".py" + info_path = self.info_cache_dir / (hash_id + ".py") with AtomicWriter(info_path) as f: f.write(info_text) logger.debug(f"Successfully cached episode info: {hash_id}") From bd9bf24e1c72b8fbc3b68c2f0428fb301f469468 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:27:47 +0300 Subject: [PATCH 32/48] feat: add more image render options --- viu_media/core/config/model.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/viu_media/core/config/model.py b/viu_media/core/config/model.py index f125b7a..35f2ecc 100644 --- a/viu_media/core/config/model.py +++ b/viu_media/core/config/model.py @@ -178,7 +178,9 @@ class GeneralConfig(BaseModel): description=desc.GENERAL_SCALE_PREVIEW, ) - image_renderer: Literal["icat", "chafa", "imgcat"] = Field( + image_renderer: Literal[ + "icat", "chafa", "imgcat", "system-sixels", "system-kitty", "system-default" + ] = Field( default_factory=defaults.GENERAL_IMAGE_RENDERER, description=desc.GENERAL_IMAGE_RENDERER, ) From 523766868cf6aa07113a94520658e6451b875110 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 17:44:55 +0300 Subject: [PATCH 33/48] feat: implement other image renders --- viu_media/assets/scripts/fzf/preview.py | 432 ++++++++++++++---------- 1 file changed, 251 insertions(+), 181 deletions(-) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index eb692fd..e90f27f 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -3,18 +3,16 @@ # FZF Preview Script Template # # This script is a template. The placeholders in curly braces, like {NAME} -# are dynamically filled by python using .replace() +# are dynamically filled by python using .replace() during runtime. -from pathlib import Path -from hashlib import sha256 -import subprocess import os import shutil +import subprocess import sys -from rich.console import Console -from rich.rule import Rule +from hashlib import sha256 +from pathlib import Path -# dynamically filled variables +# --- Template Variables (Injected by Python) --- PREVIEW_MODE = "{PREVIEW_MODE}" IMAGE_CACHE_DIR = Path("{IMAGE_CACHE_DIR}") INFO_CACHE_DIR = Path("{INFO_CACHE_DIR}") @@ -24,192 +22,264 @@ SEPARATOR_COLOR = "{SEPARATOR_COLOR}" PREFIX = "{PREFIX}" SCALE_UP = "{SCALE_UP}" == "True" -TITLE = sys.argv[1] +# --- Arguments --- +# sys.argv[1] is usually the raw line from FZF (the anime title/key) +TITLE = sys.argv[1] if len(sys.argv) > 1 else "" KEY = """{KEY}""" KEY = KEY + "-" if KEY else KEY -hash = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}" +# Generate the hash to find the cached files +hash_id = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}" + + +def get_terminal_dimensions(): + """ + Determine the available dimensions (cols x lines) for the preview window. + Prioritizes FZF environment variables. + """ + fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS") + fzf_lines = os.environ.get("FZF_PREVIEW_LINES") + + if fzf_cols and fzf_lines: + return int(fzf_cols), int(fzf_lines) + + # Fallback to stty if FZF vars aren't set (unlikely in preview) + try: + rows, cols = ( + subprocess.check_output( + ["stty", "size"], text=True, stderr=subprocess.DEVNULL + ) + .strip() + .split() + ) + return int(cols), int(rows) + except Exception: + return 80, 24 + + +def which(cmd): + """Alias for shutil.which""" + return shutil.which(cmd) + + +def render_kitty(file_path, width, height, scale_up): + """Render using the Kitty Graphics Protocol (kitten/icat).""" + # 1. Try 'kitten icat' (Modern) + # 2. Try 'icat' (Legacy/Alias) + # 3. Try 'kitty +kitten icat' (Fallback) + + cmd = [] + if which("kitten"): + cmd = ["kitten", "icat"] + elif which("icat"): + cmd = ["icat"] + elif which("kitty"): + cmd = ["kitty", "+kitten", "icat"] + + if not cmd: + return False + + # Build Arguments + args = [ + "--clear", + "--transfer-mode=memory", + "--unicode-placeholder", + "--stdin=no", + f"--place={width}x{height}@0x0", + ] + + if scale_up: + args.append("--scale-up") + + args.append(file_path) + + subprocess.run(cmd + args, stdout=sys.stdout, stderr=sys.stderr) + return True + + +def render_sixel(file_path, width, height): + """ + Render using Sixel. + Prioritizes 'chafa' for Sixel as it handles text-cell sizing better than img2sixel. + """ + + # Option A: Chafa (Best for Sixel sizing) + if which("chafa"): + # Chafa automatically detects Sixel support if terminal reports it, + # but we force it here if specifically requested via logic flow. + subprocess.run( + ["chafa", "-f", "sixel", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + # Option B: img2sixel (Libsixel) + # Note: img2sixel uses pixels, not cells. We estimate 1 cell ~= 10px width, 20px height + if which("img2sixel"): + pixel_width = width * 10 + pixel_height = height * 20 + subprocess.run( + [ + "img2sixel", + f"--width={pixel_width}", + f"--height={pixel_height}", + file_path, + ], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + return False + + +def render_iterm(file_path, width, height): + """Render using iTerm2 Inline Image Protocol.""" + if which("imgcat"): + subprocess.run( + ["imgcat", "-W", str(width), "-H", str(height), file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + # Chafa also supports iTerm + if which("chafa"): + subprocess.run( + ["chafa", "-f", "iterm", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def render_timg(file_path, width, height): + """Render using timg (supports half-blocks, quarter-blocks, sixel, kitty, etc).""" + if which("timg"): + subprocess.run( + ["timg", f"-g{width}x{height}", "--upscale", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def render_chafa_auto(file_path, width, height): + """ + Render using Chafa in auto mode. + It supports Sixel, Kitty, iTerm, and various unicode block modes. + """ + if which("chafa"): + subprocess.run( + ["chafa", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False def fzf_image_preview(file_path: str): - # Environment variables from fzf - FZF_PREVIEW_COLUMNS = os.environ.get("FZF_PREVIEW_COLUMNS") - FZF_PREVIEW_LINES = os.environ.get("FZF_PREVIEW_LINES") - FZF_PREVIEW_TOP = os.environ.get("FZF_PREVIEW_TOP") - KITTY_WINDOW_ID = os.environ.get("KITTY_WINDOW_ID") - GHOSTTY_BIN_DIR = os.environ.get("GHOSTTY_BIN_DIR") - PLATFORM = os.environ.get("PLATFORM") + """ + Main dispatch function to choose the best renderer. + """ + cols, lines = get_terminal_dimensions() - # Compute terminal dimensions - dim = ( - f"{FZF_PREVIEW_COLUMNS}x{FZF_PREVIEW_LINES}" - if FZF_PREVIEW_COLUMNS and FZF_PREVIEW_LINES - else "x" - ) + # Heuristic: Reserve 1 line for prompt/status if needed, though FZF handles this. + # Some renderers behave better with a tiny bit of padding. + width = cols + height = lines - if dim == "x": - try: - rows, cols = ( - subprocess.check_output( - ["stty", "size"], text=True, stderr=subprocess.DEVNULL - ) - .strip() - .split() + # --- 1. Check Explicit Configuration --- + + if IMAGE_RENDERER == "icat" or IMAGE_RENDERER == "system-kitty": + if render_kitty(file_path, width, height, SCALE_UP): + return + + elif IMAGE_RENDERER == "sixel" or IMAGE_RENDERER == "system-sixels": + if render_sixel(file_path, width, height): + return + + elif IMAGE_RENDERER == "imgcat": + if render_iterm(file_path, width, height): + return + + elif IMAGE_RENDERER == "timg": + if render_timg(file_path, width, height): + return + + elif IMAGE_RENDERER == "chafa": + if render_chafa_auto(file_path, width, height): + return + + # --- 2. Auto-Detection / Fallback Strategy --- + + # If explicit failed or set to 'auto'/'system-default', try detecting environment + + # Ghostty / Kitty Environment + if os.environ.get("KITTY_WINDOW_ID") or os.environ.get("GHOSTTY_BIN_DIR"): + if render_kitty(file_path, width, height, SCALE_UP): + return + + # iTerm Environment + if os.environ.get("TERM_PROGRAM") == "iTerm.app": + if render_iterm(file_path, width, height): + return + + # Try standard tools in order of quality/preference + if render_kitty(file_path, width, height, SCALE_UP): + return # Try kitty just in case + if render_sixel(file_path, width, height): + return + if render_timg(file_path, width, height): + return + if render_chafa_auto(file_path, width, height): + return + + print("⚠️ No suitable image renderer found (icat, chafa, timg, img2sixel).") + + +def fzf_text_info_render(): + """Renders the text-based info via the cached python script.""" + from rich.console import Console + from rich.rule import Rule + + console = Console(force_terminal=True, color_system="truecolor") + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": + preview_info_path = INFO_CACHE_DIR / f"{hash_id}.py" + if preview_info_path.exists(): + subprocess.run( + [sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR] ) - dim = f"{cols}x{rows}" - except Exception: - dim = "80x24" + else: + console.print("📝 Loading details...", style="dim") - # Adjust dimension if icat not used and preview area fills bottom of screen - if ( - IMAGE_RENDERER != "icat" - and not KITTY_WINDOW_ID - and FZF_PREVIEW_TOP - and FZF_PREVIEW_LINES + +def main(): + # 1. Image Preview + if (PREVIEW_MODE == "image" or PREVIEW_MODE == "full") and ( + PREFIX not in ("character", "review", "airing-schedule") ): - try: - term_rows = int( - subprocess.check_output(["stty", "size"], text=True).split()[0] - ) - if int(FZF_PREVIEW_TOP) + int(FZF_PREVIEW_LINES) == term_rows: - dim = f"{FZF_PREVIEW_COLUMNS}x{int(FZF_PREVIEW_LINES) - 1}" - except Exception: - pass - - # Helper to run commands - def run(cmd): - subprocess.run(cmd, stdout=sys.stdout, stderr=sys.stderr) - - def command_exists(cmd): - return shutil.which(cmd) is not None - - # ICAT / KITTY path - if IMAGE_RENDERER == "icat" and not GHOSTTY_BIN_DIR: - icat_cmd = None - if command_exists("kitten"): - icat_cmd = ["kitten", "icat"] - elif command_exists("icat"): - icat_cmd = ["icat"] - elif command_exists("kitty"): - icat_cmd = ["kitty", "icat"] - - if icat_cmd: - run( - icat_cmd - + [ - "--clear", - "--transfer-mode=memory", - "--unicode-placeholder", - "--stdin=no", - f"--place={dim}@0x0", - file_path, - ] - ) + preview_image_path = IMAGE_CACHE_DIR / f"{hash_id}.png" + if preview_image_path.exists(): + fzf_image_preview(str(preview_image_path)) + print() # Spacer else: - print("No icat-compatible viewer found (kitten/icat/kitty)") + print("🖼️ Loading image...") - elif GHOSTTY_BIN_DIR: - try: - cols = int(FZF_PREVIEW_COLUMNS or "80") - 1 - lines = FZF_PREVIEW_LINES or "24" - dim = f"{cols}x{lines}" - except Exception: - pass - - if command_exists("kitten"): - run( - [ - "kitten", - "icat", - "--clear", - "--transfer-mode=memory", - "--unicode-placeholder", - "--stdin=no", - f"--place={dim}@0x0", - file_path, - ] - ) - elif command_exists("icat"): - run( - [ - "icat", - "--clear", - "--transfer-mode=memory", - "--unicode-placeholder", - "--stdin=no", - f"--place={dim}@0x0", - file_path, - ] - ) - elif command_exists("chafa"): - run(["chafa", "-s", dim, file_path]) - - elif command_exists("chafa"): - # Platform specific rendering - if PLATFORM == "android": - run(["chafa", "-s", dim, file_path]) - elif PLATFORM == "windows": - run(["chafa", "-f", "sixel", "-s", dim, file_path]) - else: - run(["chafa", "-s", dim, file_path]) - print() - - elif command_exists("imgcat"): - width, height = dim.split("x") - run(["imgcat", "-W", width, "-H", height, file_path]) - - else: - print( - "⚠️ Please install a terminal image viewer (icat, kitten, imgcat, or chafa)." - ) + # 2. Text Info Preview + fzf_text_info_render() -def fzf_text_preview(file_path: str): - from base64 import standard_b64encode - - def serialize_gr_command(**cmd): - payload = cmd.pop("payload", None) - cmd = ",".join(f"{k}={v}" for k, v in cmd.items()) - ans = [] - w = ans.append - w(b"\033_G") - w(cmd.encode("ascii")) - if payload: - w(b";") - w(payload) - w(b"\033\\") - return b"".join(ans) - - def write_chunked(**cmd): - data = standard_b64encode(cmd.pop("data")) - while data: - chunk, data = data[:4096], data[4096:] - m = 1 if data else 0 - sys.stdout.buffer.write(serialize_gr_command(payload=chunk, m=m, **cmd)) - sys.stdout.flush() - cmd.clear() - - with open(file_path, "rb") as f: - write_chunked(a="T", f=100, data=f.read()) - - -console = Console(force_terminal=True, color_system="truecolor") -if (PREVIEW_MODE == "image" or PREVIEW_MODE == "full") and ( - PREFIX not in ("character", "review", "airing-schedule") -): - preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png" - if preview_image_path.exists(): - fzf_image_preview(str(preview_image_path)) - print() - else: - print("🖼️ Loading image...") - -console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) -if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": - preview_info_path = INFO_CACHE_DIR / f"{hash}.py" - if preview_info_path.exists(): - subprocess.run( - [sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR] - ) - else: - console.print("📝 Loading details...") +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass + except Exception as e: + print(f"Preview Error: {e}") From 901d1e87c52a39d782e42e4a631efdecc367d436 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 18:47:58 +0300 Subject: [PATCH 34/48] feat: rewrite FZF preview scripts to use ANSI utilities for improved formatting --- viu_media/assets/scripts/fzf/_ansi_utils.py | 152 ++++++++++++++++++ .../scripts/fzf/airing_schedule_info.py | 48 +++--- .../assets/scripts/fzf/character_info.py | 59 ++++--- viu_media/assets/scripts/fzf/episode_info.py | 68 ++++---- viu_media/assets/scripts/fzf/media_info.py | 151 +++++++++-------- viu_media/assets/scripts/fzf/preview.py | 12 +- viu_media/assets/scripts/fzf/review_info.py | 42 ++--- viu_media/cli/utils/preview.py | 16 ++ 8 files changed, 348 insertions(+), 200 deletions(-) create mode 100644 viu_media/assets/scripts/fzf/_ansi_utils.py diff --git a/viu_media/assets/scripts/fzf/_ansi_utils.py b/viu_media/assets/scripts/fzf/_ansi_utils.py new file mode 100644 index 0000000..4d1d289 --- /dev/null +++ b/viu_media/assets/scripts/fzf/_ansi_utils.py @@ -0,0 +1,152 @@ +""" +ANSI utilities for FZF preview scripts. + +Lightweight stdlib-only utilities to replace Rich dependency in preview scripts. +Provides RGB color formatting, table rendering, and markdown stripping. +""" + +import re +import shutil +import textwrap + + +def rgb_color(r: int, g: int, b: int, text: str, bold: bool = False) -> str: + """ + Format text with RGB color using ANSI escape codes. + + Args: + r: Red component (0-255) + g: Green component (0-255) + b: Blue component (0-255) + text: Text to colorize + bold: Whether to make text bold + + Returns: + ANSI-escaped colored text + """ + color_code = f"\x1b[38;2;{r};{g};{b}m" + bold_code = "\x1b[1m" if bold else "" + reset = "\x1b[0m" + return f"{color_code}{bold_code}{text}{reset}" + + +def parse_color(color_csv: str) -> tuple[int, int, int]: + """ + Parse RGB color from comma-separated string. + + Args: + color_csv: Color as 'R,G,B' string + + Returns: + Tuple of (r, g, b) integers + """ + parts = color_csv.split(",") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def print_rule(sep_color: str) -> None: + """ + Print a horizontal rule line. + + Args: + sep_color: Color as 'R,G,B' string + """ + width = shutil.get_terminal_size((80, 24)).columns + r, g, b = parse_color(sep_color) + print(rgb_color(r, g, b, "─" * width)) + + +def print_table_row( + key: str, value: str, header_color: str, key_width: int, value_width: int +) -> None: + """ + Print a two-column table row with left-aligned key and right-aligned value. + + Args: + key: Left column text (header/key) + value: Right column text (value) + header_color: Color for key as 'R,G,B' string + key_width: Width for key column + value_width: Width for value column + """ + r, g, b = parse_color(header_color) + key_styled = rgb_color(r, g, b, key, bold=True) + + # Ensure minimum width to avoid textwrap errors + safe_value_width = max(20, value_width) + + # Wrap value if it's too long + value_lines = textwrap.wrap(str(value), width=safe_value_width) if value else [""] + + if not value_lines: + value_lines = [""] + + # Print first line with right-aligned value + first_line = value_lines[0] + print(f"{key_styled:<{key_width + 20}} {first_line:>{safe_value_width}}") + + # Print remaining wrapped lines (left-aligned, indented) + for line in value_lines[1:]: + print(f"{' ' * (key_width + 2)}{line}") +def strip_markdown(text: str) -> str: + """ + Strip markdown formatting from text. + + Removes: + - Headers (# ## ###) + - Bold (**text** or __text__) + - Italic (*text* or _text_) + - Links ([text](url)) + - Code blocks (```code```) + - Inline code (`code`) + + Args: + text: Markdown-formatted text + + Returns: + Plain text with markdown removed + """ + if not text: + return "" + + # Remove code blocks first + text = re.sub(r"```[\s\S]*?```", "", text) + + # Remove inline code + text = re.sub(r"`([^`]+)`", r"\1", text) + + # Remove headers + text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) + + # Remove bold (** or __) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + + # Remove italic (* or _) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"_(.+?)_", r"\1", text) + + # Remove links, keep text + text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) + + # Remove images + text = re.sub(r"!\[.*?\]\(.+?\)", "", text) + + return text.strip() + + +def wrap_text(text: str, width: int | None = None) -> str: + """ + Wrap text to terminal width. + + Args: + text: Text to wrap + width: Width to wrap to (defaults to terminal width) + + Returns: + Wrapped text + """ + if width is None: + width = shutil.get_terminal_size((80, 24)).columns + + return textwrap.fill(text, width=width) diff --git a/viu_media/assets/scripts/fzf/airing_schedule_info.py b/viu_media/assets/scripts/fzf/airing_schedule_info.py index e11ff94..eb29f0b 100644 --- a/viu_media/assets/scripts/fzf/airing_schedule_info.py +++ b/viu_media/assets/scripts/fzf/airing_schedule_info.py @@ -1,41 +1,31 @@ import sys -from rich.console import Console -from rich.table import Table -from rich.rule import Rule -from rich.markdown import Markdown - -console = Console(force_terminal=True, color_system="truecolor") +import shutil +from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] +# Get terminal dimensions +term_width = shutil.get_terminal_size((80, 24)).columns -def rule(title: str | None = None): - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) +# Print title centered +print("{ANIME_TITLE}".center(term_width)) - -console.print("{ANIME_TITLE}", justify="center") - -left = [ - ("Total Episodes",), - ("Upcoming Episodes",), -] -right = [ - ("{TOTAL_EPISODES}",), - ("{UPCOMING_EPISODES}",), +rows = [ + ("Total Episodes", "{TOTAL_EPISODES}"), ] +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) -for L_grp, R_grp in zip(left, right): - table = Table.grid(expand=True) - table.add_column(justify="left", no_wrap=True) - table.add_column(justify="right", overflow="fold") - for L, R in zip(L_grp, R_grp): - table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") +rows = [ + ("Upcoming Episodes", "{UPCOMING_EPISODES}"), +] - rule() - console.print(table) +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - -rule() -console.print(Markdown("""{SCHEDULE_TABLE}""")) +print_rule(SEPARATOR_COLOR) +print(wrap_text(strip_markdown("""{SCHEDULE_TABLE}"""), term_width)) diff --git a/viu_media/assets/scripts/fzf/character_info.py b/viu_media/assets/scripts/fzf/character_info.py index 3880fc6..a60de67 100644 --- a/viu_media/assets/scripts/fzf/character_info.py +++ b/viu_media/assets/scripts/fzf/character_info.py @@ -1,43 +1,42 @@ import sys -from rich.console import Console -from rich.table import Table -from rich.rule import Rule -from rich.markdown import Markdown - -console = Console(force_terminal=True, color_system="truecolor") +import shutil +from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] +# Get terminal dimensions +term_width = shutil.get_terminal_size((80, 24)).columns -def rule(title: str | None = None): - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) +# Print title centered +print("{CHARACTER_NAME}".center(term_width)) - -console.print("{CHARACTER_NAME}", justify="center") - -left = [ - ("Native Name", "Gender"), - ("Age", "Blood Type"), - ("Birthday", "Favourites"), -] -right = [ - ("{CHARACTER_NATIVE_NAME}", "{CHARACTER_GENDER}"), - ("{CHARACTER_AGE}", "{CHARACTER_BLOOD_TYPE}"), - ("{CHARACTER_BIRTHDAY}", "{CHARACTER_FAVOURITES}"), +rows = [ + ("Native Name", "{CHARACTER_NATIVE_NAME}"), + ("Gender", "{CHARACTER_GENDER}"), ] +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) -for L_grp, R_grp in zip(left, right): - table = Table.grid(expand=True) - table.add_column(justify="left", no_wrap=True) - table.add_column(justify="right", overflow="fold") - for L, R in zip(L_grp, R_grp): - table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") +rows = [ + ("Age", "{CHARACTER_AGE}"), + ("Blood Type", "{CHARACTER_BLOOD_TYPE}"), +] - rule() - console.print(table) +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) +rows = [ + ("Birthday", "{CHARACTER_BIRTHDAY}"), + ("Favourites", "{CHARACTER_FAVOURITES}"), +] -rule() -console.print(Markdown("""{CHARACTER_DESCRIPTION}""")) +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +print_rule(SEPARATOR_COLOR) +print(wrap_text(strip_markdown("""{CHARACTER_DESCRIPTION}"""), term_width)) diff --git a/viu_media/assets/scripts/fzf/episode_info.py b/viu_media/assets/scripts/fzf/episode_info.py index 471be00..825513c 100644 --- a/viu_media/assets/scripts/fzf/episode_info.py +++ b/viu_media/assets/scripts/fzf/episode_info.py @@ -1,44 +1,50 @@ import sys -from rich.console import Console -from rich.table import Table -from rich.rule import Rule -from rich.markdown import Markdown - -console = Console(force_terminal=True, color_system="truecolor") +import shutil +from _ansi_utils import print_rule, print_table_row HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] +# Get terminal dimensions +term_width = shutil.get_terminal_size((80, 24)).columns -def rule(title: str | None = None): - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) +# Print title centered +print("{TITLE}".center(term_width)) - -console.print("{TITLE}", justify="center") - -left = [ - ("Duration", "Status"), - ("Total Episodes", "Next Episode"), - ("Progress", "List Status"), - ("Start Date", "End Date"), -] -right = [ - ("{DURATION}", "{STATUS}"), - ("{EPISODES}", "{NEXT_EPISODE}"), - ("{USER_PROGRESS}", "{USER_STATUS}"), - ("{START_DATE}", "{END_DATE}"), +rows = [ + ("Duration", "{DURATION}"), + ("Status", "{STATUS}"), ] +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) -for L_grp, R_grp in zip(left, right): - table = Table.grid(expand=True) - table.add_column(justify="left", no_wrap=True) - table.add_column(justify="right", overflow="fold") - for L, R in zip(L_grp, R_grp): - table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") +rows = [ + ("Total Episodes", "{EPISODES}"), + ("Next Episode", "{NEXT_EPISODE}"), +] - rule() - console.print(table) +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) +rows = [ + ("Progress", "{USER_PROGRESS}"), + ("List Status", "{USER_STATUS}"), +] -rule() +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +rows = [ + ("Start Date", "{START_DATE}"), + ("End Date", "{END_DATE}"), +] + +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +print_rule(SEPARATOR_COLOR) diff --git a/viu_media/assets/scripts/fzf/media_info.py b/viu_media/assets/scripts/fzf/media_info.py index 6d575ac..f7dd7d1 100644 --- a/viu_media/assets/scripts/fzf/media_info.py +++ b/viu_media/assets/scripts/fzf/media_info.py @@ -1,89 +1,88 @@ import sys -from rich.console import Console -from rich.table import Table -from rich.rule import Rule -from rich.markdown import Markdown - -console = Console(force_terminal=True, color_system="truecolor") +import shutil +from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] +# Get terminal dimensions +term_width = shutil.get_terminal_size((80, 24)).columns -def rule(title: str | None = None): - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) +# Print title centered +print("{TITLE}".center(term_width)) - -console.print("{TITLE}", justify="center") - -left = [ - ( - "Score", - "Favorites", - "Popularity", - "Status", - ), - ( - "Episodes", - "Duration", - "Next Episode", - ), - ( - "Genres", - "Format", - ), - ( - "List Status", - "Progress", - ), - ( - "Start Date", - "End Date", - ), - ("Studios",), - ("Synonymns",), - ("Tags",), -] -right = [ - ( - "{SCORE}", - "{FAVOURITES}", - "{POPULARITY}", - "{STATUS}", - ), - ( - "{EPISODES}", - "{DURATION}", - "{NEXT_EPISODE}", - ), - ( - "{GENRES}", - "{FORMAT}", - ), - ( - "{USER_STATUS}", - "{USER_PROGRESS}", - ), - ( - "{START_DATE}", - "{END_DATE}", - ), - ("{STUDIOS}",), - ("{SYNONYMNS}",), - ("{TAGS}",), +# Define table data +rows = [ + ("Score", "{SCORE}"), + ("Favorites", "{FAVOURITES}"), + ("Popularity", "{POPULARITY}"), + ("Status", "{STATUS}"), ] +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) -for L_grp, R_grp in zip(left, right): - table = Table.grid(expand=True) - table.add_column(justify="left", no_wrap=True) - table.add_column(justify="right", overflow="fold") - for L, R in zip(L_grp, R_grp): - table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") +rows = [ + ("Episodes", "{EPISODES}"), + ("Duration", "{DURATION}"), + ("Next Episode", "{NEXT_EPISODE}"), +] - rule() - console.print(table) +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) +rows = [ + ("Genres", "{GENRES}"), + ("Format", "{FORMAT}"), +] -rule() -console.print(Markdown("""{SYNOPSIS}""")) +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +rows = [ + ("List Status", "{USER_STATUS}"), + ("Progress", "{USER_PROGRESS}"), +] + +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +rows = [ + ("Start Date", "{START_DATE}"), + ("End Date", "{END_DATE}"), +] + +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +rows = [ + ("Studios", "{STUDIOS}"), +] + +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +rows = [ + ("Synonymns", "{SYNONYMNS}"), +] + +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +rows = [ + ("Tags", "{TAGS}"), +] + +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + +print_rule(SEPARATOR_COLOR) +print(wrap_text(strip_markdown("""{SYNOPSIS}"""), term_width)) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index e90f27f..8c4b064 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -244,11 +244,12 @@ def fzf_image_preview(file_path: str): def fzf_text_info_render(): """Renders the text-based info via the cached python script.""" - from rich.console import Console - from rich.rule import Rule + import shutil - console = Console(force_terminal=True, color_system="truecolor") - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + # Print simple separator line + width = shutil.get_terminal_size((80, 24)).columns + r, g, b = map(int, SEPARATOR_COLOR.split(",")) + print(f"\x1b[38;2;{r};{g};{b}m" + "─" * width + "\x1b[0m") if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": preview_info_path = INFO_CACHE_DIR / f"{hash_id}.py" @@ -257,7 +258,8 @@ def fzf_text_info_render(): [sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR] ) else: - console.print("📝 Loading details...", style="dim") + # Print dim text + print("\x1b[2m📝 Loading details...\x1b[0m") def main(): diff --git a/viu_media/assets/scripts/fzf/review_info.py b/viu_media/assets/scripts/fzf/review_info.py index fb13de7..e7fdbdc 100644 --- a/viu_media/assets/scripts/fzf/review_info.py +++ b/viu_media/assets/scripts/fzf/review_info.py @@ -1,39 +1,23 @@ import sys -from rich.console import Console -from rich.table import Table -from rich.rule import Rule -from rich.markdown import Markdown - -console = Console(force_terminal=True, color_system="truecolor") +import shutil +from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] +# Get terminal dimensions +term_width = shutil.get_terminal_size((80, 24)).columns -def rule(title: str | None = None): - console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) +# Print title centered +print("{REVIEWER_NAME}".center(term_width)) - -console.print("{REVIEWER_NAME}", justify="center") - -left = [ - ("Summary",), -] -right = [ - ("{REVIEW_SUMMARY}",), +rows = [ + ("Summary", "{REVIEW_SUMMARY}"), ] +print_rule(SEPARATOR_COLOR) +for key, value in rows: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) -for L_grp, R_grp in zip(left, right): - table = Table.grid(expand=True) - table.add_column(justify="left", no_wrap=True) - table.add_column(justify="right", overflow="fold") - for L, R in zip(L_grp, R_grp): - table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}") - - rule() - console.print(table) - - -rule() -console.print(Markdown("""{REVIEW_BODY}""")) +print_rule(SEPARATOR_COLOR) +print(wrap_text(strip_markdown("""{REVIEW_BODY}"""), term_width)) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index e0dd5bc..37c29ab 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -135,6 +135,20 @@ EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*") _preview_manager: Optional[PreviewWorkerManager] = None +def _ensure_ansi_utils_in_cache(): + """Copy _ansi_utils.py to the info cache directory so cached scripts can import it.""" + source = FZF_SCRIPTS_DIR / "_ansi_utils.py" + dest = INFO_CACHE_DIR / "_ansi_utils.py" + + if source.exists() and (not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime): + try: + import shutil + shutil.copy2(source, dest) + logger.debug(f"Copied _ansi_utils.py to {INFO_CACHE_DIR}") + except Exception as e: + logger.warning(f"Failed to copy _ansi_utils.py to cache: {e}") + + def create_preview_context(): """ Create a context manager for preview operations. @@ -270,6 +284,7 @@ def get_anime_preview( # Ensure cache directories exist on startup IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + _ensure_ansi_utils_in_cache() HEADER_COLOR = config.fzf.preview_header_color.split(",") SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") @@ -527,6 +542,7 @@ def get_dynamic_anime_preview(config: AppConfig) -> str: # Ensure cache directories exist IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + _ensure_ansi_utils_in_cache() HEADER_COLOR = config.fzf.preview_header_color.split(",") SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") From 26bc84e2ebf5d91d60e46e832dfb5c1481979c12 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 1 Dec 2025 18:48:15 +0300 Subject: [PATCH 35/48] fix: clean up whitespace in ANSI utilities and preview script --- viu_media/assets/scripts/fzf/_ansi_utils.py | 12 +++++++----- viu_media/cli/utils/preview.py | 7 +++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/viu_media/assets/scripts/fzf/_ansi_utils.py b/viu_media/assets/scripts/fzf/_ansi_utils.py index 4d1d289..a6b6d32 100644 --- a/viu_media/assets/scripts/fzf/_ansi_utils.py +++ b/viu_media/assets/scripts/fzf/_ansi_utils.py @@ -71,23 +71,25 @@ def print_table_row( """ r, g, b = parse_color(header_color) key_styled = rgb_color(r, g, b, key, bold=True) - + # Ensure minimum width to avoid textwrap errors safe_value_width = max(20, value_width) - + # Wrap value if it's too long value_lines = textwrap.wrap(str(value), width=safe_value_width) if value else [""] - + if not value_lines: value_lines = [""] - + # Print first line with right-aligned value first_line = value_lines[0] print(f"{key_styled:<{key_width + 20}} {first_line:>{safe_value_width}}") - + # Print remaining wrapped lines (left-aligned, indented) for line in value_lines[1:]: print(f"{' ' * (key_width + 2)}{line}") + + def strip_markdown(text: str) -> str: """ Strip markdown formatting from text. diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 37c29ab..c719324 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -139,10 +139,13 @@ def _ensure_ansi_utils_in_cache(): """Copy _ansi_utils.py to the info cache directory so cached scripts can import it.""" source = FZF_SCRIPTS_DIR / "_ansi_utils.py" dest = INFO_CACHE_DIR / "_ansi_utils.py" - - if source.exists() and (not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime): + + if source.exists() and ( + not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime + ): try: import shutil + shutil.copy2(source, dest) logger.debug(f"Copied _ansi_utils.py to {INFO_CACHE_DIR}") except Exception as e: From 803c8316a786bc4d4fe7c3d6e7fc25ff69a76156 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 13:04:25 +0300 Subject: [PATCH 36/48] fix: improve value alignment in print_table_row for better formatting --- viu_media/assets/scripts/fzf/_ansi_utils.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/viu_media/assets/scripts/fzf/_ansi_utils.py b/viu_media/assets/scripts/fzf/_ansi_utils.py index a6b6d32..990a9fa 100644 --- a/viu_media/assets/scripts/fzf/_ansi_utils.py +++ b/viu_media/assets/scripts/fzf/_ansi_utils.py @@ -72,22 +72,30 @@ def print_table_row( r, g, b = parse_color(header_color) key_styled = rgb_color(r, g, b, key, bold=True) - # Ensure minimum width to avoid textwrap errors - safe_value_width = max(20, value_width) + # Get actual terminal width + term_width = shutil.get_terminal_size((80, 24)).columns + + # Calculate actual value width based on terminal and key + actual_value_width = max(20, term_width - len(key) - 2) # Wrap value if it's too long - value_lines = textwrap.wrap(str(value), width=safe_value_width) if value else [""] + value_lines = textwrap.wrap(str(value), width=actual_value_width) if value else [""] if not value_lines: value_lines = [""] - # Print first line with right-aligned value + # Print first line with properly aligned value first_line = value_lines[0] - print(f"{key_styled:<{key_width + 20}} {first_line:>{safe_value_width}}") + # Use manual spacing to right-align + spacing = term_width - len(key) - len(first_line) - 2 + if spacing > 0: + print(f"{key_styled} {' ' * spacing}{first_line}") + else: + print(f"{key_styled} {first_line}") # Print remaining wrapped lines (left-aligned, indented) for line in value_lines[1:]: - print(f"{' ' * (key_width + 2)}{line}") + print(f"{' ' * (len(key) + 2)}{line}") def strip_markdown(text: str) -> str: From 1f72e0a57907cf67ba8895fb0ab13becbe83232f Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 13:07:55 +0300 Subject: [PATCH 37/48] feat: enhance display width calculation for better text alignment in print_table_row --- viu_media/assets/scripts/fzf/_ansi_utils.py | 38 +++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/viu_media/assets/scripts/fzf/_ansi_utils.py b/viu_media/assets/scripts/fzf/_ansi_utils.py index 990a9fa..c943023 100644 --- a/viu_media/assets/scripts/fzf/_ansi_utils.py +++ b/viu_media/assets/scripts/fzf/_ansi_utils.py @@ -8,6 +8,27 @@ Provides RGB color formatting, table rendering, and markdown stripping. import re import shutil import textwrap +import unicodedata + + +def display_width(text: str) -> int: + """ + Calculate the actual display width of text, accounting for wide characters. + + Args: + text: Text to measure + + Returns: + Display width in terminal columns + """ + width = 0 + for char in text: + # East Asian Width property: 'F' (Fullwidth) and 'W' (Wide) take 2 columns + if unicodedata.east_asian_width(char) in ('F', 'W'): + width += 2 + else: + width += 1 + return width def rgb_color(r: int, g: int, b: int, text: str, bold: bool = False) -> str: @@ -75,10 +96,13 @@ def print_table_row( # Get actual terminal width term_width = shutil.get_terminal_size((80, 24)).columns - # Calculate actual value width based on terminal and key - actual_value_width = max(20, term_width - len(key) - 2) + # Calculate display widths accounting for wide characters + key_display_width = display_width(key) + + # Calculate actual value width based on terminal and key display width + actual_value_width = max(20, term_width - key_display_width - 2) - # Wrap value if it's too long + # Wrap value if it's too long (use character count, not display width for wrapping) value_lines = textwrap.wrap(str(value), width=actual_value_width) if value else [""] if not value_lines: @@ -86,8 +110,10 @@ def print_table_row( # Print first line with properly aligned value first_line = value_lines[0] - # Use manual spacing to right-align - spacing = term_width - len(key) - len(first_line) - 2 + first_line_display_width = display_width(first_line) + + # Use manual spacing to right-align based on display width + spacing = term_width - key_display_width - first_line_display_width - 2 if spacing > 0: print(f"{key_styled} {' ' * spacing}{first_line}") else: @@ -95,7 +121,7 @@ def print_table_row( # Print remaining wrapped lines (left-aligned, indented) for line in value_lines[1:]: - print(f"{' ' * (len(key) + 2)}{line}") + print(f"{' ' * (key_display_width + 2)}{line}") def strip_markdown(text: str) -> str: From f4958cc0cc6e66ddf07f1f3a6f43e2b4ee73fa70 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 13:14:19 +0300 Subject: [PATCH 38/48] fix: clean up whitespace in display_width and print_table_row functions --- viu_media/assets/scripts/fzf/_ansi_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/viu_media/assets/scripts/fzf/_ansi_utils.py b/viu_media/assets/scripts/fzf/_ansi_utils.py index c943023..05a9048 100644 --- a/viu_media/assets/scripts/fzf/_ansi_utils.py +++ b/viu_media/assets/scripts/fzf/_ansi_utils.py @@ -14,17 +14,17 @@ import unicodedata def display_width(text: str) -> int: """ Calculate the actual display width of text, accounting for wide characters. - + Args: text: Text to measure - + Returns: Display width in terminal columns """ width = 0 for char in text: # East Asian Width property: 'F' (Fullwidth) and 'W' (Wide) take 2 columns - if unicodedata.east_asian_width(char) in ('F', 'W'): + if unicodedata.east_asian_width(char) in ("F", "W"): width += 2 else: width += 1 @@ -98,7 +98,7 @@ def print_table_row( # Calculate display widths accounting for wide characters key_display_width = display_width(key) - + # Calculate actual value width based on terminal and key display width actual_value_width = max(20, term_width - key_display_width - 2) @@ -111,7 +111,7 @@ def print_table_row( # Print first line with properly aligned value first_line = value_lines[0] first_line_display_width = display_width(first_line) - + # Use manual spacing to right-align based on display width spacing = term_width - key_display_width - first_line_display_width - 2 if spacing > 0: From c8c4e1b2c07e6390200997e1d8ad8dc5d2b0d43f Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 13:30:03 +0300 Subject: [PATCH 39/48] feat: refactor terminal width handling in FZF scripts for improved consistency --- viu_media/assets/scripts/fzf/_ansi_utils.py | 20 ++++++++++++++++--- .../scripts/fzf/airing_schedule_info.py | 11 +++++++--- .../assets/scripts/fzf/character_info.py | 11 +++++++--- viu_media/assets/scripts/fzf/episode_info.py | 5 ++--- viu_media/assets/scripts/fzf/media_info.py | 11 +++++++--- viu_media/assets/scripts/fzf/preview.py | 9 +++++---- viu_media/assets/scripts/fzf/review_info.py | 11 +++++++--- 7 files changed, 56 insertions(+), 22 deletions(-) diff --git a/viu_media/assets/scripts/fzf/_ansi_utils.py b/viu_media/assets/scripts/fzf/_ansi_utils.py index 05a9048..a31605d 100644 --- a/viu_media/assets/scripts/fzf/_ansi_utils.py +++ b/viu_media/assets/scripts/fzf/_ansi_utils.py @@ -5,12 +5,26 @@ Lightweight stdlib-only utilities to replace Rich dependency in preview scripts. Provides RGB color formatting, table rendering, and markdown stripping. """ +import os import re import shutil import textwrap import unicodedata +def get_terminal_width() -> int: + """ + Get terminal width, prioritizing FZF preview environment variables. + + Returns: + Terminal width in columns + """ + fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS") + if fzf_cols: + return int(fzf_cols) + return shutil.get_terminal_size((80, 24)).columns + + def display_width(text: str) -> int: """ Calculate the actual display width of text, accounting for wide characters. @@ -72,7 +86,7 @@ def print_rule(sep_color: str) -> None: Args: sep_color: Color as 'R,G,B' string """ - width = shutil.get_terminal_size((80, 24)).columns + width = get_terminal_width() r, g, b = parse_color(sep_color) print(rgb_color(r, g, b, "─" * width)) @@ -94,7 +108,7 @@ def print_table_row( key_styled = rgb_color(r, g, b, key, bold=True) # Get actual terminal width - term_width = shutil.get_terminal_size((80, 24)).columns + term_width = get_terminal_width() # Calculate display widths accounting for wide characters key_display_width = display_width(key) @@ -183,6 +197,6 @@ def wrap_text(text: str, width: int | None = None) -> str: Wrapped text """ if width is None: - width = shutil.get_terminal_size((80, 24)).columns + width = get_terminal_width() return textwrap.fill(text, width=width) diff --git a/viu_media/assets/scripts/fzf/airing_schedule_info.py b/viu_media/assets/scripts/fzf/airing_schedule_info.py index eb29f0b..11cc64f 100644 --- a/viu_media/assets/scripts/fzf/airing_schedule_info.py +++ b/viu_media/assets/scripts/fzf/airing_schedule_info.py @@ -1,12 +1,17 @@ import sys -import shutil -from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text +from _ansi_utils import ( + print_rule, + print_table_row, + strip_markdown, + wrap_text, + get_terminal_width, +) HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] # Get terminal dimensions -term_width = shutil.get_terminal_size((80, 24)).columns +term_width = get_terminal_width() # Print title centered print("{ANIME_TITLE}".center(term_width)) diff --git a/viu_media/assets/scripts/fzf/character_info.py b/viu_media/assets/scripts/fzf/character_info.py index a60de67..f99aed5 100644 --- a/viu_media/assets/scripts/fzf/character_info.py +++ b/viu_media/assets/scripts/fzf/character_info.py @@ -1,12 +1,17 @@ import sys -import shutil -from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text +from _ansi_utils import ( + print_rule, + print_table_row, + strip_markdown, + wrap_text, + get_terminal_width, +) HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] # Get terminal dimensions -term_width = shutil.get_terminal_size((80, 24)).columns +term_width = get_terminal_width() # Print title centered print("{CHARACTER_NAME}".center(term_width)) diff --git a/viu_media/assets/scripts/fzf/episode_info.py b/viu_media/assets/scripts/fzf/episode_info.py index 825513c..c0bd03c 100644 --- a/viu_media/assets/scripts/fzf/episode_info.py +++ b/viu_media/assets/scripts/fzf/episode_info.py @@ -1,12 +1,11 @@ import sys -import shutil -from _ansi_utils import print_rule, print_table_row +from _ansi_utils import print_rule, print_table_row, get_terminal_width HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] # Get terminal dimensions -term_width = shutil.get_terminal_size((80, 24)).columns +term_width = get_terminal_width() # Print title centered print("{TITLE}".center(term_width)) diff --git a/viu_media/assets/scripts/fzf/media_info.py b/viu_media/assets/scripts/fzf/media_info.py index f7dd7d1..c3c4b27 100644 --- a/viu_media/assets/scripts/fzf/media_info.py +++ b/viu_media/assets/scripts/fzf/media_info.py @@ -1,12 +1,17 @@ import sys -import shutil -from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text +from _ansi_utils import ( + print_rule, + print_table_row, + strip_markdown, + wrap_text, + get_terminal_width, +) HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] # Get terminal dimensions -term_width = shutil.get_terminal_size((80, 24)).columns +term_width = get_terminal_width() # Print title centered print("{TITLE}".center(term_width)) diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index 8c4b064..de500c7 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -244,12 +244,13 @@ def fzf_image_preview(file_path: str): def fzf_text_info_render(): """Renders the text-based info via the cached python script.""" - import shutil + # Get terminal dimensions from FZF environment or fallback + cols, lines = get_terminal_dimensions() - # Print simple separator line - width = shutil.get_terminal_size((80, 24)).columns + # Print simple separator line with proper width r, g, b = map(int, SEPARATOR_COLOR.split(",")) - print(f"\x1b[38;2;{r};{g};{b}m" + "─" * width + "\x1b[0m") + separator = f"\x1b[38;2;{r};{g};{b}m" + ("─" * cols) + "\x1b[0m" + print(separator, flush=True) if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": preview_info_path = INFO_CACHE_DIR / f"{hash_id}.py" diff --git a/viu_media/assets/scripts/fzf/review_info.py b/viu_media/assets/scripts/fzf/review_info.py index e7fdbdc..6c2decc 100644 --- a/viu_media/assets/scripts/fzf/review_info.py +++ b/viu_media/assets/scripts/fzf/review_info.py @@ -1,12 +1,17 @@ import sys -import shutil -from _ansi_utils import print_rule, print_table_row, strip_markdown, wrap_text +from _ansi_utils import ( + print_rule, + print_table_row, + strip_markdown, + wrap_text, + get_terminal_width, +) HEADER_COLOR = sys.argv[1] SEPARATOR_COLOR = sys.argv[2] # Get terminal dimensions -term_width = shutil.get_terminal_size((80, 24)).columns +term_width = get_terminal_width() # Print title centered print("{REVIEWER_NAME}".center(term_width)) From 80771f65ea73af0f05f46811ffae644f2e831fed Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 14:36:03 +0300 Subject: [PATCH 40/48] feat: dynamic search rewrite in python --- .../assets/scripts/fzf/dynamic_preview.py | 474 ++++++++++++++++++ viu_media/assets/scripts/fzf/search.py | 153 ++++++ .../interactive/menu/media/dynamic_search.py | 22 +- viu_media/cli/utils/preview.py | 39 +- 4 files changed, 661 insertions(+), 27 deletions(-) create mode 100755 viu_media/assets/scripts/fzf/dynamic_preview.py create mode 100755 viu_media/assets/scripts/fzf/search.py diff --git a/viu_media/assets/scripts/fzf/dynamic_preview.py b/viu_media/assets/scripts/fzf/dynamic_preview.py new file mode 100755 index 0000000..9085466 --- /dev/null +++ b/viu_media/assets/scripts/fzf/dynamic_preview.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +# +# FZF Dynamic Preview Script for Search Results +# +# This script handles previews for dynamic search by reading from the cached +# search results JSON and generating preview content on-the-fly. +# Template variables are injected by Python using .replace() + +import json +import os +import shutil +import subprocess +import sys +from hashlib import sha256 +from pathlib import Path + +# Import the utility functions +try: + from _ansi_utils import ( + get_terminal_width, + print_rule, + print_table_row, + strip_markdown, + wrap_text, + ) + ANSI_UTILS_AVAILABLE = True +except ImportError: + ANSI_UTILS_AVAILABLE = False + # Fallback if _ansi_utils is not available + def get_terminal_width(): + return int(os.environ.get("FZF_PREVIEW_COLUMNS", "80")) + + def print_rule(sep_color): + r, g, b = map(int, sep_color.split(",")) + width = get_terminal_width() + print(f"\x1b[38;2;{r};{g};{b}m" + ("─" * width) + "\x1b[0m") + + def print_table_row(key, value, header_color, _key_width, _value_width): + r, g, b = map(int, header_color.split(",")) + print(f"\x1b[38;2;{r};{g};{b};1m{key}\x1b[0m: {value}") + + def strip_markdown(text): + import re + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) + return text + + def wrap_text(text, width): + import textwrap + return '\n'.join(textwrap.wrap(text, width)) + +# --- Template Variables (Injected by Python) --- +SEARCH_RESULTS_FILE = Path("{SEARCH_RESULTS_FILE}") +IMAGE_CACHE_DIR = Path("{IMAGE_CACHE_DIR}") +PREVIEW_MODE = "{PREVIEW_MODE}" +IMAGE_RENDERER = "{IMAGE_RENDERER}" +HEADER_COLOR = "{HEADER_COLOR}" +SEPARATOR_COLOR = "{SEPARATOR_COLOR}" +SCALE_UP = "{SCALE_UP}" == "True" + +# --- Arguments --- +# sys.argv[1] is the selected anime title from fzf +SELECTED_TITLE = sys.argv[1] if len(sys.argv) > 1 else "" + + +def format_number(num): + """Format number with thousand separators.""" + if num is None: + return "N/A" + return f"{num:,}" + + +def format_date(date_obj): + """Format date object to string.""" + if not date_obj or date_obj == "null": + return "N/A" + + year = date_obj.get("year") + month = date_obj.get("month") + day = date_obj.get("day") + + if not year: + return "N/A" + if month and day: + return f"{day}/{month}/{year}" + if month: + return f"{month}/{year}" + return str(year) + + +def get_media_from_results(title): + """Find media item in search results by title.""" + if not SEARCH_RESULTS_FILE.exists(): + return None + + try: + with open(SEARCH_RESULTS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + + media_list = data.get("data", {}).get("Page", {}).get("media", []) + + for media in media_list: + title_obj = media.get("title", {}) + eng = title_obj.get("english") + rom = title_obj.get("romaji") + nat = title_obj.get("native") + + if title in (eng, rom, nat): + return media + + return None + except Exception as e: + print(f"Error reading search results: {e}", file=sys.stderr) + return None + + +def download_image(url: str, output_path: Path) -> bool: + """Download image from URL and save to file.""" + try: + # Try using urllib (stdlib) + from urllib import request + + req = request.Request(url, headers={"User-Agent": "viu/1.0"}) + with request.urlopen(req, timeout=5) as response: + data = response.read() + output_path.write_bytes(data) + return True + except Exception: + # Silently fail - preview will just not show image + return False + + +def which(cmd): + """Check if command exists.""" + return shutil.which(cmd) + + +def get_terminal_dimensions(): + """Get terminal dimensions from FZF environment.""" + fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS") + fzf_lines = os.environ.get("FZF_PREVIEW_LINES") + + if fzf_cols and fzf_lines: + return int(fzf_cols), int(fzf_lines) + + try: + rows, cols = ( + subprocess.check_output( + ["stty", "size"], text=True, stderr=subprocess.DEVNULL + ) + .strip() + .split() + ) + return int(cols), int(rows) + except Exception: + return 80, 24 + + +def render_kitty(file_path, width, height, scale_up): + """Render using the Kitty Graphics Protocol (kitten/icat).""" + cmd = [] + if which("kitten"): + cmd = ["kitten", "icat"] + elif which("icat"): + cmd = ["icat"] + elif which("kitty"): + cmd = ["kitty", "+kitten", "icat"] + + if not cmd: + return False + + args = [ + "--clear", + "--transfer-mode=memory", + "--unicode-placeholder", + "--stdin=no", + f"--place={width}x{height}@0x0", + ] + + if scale_up: + args.append("--scale-up") + + args.append(file_path) + + subprocess.run(cmd + args, stdout=sys.stdout, stderr=sys.stderr) + return True + + +def render_sixel(file_path, width, height): + """Render using Sixel.""" + if which("chafa"): + subprocess.run( + ["chafa", "-f", "sixel", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + if which("img2sixel"): + pixel_width = width * 10 + pixel_height = height * 20 + subprocess.run( + [ + "img2sixel", + f"--width={pixel_width}", + f"--height={pixel_height}", + file_path, + ], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + return False + + +def render_iterm(file_path, width, height): + """Render using iTerm2 Inline Image Protocol.""" + if which("imgcat"): + subprocess.run( + ["imgcat", "-W", str(width), "-H", str(height), file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + if which("chafa"): + subprocess.run( + ["chafa", "-f", "iterm", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def render_timg(file_path, width, height): + """Render using timg.""" + if which("timg"): + subprocess.run( + ["timg", f"-g{width}x{height}", "--upscale", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def render_chafa_auto(file_path, width, height): + """Render using Chafa in auto mode.""" + if which("chafa"): + subprocess.run( + ["chafa", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def fzf_image_preview(file_path: str): + """Main dispatch function to choose the best renderer.""" + cols, lines = get_terminal_dimensions() + width = cols + height = lines + + # Check explicit configuration + if IMAGE_RENDERER == "icat" or IMAGE_RENDERER == "system-kitty": + if render_kitty(file_path, width, height, SCALE_UP): + return + + elif IMAGE_RENDERER == "sixel" or IMAGE_RENDERER == "system-sixels": + if render_sixel(file_path, width, height): + return + + elif IMAGE_RENDERER == "imgcat": + if render_iterm(file_path, width, height): + return + + elif IMAGE_RENDERER == "timg": + if render_timg(file_path, width, height): + return + + elif IMAGE_RENDERER == "chafa": + if render_chafa_auto(file_path, width, height): + return + + # Auto-detection / Fallback + if os.environ.get("KITTY_WINDOW_ID") or os.environ.get("GHOSTTY_BIN_DIR"): + if render_kitty(file_path, width, height, SCALE_UP): + return + + if os.environ.get("TERM_PROGRAM") == "iTerm.app": + if render_iterm(file_path, width, height): + return + + # Try standard tools in order of quality/preference + if render_kitty(file_path, width, height, SCALE_UP): + return + if render_sixel(file_path, width, height): + return + if render_timg(file_path, width, height): + return + if render_chafa_auto(file_path, width, height): + return + + print("⚠️ No suitable image renderer found (icat, chafa, timg, img2sixel).") + + +def main(): + if not SELECTED_TITLE: + print("No selection") + return + + # Get the media data from cached search results + media = get_media_from_results(SELECTED_TITLE) + + if not media: + print("Loading preview...") + return + + term_width = get_terminal_width() + + # Extract media information + title_obj = media.get("title", {}) + title = title_obj.get("english") or title_obj.get("romaji") or title_obj.get("native") or "Unknown" + + # Show image if in image or full mode + if PREVIEW_MODE in ("image", "full"): + cover_image = media.get("coverImage", {}).get("large", "") + if cover_image: + # Ensure image cache directory exists + IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + # Generate hash matching the preview worker pattern + # Use "anime-" prefix and hash of just the title (no KEY prefix for dynamic search) + hash_id = f"anime-{sha256(SELECTED_TITLE.encode('utf-8')).hexdigest()}" + image_file = IMAGE_CACHE_DIR / f"{hash_id}.png" + + # Download image if not cached + if not image_file.exists(): + download_image(cover_image, image_file) + + # Try to render the image + if image_file.exists(): + fzf_image_preview(str(image_file)) + print() # Spacer + else: + print("🖼️ Loading image...") + print() + + # Show text info if in text or full mode + if PREVIEW_MODE in ("text", "full"): + # Separator line + r, g, b = map(int, SEPARATOR_COLOR.split(",")) + separator = f"\x1b[38;2;{r};{g};{b}m" + ("─" * term_width) + "\x1b[0m" + print(separator, flush=True) + + # Title centered + print(title.center(term_width)) + + # Extract data + status = media.get("status", "Unknown") + format_type = media.get("format", "Unknown") + episodes = media.get("episodes", "?") + duration = media.get("duration") + duration_str = f"{duration} min" if duration else "Unknown" + + score = media.get("averageScore") + score_str = f"{score}/100" if score else "N/A" + + favourites = format_number(media.get("favourites", 0)) + popularity = format_number(media.get("popularity", 0)) + + genres = ", ".join(media.get("genres", [])[:5]) or "Unknown" + + start_date = format_date(media.get("startDate")) + end_date = format_date(media.get("endDate")) + + studios_list = media.get("studios", {}).get("nodes", []) + studios = ", ".join([s.get("name", "") for s in studios_list[:3]]) or "Unknown" + + synonyms_list = media.get("synonyms", []) + synonyms = ", ".join(synonyms_list[:3]) or "N/A" + + description = media.get("description", "No description available.") + description = strip_markdown(description) + + # Print sections matching media_info.py structure + rows = [ + ("Score", score_str), + ("Favorites", favourites), + ("Popularity", popularity), + ("Status", status), + ] + + print_rule(SEPARATOR_COLOR) + for key, value in rows: + if ANSI_UTILS_AVAILABLE: + print_table_row(key, value, HEADER_COLOR, 0, 0) + else: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + + rows = [ + ("Episodes", str(episodes)), + ("Duration", duration_str), + ] + + print_rule(SEPARATOR_COLOR) + for key, value in rows: + if ANSI_UTILS_AVAILABLE: + print_table_row(key, value, HEADER_COLOR, 0, 0) + else: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + + rows = [ + ("Genres", genres), + ("Format", format_type), + ] + + print_rule(SEPARATOR_COLOR) + for key, value in rows: + if ANSI_UTILS_AVAILABLE: + print_table_row(key, value, HEADER_COLOR, 0, 0) + else: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + + rows = [ + ("Start Date", start_date), + ("End Date", end_date), + ] + + print_rule(SEPARATOR_COLOR) + for key, value in rows: + if ANSI_UTILS_AVAILABLE: + print_table_row(key, value, HEADER_COLOR, 0, 0) + else: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + + rows = [ + ("Studios", studios), + ] + + print_rule(SEPARATOR_COLOR) + for key, value in rows: + if ANSI_UTILS_AVAILABLE: + print_table_row(key, value, HEADER_COLOR, 0, 0) + else: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + + rows = [ + ("Synonyms", synonyms), + ] + + print_rule(SEPARATOR_COLOR) + for key, value in rows: + if ANSI_UTILS_AVAILABLE: + print_table_row(key, value, HEADER_COLOR, 0, 0) + else: + print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + + print_rule(SEPARATOR_COLOR) + print(wrap_text(description, term_width)) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass + except Exception as e: + print(f"Preview Error: {e}", file=sys.stderr) diff --git a/viu_media/assets/scripts/fzf/search.py b/viu_media/assets/scripts/fzf/search.py new file mode 100755 index 0000000..1ea9fcf --- /dev/null +++ b/viu_media/assets/scripts/fzf/search.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# FZF Dynamic Search Script Template +# +# This script is a template for dynamic search functionality in fzf. +# The placeholders in curly braces, like {GRAPHQL_ENDPOINT} are dynamically +# filled by Python using .replace() during runtime. + +import json +import os +import sys +from pathlib import Path +from urllib import request +from urllib.error import URLError + +# --- Template Variables (Injected by Python) --- +GRAPHQL_ENDPOINT = "{GRAPHQL_ENDPOINT}" +SEARCH_RESULTS_FILE = Path("{SEARCH_RESULTS_FILE}") +AUTH_HEADER = "{AUTH_HEADER}" + +# The GraphQL query is injected as a properly escaped JSON string +GRAPHQL_QUERY = {GRAPHQL_QUERY} + +# --- Get Query from fzf --- +# fzf passes the current query as the first argument when using --bind change:reload +QUERY = sys.argv[1] if len(sys.argv) > 1 else "" + +# If query is empty, exit with empty results +if not QUERY.strip(): + print("") + sys.exit(0) + + +def make_graphql_request(endpoint: str, query: str, variables: dict, auth_token: str = "") -> dict | None: + """ + Make a GraphQL request to the specified endpoint. + + Args: + endpoint: GraphQL API endpoint URL + query: GraphQL query string + variables: Query variables as a dictionary + auth_token: Optional authorization token (Bearer token) + + Returns: + Response JSON as a dictionary, or None if request fails + """ + payload = { + "query": query, + "variables": variables + } + + headers = { + "Content-Type": "application/json", + "User-Agent": "viu/1.0" + } + + if auth_token: + headers["Authorization"] = auth_token + + try: + req = request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST" + ) + + with request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except (URLError, json.JSONDecodeError, Exception) as e: + print(f"❌ Request failed: {e}", file=sys.stderr) + return None + + +def extract_title(media_item: dict) -> str: + """ + Extract the best available title from a media item. + + Args: + media_item: Media object from GraphQL response + + Returns: + Title string (english > romaji > native > "Unknown") + """ + title_obj = media_item.get("title", {}) + return ( + title_obj.get("english") or + title_obj.get("romaji") or + title_obj.get("native") or + "Unknown" + ) + + +def main(): + # Ensure parent directory exists + SEARCH_RESULTS_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Create GraphQL variables + variables = { + "query": QUERY, + "type": "ANIME", + "per_page": 50, + "genre_not_in": ["Hentai"] + } + + # Make the GraphQL request + response = make_graphql_request( + GRAPHQL_ENDPOINT, + GRAPHQL_QUERY, + variables, + AUTH_HEADER + ) + + if response is None: + print("❌ Search failed") + sys.exit(1) + + # Save the raw response for later processing by dynamic_search.py + try: + with open(SEARCH_RESULTS_FILE, "w", encoding="utf-8") as f: + json.dump(response, f, ensure_ascii=False, indent=2) + except IOError as e: + print(f"❌ Failed to save results: {e}", file=sys.stderr) + sys.exit(1) + + # Parse and display results + if "errors" in response: + print(f"❌ Search error: {response['errors']}") + sys.exit(1) + + # Navigate the response structure + data = response.get("data", {}) + page = data.get("Page", {}) + media_list = page.get("media", []) + + if not media_list: + print("❌ No results found") + sys.exit(0) + + # Output titles for fzf (one per line) + for media in media_list: + title = extract_title(media) + print(title) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) + except Exception as e: + print(f"❌ Unexpected error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/viu_media/cli/interactive/menu/media/dynamic_search.py b/viu_media/cli/interactive/menu/media/dynamic_search.py index 6303584..46ccf90 100644 --- a/viu_media/cli/interactive/menu/media/dynamic_search.py +++ b/viu_media/cli/interactive/menu/media/dynamic_search.py @@ -1,5 +1,6 @@ import json import logging +import sys from .....core.constants import APP_CACHE_DIR, SCRIPTS_DIR from .....libs.media_api.params import MediaSearchParams @@ -11,7 +12,7 @@ logger = logging.getLogger(__name__) SEARCH_CACHE_DIR = APP_CACHE_DIR / "search" SEARCH_RESULTS_FILE = SEARCH_CACHE_DIR / "current_search_results.json" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" -SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.template.sh").read_text( +SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.py").read_text( encoding="utf-8" ) @@ -29,8 +30,8 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: from .....libs.media_api.anilist import gql search_query = gql.SEARCH_MEDIA.read_text(encoding="utf-8") - # Properly escape the GraphQL query for JSON - search_query_escaped = json.dumps(search_query) + # Escape the GraphQL query as a JSON string literal for Python script + search_query_json = json.dumps(search_query) # Prepare the search script auth_header = "" @@ -42,8 +43,7 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: replacements = { "GRAPHQL_ENDPOINT": "https://graphql.anilist.co", - "GRAPHQL_QUERY": search_query_escaped, - "CACHE_DIR": str(SEARCH_CACHE_DIR), + "GRAPHQL_QUERY": search_query_json, "SEARCH_RESULTS_FILE": str(SEARCH_RESULTS_FILE), "AUTH_HEADER": auth_header, } @@ -51,6 +51,14 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: for key, value in replacements.items(): search_command = search_command.replace(f"{{{key}}}", str(value)) + # Write the filled template to a cache file + search_script_file = SEARCH_CACHE_DIR / "search-script.py" + search_script_file.write_text(search_command, encoding="utf-8") + + # Make the search script executable by calling it with python3 + # fzf will pass the query as {q} which becomes the first argument + search_command_final = f"{sys.executable} {search_script_file} {{q}}" + try: # Prepare preview functionality preview_command = None @@ -62,13 +70,13 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: choice = ctx.selector.search( prompt="Search Anime", - search_command=search_command, + search_command=search_command_final, preview=preview_command, ) else: choice = ctx.selector.search( prompt="Search Anime", - search_command=search_command, + search_command=search_command_final, ) except NotImplementedError: feedback.error("Dynamic search is not supported by your current selector") diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index c719324..cce9c19 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -127,7 +127,9 @@ INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8") -DYNAMIC_PREVIEW_SCRIPT = "" +DYNAMIC_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "dynamic_preview.py").read_text( + encoding="utf-8" +) EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*") @@ -534,13 +536,13 @@ def get_dynamic_anime_preview(config: AppConfig) -> str: This is different from regular anime preview because: 1. We don't have media items upfront 2. The preview needs to work with search results as they come in - 3. Preview is handled entirely in shell by parsing JSON results + 3. Preview script dynamically loads data from search results JSON Args: config: Application configuration Returns: - Preview script content for fzf dynamic search + Preview script command for fzf dynamic search """ # Ensure cache directories exist IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -556,30 +558,27 @@ def get_dynamic_anime_preview(config: AppConfig) -> str: search_cache_dir = APP_CACHE_DIR / "search" search_results_file = search_cache_dir / "current_search_results.json" - # Prepare values to inject into the template - path_sep = "\\" if PLATFORM == "win32" else "/" - - # Format the template with the dynamic values + # Prepare replacements for the template replacements = { - "PREVIEW_MODE": config.general.preview, - "IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR), - "INFO_CACHE_PATH": str(INFO_CACHE_DIR), - "PATH_SEP": path_sep, - "IMAGE_RENDERER": config.general.image_renderer, "SEARCH_RESULTS_FILE": str(search_results_file), - # 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, - "SCALE_UP": " --scale-up" if config.general.preview_scale_up else "", + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), + "PREVIEW_MODE": config.general.preview, + "IMAGE_RENDERER": config.general.image_renderer, + "HEADER_COLOR": ",".join(HEADER_COLOR), + "SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR), + "SCALE_UP": str(config.general.preview_scale_up), } for key, value in replacements.items(): preview_script = preview_script.replace(f"{{{key}}}", value) - return preview_script + # Write the preview script to cache + preview_file = PREVIEWS_CACHE_DIR / "dynamic-search-preview-script.py" + preview_file.write_text(preview_script, encoding="utf-8") + + # Return the command to execute the preview script + preview_script_final = f"{sys.executable} {preview_file} {{}}" + return preview_script_final def _get_preview_manager() -> PreviewWorkerManager: From 725754ea1a6a535a01dd32d33b1afb6fb2fa09dc Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:12:27 +0300 Subject: [PATCH 41/48] feat: improve text display for dynamic search --- .../assets/scripts/fzf/dynamic_preview.py | 112 ++++++++++-------- viu_media/assets/scripts/fzf/search.py | 63 +++++----- .../interactive/menu/media/dynamic_search.py | 4 +- 3 files changed, 90 insertions(+), 89 deletions(-) diff --git a/viu_media/assets/scripts/fzf/dynamic_preview.py b/viu_media/assets/scripts/fzf/dynamic_preview.py index 9085466..dc338f3 100755 --- a/viu_media/assets/scripts/fzf/dynamic_preview.py +++ b/viu_media/assets/scripts/fzf/dynamic_preview.py @@ -23,33 +23,38 @@ try: strip_markdown, wrap_text, ) + ANSI_UTILS_AVAILABLE = True except ImportError: ANSI_UTILS_AVAILABLE = False + # Fallback if _ansi_utils is not available def get_terminal_width(): return int(os.environ.get("FZF_PREVIEW_COLUMNS", "80")) - + def print_rule(sep_color): r, g, b = map(int, sep_color.split(",")) width = get_terminal_width() print(f"\x1b[38;2;{r};{g};{b}m" + ("─" * width) + "\x1b[0m") - + def print_table_row(key, value, header_color, _key_width, _value_width): r, g, b = map(int, header_color.split(",")) print(f"\x1b[38;2;{r};{g};{b};1m{key}\x1b[0m: {value}") - + def strip_markdown(text): import re - text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) - text = re.sub(r'__(.+?)__', r'\1', text) - text = re.sub(r'\*(.+?)\*', r'\1', text) - text = re.sub(r'_(.+?)_', r'\1', text) + + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"_(.+?)_", r"\1", text) return text - + def wrap_text(text, width): import textwrap - return '\n'.join(textwrap.wrap(text, width)) + + return "\n".join(textwrap.wrap(text, width)) + # --- Template Variables (Injected by Python) --- SEARCH_RESULTS_FILE = Path("{SEARCH_RESULTS_FILE}") @@ -76,11 +81,11 @@ def format_date(date_obj): """Format date object to string.""" if not date_obj or date_obj == "null": return "N/A" - + year = date_obj.get("year") month = date_obj.get("month") day = date_obj.get("day") - + if not year: return "N/A" if month and day: @@ -94,22 +99,22 @@ def get_media_from_results(title): """Find media item in search results by title.""" if not SEARCH_RESULTS_FILE.exists(): return None - + try: with open(SEARCH_RESULTS_FILE, "r", encoding="utf-8") as f: data = json.load(f) - + media_list = data.get("data", {}).get("Page", {}).get("media", []) - + for media in media_list: title_obj = media.get("title", {}) eng = title_obj.get("english") rom = title_obj.get("romaji") nat = title_obj.get("native") - + if title in (eng, rom, nat): return media - + return None except Exception as e: print(f"Error reading search results: {e}", file=sys.stderr) @@ -121,7 +126,7 @@ def download_image(url: str, output_path: Path) -> bool: try: # Try using urllib (stdlib) from urllib import request - + req = request.Request(url, headers={"User-Agent": "viu/1.0"}) with request.urlopen(req, timeout=5) as response: data = response.read() @@ -141,10 +146,10 @@ def get_terminal_dimensions(): """Get terminal dimensions from FZF environment.""" fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS") fzf_lines = os.environ.get("FZF_PREVIEW_LINES") - + if fzf_cols and fzf_lines: return int(fzf_cols), int(fzf_lines) - + try: rows, cols = ( subprocess.check_output( @@ -313,36 +318,41 @@ def main(): if not SELECTED_TITLE: print("No selection") return - + # Get the media data from cached search results media = get_media_from_results(SELECTED_TITLE) - + if not media: print("Loading preview...") return - + term_width = get_terminal_width() - + # Extract media information title_obj = media.get("title", {}) - title = title_obj.get("english") or title_obj.get("romaji") or title_obj.get("native") or "Unknown" - + title = ( + title_obj.get("english") + or title_obj.get("romaji") + or title_obj.get("native") + or "Unknown" + ) + # Show image if in image or full mode if PREVIEW_MODE in ("image", "full"): cover_image = media.get("coverImage", {}).get("large", "") if cover_image: # Ensure image cache directory exists IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - + # Generate hash matching the preview worker pattern # Use "anime-" prefix and hash of just the title (no KEY prefix for dynamic search) hash_id = f"anime-{sha256(SELECTED_TITLE.encode('utf-8')).hexdigest()}" image_file = IMAGE_CACHE_DIR / f"{hash_id}.png" - + # Download image if not cached if not image_file.exists(): download_image(cover_image, image_file) - + # Try to render the image if image_file.exists(): fzf_image_preview(str(image_file)) @@ -350,44 +360,44 @@ def main(): else: print("🖼️ Loading image...") print() - + # Show text info if in text or full mode if PREVIEW_MODE in ("text", "full"): # Separator line r, g, b = map(int, SEPARATOR_COLOR.split(",")) separator = f"\x1b[38;2;{r};{g};{b}m" + ("─" * term_width) + "\x1b[0m" print(separator, flush=True) - + # Title centered print(title.center(term_width)) - + # Extract data status = media.get("status", "Unknown") format_type = media.get("format", "Unknown") episodes = media.get("episodes", "?") duration = media.get("duration") duration_str = f"{duration} min" if duration else "Unknown" - + score = media.get("averageScore") score_str = f"{score}/100" if score else "N/A" - + favourites = format_number(media.get("favourites", 0)) popularity = format_number(media.get("popularity", 0)) - + genres = ", ".join(media.get("genres", [])[:5]) or "Unknown" - + start_date = format_date(media.get("startDate")) end_date = format_date(media.get("endDate")) - + studios_list = media.get("studios", {}).get("nodes", []) studios = ", ".join([s.get("name", "") for s in studios_list[:3]]) or "Unknown" - + synonyms_list = media.get("synonyms", []) synonyms = ", ".join(synonyms_list[:3]) or "N/A" - + description = media.get("description", "No description available.") description = strip_markdown(description) - + # Print sections matching media_info.py structure rows = [ ("Score", score_str), @@ -395,72 +405,72 @@ def main(): ("Popularity", popularity), ("Status", status), ] - + print_rule(SEPARATOR_COLOR) for key, value in rows: if ANSI_UTILS_AVAILABLE: print_table_row(key, value, HEADER_COLOR, 0, 0) else: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - + rows = [ ("Episodes", str(episodes)), ("Duration", duration_str), ] - + print_rule(SEPARATOR_COLOR) for key, value in rows: if ANSI_UTILS_AVAILABLE: print_table_row(key, value, HEADER_COLOR, 0, 0) else: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - + rows = [ ("Genres", genres), ("Format", format_type), ] - + print_rule(SEPARATOR_COLOR) for key, value in rows: if ANSI_UTILS_AVAILABLE: print_table_row(key, value, HEADER_COLOR, 0, 0) else: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - + rows = [ ("Start Date", start_date), ("End Date", end_date), ] - + print_rule(SEPARATOR_COLOR) for key, value in rows: if ANSI_UTILS_AVAILABLE: print_table_row(key, value, HEADER_COLOR, 0, 0) else: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - + rows = [ ("Studios", studios), ] - + print_rule(SEPARATOR_COLOR) for key, value in rows: if ANSI_UTILS_AVAILABLE: print_table_row(key, value, HEADER_COLOR, 0, 0) else: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - + rows = [ ("Synonyms", synonyms), ] - + print_rule(SEPARATOR_COLOR) for key, value in rows: if ANSI_UTILS_AVAILABLE: print_table_row(key, value, HEADER_COLOR, 0, 0) else: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) - + print_rule(SEPARATOR_COLOR) print(wrap_text(description, term_width)) diff --git a/viu_media/assets/scripts/fzf/search.py b/viu_media/assets/scripts/fzf/search.py index 1ea9fcf..97e3d73 100755 --- a/viu_media/assets/scripts/fzf/search.py +++ b/viu_media/assets/scripts/fzf/search.py @@ -31,40 +31,36 @@ if not QUERY.strip(): sys.exit(0) -def make_graphql_request(endpoint: str, query: str, variables: dict, auth_token: str = "") -> dict | None: +def make_graphql_request( + endpoint: str, query: str, variables: dict, auth_token: str = "" +) -> dict | None: """ Make a GraphQL request to the specified endpoint. - + Args: endpoint: GraphQL API endpoint URL query: GraphQL query string variables: Query variables as a dictionary auth_token: Optional authorization token (Bearer token) - + Returns: Response JSON as a dictionary, or None if request fails """ - payload = { - "query": query, - "variables": variables - } - - headers = { - "Content-Type": "application/json", - "User-Agent": "viu/1.0" - } - + payload = {"query": query, "variables": variables} + + headers = {"Content-Type": "application/json", "User-Agent": "viu/1.0"} + if auth_token: headers["Authorization"] = auth_token - + try: req = request.Request( endpoint, data=json.dumps(payload).encode("utf-8"), headers=headers, - method="POST" + method="POST", ) - + with request.urlopen(req, timeout=10) as response: return json.loads(response.read().decode("utf-8")) except (URLError, json.JSONDecodeError, Exception) as e: @@ -75,46 +71,43 @@ def make_graphql_request(endpoint: str, query: str, variables: dict, auth_token: def extract_title(media_item: dict) -> str: """ Extract the best available title from a media item. - + Args: media_item: Media object from GraphQL response - + Returns: Title string (english > romaji > native > "Unknown") """ title_obj = media_item.get("title", {}) return ( - title_obj.get("english") or - title_obj.get("romaji") or - title_obj.get("native") or - "Unknown" + title_obj.get("english") + or title_obj.get("romaji") + or title_obj.get("native") + or "Unknown" ) def main(): # Ensure parent directory exists SEARCH_RESULTS_FILE.parent.mkdir(parents=True, exist_ok=True) - + # Create GraphQL variables variables = { "query": QUERY, "type": "ANIME", "per_page": 50, - "genre_not_in": ["Hentai"] + "genre_not_in": ["Hentai"], } - + # Make the GraphQL request response = make_graphql_request( - GRAPHQL_ENDPOINT, - GRAPHQL_QUERY, - variables, - AUTH_HEADER + GRAPHQL_ENDPOINT, GRAPHQL_QUERY, variables, AUTH_HEADER ) - + if response is None: print("❌ Search failed") sys.exit(1) - + # Save the raw response for later processing by dynamic_search.py try: with open(SEARCH_RESULTS_FILE, "w", encoding="utf-8") as f: @@ -122,21 +115,21 @@ def main(): except IOError as e: print(f"❌ Failed to save results: {e}", file=sys.stderr) sys.exit(1) - + # Parse and display results if "errors" in response: print(f"❌ Search error: {response['errors']}") sys.exit(1) - + # Navigate the response structure data = response.get("data", {}) page = data.get("Page", {}) media_list = page.get("media", []) - + if not media_list: print("❌ No results found") sys.exit(0) - + # Output titles for fzf (one per line) for media in media_list: title = extract_title(media) diff --git a/viu_media/cli/interactive/menu/media/dynamic_search.py b/viu_media/cli/interactive/menu/media/dynamic_search.py index 46ccf90..e0c484e 100644 --- a/viu_media/cli/interactive/menu/media/dynamic_search.py +++ b/viu_media/cli/interactive/menu/media/dynamic_search.py @@ -12,9 +12,7 @@ logger = logging.getLogger(__name__) SEARCH_CACHE_DIR = APP_CACHE_DIR / "search" SEARCH_RESULTS_FILE = SEARCH_CACHE_DIR / "current_search_results.json" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" -SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.py").read_text( - encoding="utf-8" -) +SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.py").read_text(encoding="utf-8") @session.menu From 7b9de8620b4cab3928a4f0114d2992a5d8739760 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:15:18 +0300 Subject: [PATCH 42/48] chore: cleanup old preview scripts --- .../fzf/old/airing-schedule-info.template.sh | 22 -- .../old/airing-schedule-preview.template.sh | 75 ----- .../fzf/old/character-info.template.sh | 41 --- .../fzf/old/character-preview.template.sh | 130 -------- .../fzf/old/dynamic-preview.template.sh | 315 ------------------ .../scripts/fzf/old/episode-info.template.sh | 31 -- .../assets/scripts/fzf/old/info.template.sh | 54 --- .../scripts/fzf/old/preview.template.sh | 147 -------- .../scripts/fzf/old/review-info.template.sh | 19 -- .../fzf/old/review-preview.template.sh | 75 ----- .../assets/scripts/fzf/old/search.template.sh | 118 ------- 11 files changed, 1027 deletions(-) delete mode 100644 viu_media/assets/scripts/fzf/old/airing-schedule-info.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/airing-schedule-preview.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/character-info.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/character-preview.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/dynamic-preview.template.sh delete mode 100755 viu_media/assets/scripts/fzf/old/episode-info.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/info.template.sh delete mode 100755 viu_media/assets/scripts/fzf/old/preview.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/review-info.template.sh delete mode 100644 viu_media/assets/scripts/fzf/old/review-preview.template.sh delete mode 100755 viu_media/assets/scripts/fzf/old/search.template.sh diff --git a/viu_media/assets/scripts/fzf/old/airing-schedule-info.template.sh b/viu_media/assets/scripts/fzf/old/airing-schedule-info.template.sh deleted file mode 100644 index 59d5e56..0000000 --- a/viu_media/assets/scripts/fzf/old/airing-schedule-info.template.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -# -# Viu Airing Schedule Info Script Template -# This script formats and displays airing schedule details in the FZF preview pane. -# Python injects the actual data values into the placeholders. - -draw_rule - -print_kv "Anime Title" "{ANIME_TITLE}" - -draw_rule - -print_kv "Total Episodes" "{TOTAL_EPISODES}" -print_kv "Upcoming Episodes" "{UPCOMING_EPISODES}" - -draw_rule - -echo "{C_KEY}Next Episodes:{RESET}" -echo -echo "{SCHEDULE_TABLE}" | fold -s -w "$WIDTH" - -draw_rule diff --git a/viu_media/assets/scripts/fzf/old/airing-schedule-preview.template.sh b/viu_media/assets/scripts/fzf/old/airing-schedule-preview.template.sh deleted file mode 100644 index acb0a71..0000000 --- a/viu_media/assets/scripts/fzf/old/airing-schedule-preview.template.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/sh -# -# FZF Airing Schedule Preview Script Template -# -# This script is a template. The placeholders in curly braces, like {NAME} -# are dynamically filled by python using .replace() - -WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80 -IMAGE_RENDERER="{IMAGE_RENDERER}" - -generate_sha256() { - local input - - # Check if input is passed as an argument or piped - if [ -n "$1" ]; then - input="$1" - else - input=$(cat) - fi - - if command -v sha256sum &>/dev/null; then - echo -n "$input" | sha256sum | awk '{print $1}' - elif command -v shasum &>/dev/null; then - echo -n "$input" | shasum -a 256 | awk '{print $1}' - elif command -v sha256 &>/dev/null; then - echo -n "$input" | sha256 | awk '{print $1}' - elif command -v openssl &>/dev/null; then - echo -n "$input" | openssl dgst -sha256 | awk '{print $2}' - else - echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n' - fi -} - - -print_kv() { - local key="$1" - local value="$2" - local key_len=${#key} - local value_len=${#value} - local multiplier="${3:-1}" - - # Correctly calculate padding by accounting for the key, the ": ", and the value. - local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier)) - - # If the text is too long to fit, just add a single space for separation. - if [ "$padding_len" -lt 1 ]; then - padding_len=1 - value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))") - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - else - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - fi -} - - -draw_rule(){ - ll=2 - while [ $ll -le $FZF_PREVIEW_COLUMNS ];do - echo -n -e "{C_RULE}─{RESET}" - ((ll++)) - done - echo -} - -title={} -hash=$(generate_sha256 "$title") - -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then - info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash" - if [ -f "$info_file" ]; then - source "$info_file" - else - echo "📅 Loading airing schedule..." - fi -fi diff --git a/viu_media/assets/scripts/fzf/old/character-info.template.sh b/viu_media/assets/scripts/fzf/old/character-info.template.sh deleted file mode 100644 index 610b422..0000000 --- a/viu_media/assets/scripts/fzf/old/character-info.template.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -# -# Viu Character Info Script Template -# This script formats and displays character details in the FZF preview pane. -# Python injects the actual data values into the placeholders. - -draw_rule - -print_kv "Character Name" "{CHARACTER_NAME}" - -if [ -n "{CHARACTER_NATIVE_NAME}" ] && [ "{CHARACTER_NATIVE_NAME}" != "N/A" ]; then - print_kv "Native Name" "{CHARACTER_NATIVE_NAME}" -fi - -draw_rule - -if [ -n "{CHARACTER_GENDER}" ] && [ "{CHARACTER_GENDER}" != "Unknown" ]; then - print_kv "Gender" "{CHARACTER_GENDER}" -fi - -if [ -n "{CHARACTER_AGE}" ] && [ "{CHARACTER_AGE}" != "Unknown" ]; then - print_kv "Age" "{CHARACTER_AGE}" -fi - -if [ -n "{CHARACTER_BLOOD_TYPE}" ] && [ "{CHARACTER_BLOOD_TYPE}" != "N/A" ]; then - print_kv "Blood Type" "{CHARACTER_BLOOD_TYPE}" -fi - -if [ -n "{CHARACTER_BIRTHDAY}" ] && [ "{CHARACTER_BIRTHDAY}" != "N/A" ]; then - print_kv "Birthday" "{CHARACTER_BIRTHDAY}" -fi - -if [ -n "{CHARACTER_FAVOURITES}" ] && [ "{CHARACTER_FAVOURITES}" != "0" ]; then - print_kv "Favorites" "{CHARACTER_FAVOURITES}" -fi - -draw_rule - -echo "{CHARACTER_DESCRIPTION}" | fold -s -w "$WIDTH" - -draw_rule diff --git a/viu_media/assets/scripts/fzf/old/character-preview.template.sh b/viu_media/assets/scripts/fzf/old/character-preview.template.sh deleted file mode 100644 index 566936a..0000000 --- a/viu_media/assets/scripts/fzf/old/character-preview.template.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/sh -# -# FZF Character Preview Script Template -# -# This script is a template. The placeholders in curly braces, like {NAME} -# are dynamically filled by python using .replace() - -WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80 -IMAGE_RENDERER="{IMAGE_RENDERER}" - -generate_sha256() { - local input - - # Check if input is passed as an argument or piped - if [ -n "$1" ]; then - input="$1" - else - input=$(cat) - fi - - if command -v sha256sum &>/dev/null; then - echo -n "$input" | sha256sum | awk '{print $1}' - elif command -v shasum &>/dev/null; then - echo -n "$input" | shasum -a 256 | awk '{print $1}' - elif command -v sha256 &>/dev/null; then - echo -n "$input" | sha256 | awk '{print $1}' - elif command -v openssl &>/dev/null; then - echo -n "$input" | openssl dgst -sha256 | awk '{print $2}' - else - echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n' - fi -} - -fzf_preview() { - file=$1 - - dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES} - if [ "$dim" = x ]; then - dim=$(stty size /dev/null 2>&1; then - kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - elif command -v icat >/dev/null 2>&1; then - icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - else - kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - fi - - elif [ -n "$GHOSTTY_BIN_DIR" ]; then - if command -v kitten >/dev/null 2>&1; then - kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - elif command -v icat >/dev/null 2>&1; then - icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - else - chafa -s "$dim" "$file" - fi - elif command -v chafa >/dev/null 2>&1; then - case "$PLATFORM" in - android) chafa -s "$dim" "$file" ;; - windows) chafa -f sixel -s "$dim" "$file" ;; - *) chafa -s "$dim" "$file" ;; - esac - echo - - elif command -v imgcat >/dev/null; then - imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file" - - else - echo please install a terminal image viewer - echo either icat for kitty terminal and wezterm or imgcat or chafa - fi -} -print_kv() { - local key="$1" - local value="$2" - local key_len=${#key} - local value_len=${#value} - local multiplier="${3:-1}" - - # Correctly calculate padding by accounting for the key, the ": ", and the value. - local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier)) - - # If the text is too long to fit, just add a single space for separation. - if [ "$padding_len" -lt 1 ]; then - padding_len=1 - value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))") - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - else - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - fi -} - - -draw_rule(){ - ll=2 - while [ $ll -le $FZF_PREVIEW_COLUMNS ];do - echo -n -e "{C_RULE}─{RESET}" - ((ll++)) - done - echo -} - -title={} -hash=$(generate_sha256 "$title") - - -# FIXME: Disabled since they cover the text perhaps its aspect ratio related or image format not sure -# if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then -# image_file="{IMAGE_CACHE_DIR}{PATH_SEP}$hash.png" -# if [ -f "$image_file" ]; then -# fzf_preview "$image_file" -# echo # Add a newline for spacing -# fi -# fi - -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then - info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash" - if [ -f "$info_file" ]; then - source "$info_file" - else - echo "👤 Loading character details..." - fi -fi - - diff --git a/viu_media/assets/scripts/fzf/old/dynamic-preview.template.sh b/viu_media/assets/scripts/fzf/old/dynamic-preview.template.sh deleted file mode 100644 index a702260..0000000 --- a/viu_media/assets/scripts/fzf/old/dynamic-preview.template.sh +++ /dev/null @@ -1,315 +0,0 @@ -#!/bin/bash -# -# FZF Dynamic Preview Script Template -# -# This script handles previews for dynamic search results by parsing the JSON -# search results file and extracting info for the selected item. -# The placeholders in curly braces are dynamically filled by Python using .replace() - -WIDTH=${FZF_PREVIEW_COLUMNS:-80} -IMAGE_RENDERER="{IMAGE_RENDERER}" -SEARCH_RESULTS_FILE="{SEARCH_RESULTS_FILE}" -IMAGE_CACHE_PATH="{IMAGE_CACHE_PATH}" -INFO_CACHE_PATH="{INFO_CACHE_PATH}" -PATH_SEP="{PATH_SEP}" - -# Color codes injected by Python -C_TITLE="{C_TITLE}" -C_KEY="{C_KEY}" -C_VALUE="{C_VALUE}" -C_RULE="{C_RULE}" -RESET="{RESET}" - -# Selected item from fzf -SELECTED_ITEM={} - -generate_sha256() { - local input="$1" - if command -v sha256sum &>/dev/null; then - echo -n "$input" | sha256sum | awk '{print $1}' - elif command -v shasum &>/dev/null; then - echo -n "$input" | shasum -a 256 | awk '{print $1}' - elif command -v sha256 &>/dev/null; then - echo -n "$input" | sha256 | awk '{print $1}' - elif command -v openssl &>/dev/null; then - echo -n "$input" | openssl dgst -sha256 | awk '{print $2}' - else - echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n' - fi -} - -fzf_preview() { - file=$1 - dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES} - if [ "$dim" = x ]; then - dim=$(stty size /dev/null 2>&1; then - kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - elif command -v icat >/dev/null 2>&1; then - icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - else - kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - fi - elif [ -n "$GHOSTTY_BIN_DIR" ]; then - if command -v kitten >/dev/null 2>&1; then - kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - elif command -v icat >/dev/null 2>&1; then - icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - else - chafa -s "$dim" "$file" - fi - elif command -v chafa >/dev/null 2>&1; then - case "$PLATFORM" in - android) chafa -s "$dim" "$file" ;; - windows) chafa -f sixel -s "$dim" "$file" ;; - *) chafa -s "$dim" "$file" ;; - esac - echo - elif command -v imgcat >/dev/null; then - imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file" - else - echo please install a terminal image viewer - echo either icat for kitty terminal and wezterm or imgcat or chafa - fi -} - -print_kv() { - local key="$1" - local value="$2" - local key_len=${#key} - local value_len=${#value} - local multiplier="${3:-1}" - - local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier)) - - if [ "$padding_len" -lt 1 ]; then - padding_len=1 - value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))") - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - else - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - fi -} - -draw_rule() { - ll=2 - while [ $ll -le $FZF_PREVIEW_COLUMNS ];do - echo -n -e "{C_RULE}─{RESET}" - ((ll++)) - done - echo -} - -clean_html() { - echo "$1" | sed 's/<[^>]*>//g' | sed 's/<//g' | sed 's/&/\&/g' | sed 's/"/"/g' | sed "s/'/'/g" -} - -format_date() { - local date_obj="$1" - if [ "$date_obj" = "null" ] || [ -z "$date_obj" ]; then - echo "N/A" - return - fi - - # Extract year, month, day from the date object - if command -v jq >/dev/null 2>&1; then - year=$(echo "$date_obj" | jq -r '.year // "N/A"' 2>/dev/null || echo "N/A") - month=$(echo "$date_obj" | jq -r '.month // ""' 2>/dev/null || echo "") - day=$(echo "$date_obj" | jq -r '.day // ""' 2>/dev/null || echo "") - else - year=$(echo "$date_obj" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('year', 'N/A'))" 2>/dev/null || echo "N/A") - month=$(echo "$date_obj" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('month', ''))" 2>/dev/null || echo "") - day=$(echo "$date_obj" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('day', ''))" 2>/dev/null || echo "") - fi - - if [ "$year" = "N/A" ] || [ "$year" = "null" ]; then - echo "N/A" - elif [ -n "$month" ] && [ "$month" != "null" ] && [ -n "$day" ] && [ "$day" != "null" ]; then - echo "$day/$month/$year" - elif [ -n "$month" ] && [ "$month" != "null" ]; then - echo "$month/$year" - else - echo "$year" - fi -} - -# If no selection or search results file doesn't exist, show placeholder -if [ -z "$SELECTED_ITEM" ] || [ ! -f "$SEARCH_RESULTS_FILE" ]; then - echo "${C_TITLE}Dynamic Search Preview${RESET}" - draw_rule - echo "Type to search for anime..." - echo "Results will appear here as you type." - echo - echo "DEBUG:" - echo "SELECTED_ITEM='$SELECTED_ITEM'" - echo "SEARCH_RESULTS_FILE='$SEARCH_RESULTS_FILE'" - if [ -f "$SEARCH_RESULTS_FILE" ]; then - echo "Search results file exists" - else - echo "Search results file missing" - fi - exit 0 -fi -# Parse the search results JSON and find the matching item -if command -v jq >/dev/null 2>&1; then - MEDIA_DATA=$(cat "$SEARCH_RESULTS_FILE" | jq --arg anime_title "$SELECTED_ITEM" ' - .data.Page.media[]? | - select((.title.english // .title.romaji // .title.native // "Unknown") == $anime_title ) - ' ) -else - # Fallback to Python for JSON parsing - MEDIA_DATA=$(cat "$SEARCH_RESULTS_FILE" | python3 -c " -import json -import sys - -try: - data = json.load(sys.stdin) - selected_item = '''$SELECTED_ITEM''' - - if 'data' not in data or 'Page' not in data['data'] or 'media' not in data['data']['Page']: - sys.exit(1) - - media_list = data['data']['Page']['media'] - - for media in media_list: - title = media.get('title', {}) - english_title = title.get('english') or title.get('romaji') or title.get('native', 'Unknown') - year = media.get('startDate', {}).get('year', 'Unknown') if media.get('startDate') else 'Unknown' - status = media.get('status', 'Unknown') - genres = ', '.join(media.get('genres', [])[:3]) or 'Unknown' - display_format = f'{english_title} ({year}) [{status}] - {genres}' - # Debug output for matching - print(f"DEBUG: selected_item='{selected_item.strip()}' display_format='{display_format.strip()}'", file=sys.stderr) - if selected_item.strip() == display_format.strip(): - json.dump(media, sys.stdout, indent=2) - sys.exit(0) - print(f"DEBUG: No match found for selected_item='{selected_item.strip()}'", file=sys.stderr) - sys.exit(1) -except Exception as e: - print(f'Error: {e}', file=sys.stderr) - sys.exit(1) -" 2>/dev/null) -fi - -# If we couldn't find the media data, show error -if [ $? -ne 0 ] || [ -z "$MEDIA_DATA" ]; then - echo "${C_TITLE}Preview Error${RESET}" - draw_rule - echo "Could not load preview data for:" - echo "$SELECTED_ITEM" - echo - echo "DEBUG INFO:" - echo "Search results file: $SEARCH_RESULTS_FILE" - if [ -f "$SEARCH_RESULTS_FILE" ]; then - echo "File exists, size: $(wc -c < "$SEARCH_RESULTS_FILE") bytes" - echo "First few lines of search results:" - head -3 "$SEARCH_RESULTS_FILE" 2>/dev/null || echo "Cannot read file" - else - echo "Search results file does not exist" - fi - exit 0 -fi - -# Extract information from the media data -if command -v jq >/dev/null 2>&1; then - # Use jq for faster extraction - TITLE=$(echo "$MEDIA_DATA" | jq -r '.title.english // .title.romaji // .title.native // "Unknown"' 2>/dev/null || echo "Unknown") - STATUS=$(echo "$MEDIA_DATA" | jq -r '.status // "Unknown"' 2>/dev/null || echo "Unknown") - FORMAT=$(echo "$MEDIA_DATA" | jq -r '.format // "Unknown"' 2>/dev/null || echo "Unknown") - EPISODES=$(echo "$MEDIA_DATA" | jq -r '.episodes // "Unknown"' 2>/dev/null || echo "Unknown") - DURATION=$(echo "$MEDIA_DATA" | jq -r 'if .duration then "\(.duration) min" else "Unknown" end' 2>/dev/null || echo "Unknown") - SCORE=$(echo "$MEDIA_DATA" | jq -r 'if .averageScore then "\(.averageScore)/100" else "N/A" end' 2>/dev/null || echo "N/A") - FAVOURITES=$(echo "$MEDIA_DATA" | jq -r '.favourites // 0' 2>/dev/null | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta' || echo "0") - POPULARITY=$(echo "$MEDIA_DATA" | jq -r '.popularity // 0' 2>/dev/null | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta' || echo "0") - GENRES=$(echo "$MEDIA_DATA" | jq -r '(.genres[:5] // []) | join(", ") | if . == "" then "Unknown" else . end' 2>/dev/null || echo "Unknown") - DESCRIPTION=$(echo "$MEDIA_DATA" | jq -r '.description // "No description available."' 2>/dev/null || echo "No description available.") - - # Get start and end dates as JSON objects - START_DATE_OBJ=$(echo "$MEDIA_DATA" | jq -c '.startDate' 2>/dev/null || echo "null") - END_DATE_OBJ=$(echo "$MEDIA_DATA" | jq -c '.endDate' 2>/dev/null || echo "null") - - # Get cover image URL - COVER_IMAGE=$(echo "$MEDIA_DATA" | jq -r '.coverImage.large // ""' 2>/dev/null || echo "") -else - # Fallback to Python for extraction - TITLE=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); title=data.get('title',{}); print(title.get('english') or title.get('romaji') or title.get('native', 'Unknown'))" 2>/dev/null || echo "Unknown") - STATUS=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('status', 'Unknown'))" 2>/dev/null || echo "Unknown") - FORMAT=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('format', 'Unknown'))" 2>/dev/null || echo "Unknown") - EPISODES=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('episodes', 'Unknown'))" 2>/dev/null || echo "Unknown") - DURATION=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); duration=data.get('duration'); print(f'{duration} min' if duration else 'Unknown')" 2>/dev/null || echo "Unknown") - SCORE=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); score=data.get('averageScore'); print(f'{score}/100' if score else 'N/A')" 2>/dev/null || echo "N/A") - FAVOURITES=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(f\"{data.get('favourites', 0):,}\")" 2>/dev/null || echo "0") - POPULARITY=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(f\"{data.get('popularity', 0):,}\")" 2>/dev/null || echo "0") - GENRES=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(', '.join(data.get('genres', [])[:5]))" 2>/dev/null || echo "Unknown") - DESCRIPTION=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('description', 'No description available.'))" 2>/dev/null || echo "No description available.") - - # Get start and end dates - START_DATE_OBJ=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); json.dump(data.get('startDate'), sys.stdout)" 2>/dev/null || echo "null") - END_DATE_OBJ=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); json.dump(data.get('endDate'), sys.stdout)" 2>/dev/null || echo "null") - - # Get cover image URL - COVER_IMAGE=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); cover=data.get('coverImage',{}); print(cover.get('large', ''))" 2>/dev/null || echo "") -fi - -# Format the dates -START_DATE=$(format_date "$START_DATE_OBJ") -END_DATE=$(format_date "$END_DATE_OBJ") - -# Generate cache hash for this item (using selected item like regular preview) -CACHE_HASH=$(generate_sha256 "$SELECTED_ITEM") - -# Try to show image if available -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then - image_file="{IMAGE_CACHE_PATH}{PATH_SEP}${CACHE_HASH}.png" - - # If image not cached and we have a URL, try to download it quickly - if [ ! -f "$image_file" ] && [ -n "$COVER_IMAGE" ]; then - if command -v curl >/dev/null 2>&1; then - # Quick download with timeout - curl -s -m 3 -L "$COVER_IMAGE" -o "$image_file" 2>/dev/null || rm -f "$image_file" 2>/dev/null - fi - fi - - if [ -f "$image_file" ]; then - fzf_preview "$image_file" - else - echo "🖼️ Loading image..." - fi - echo -fi - -# Display text info if configured -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then - draw_rule - print_kv "Title" "$TITLE" - draw_rule - - print_kv "Score" "$SCORE" - print_kv "Favourites" "$FAVOURITES" - print_kv "Popularity" "$POPULARITY" - print_kv "Status" "$STATUS" - - draw_rule - - print_kv "Episodes" "$EPISODES" - print_kv "Duration" "$DURATION" - print_kv "Format" "$FORMAT" - - draw_rule - - print_kv "Genres" "$GENRES" - print_kv "Start Date" "$START_DATE" - print_kv "End Date" "$END_DATE" - - draw_rule - - # Clean and display description - CLEAN_DESCRIPTION=$(clean_html "$DESCRIPTION") - echo "$CLEAN_DESCRIPTION" | fold -s -w "$WIDTH" -fi diff --git a/viu_media/assets/scripts/fzf/old/episode-info.template.sh b/viu_media/assets/scripts/fzf/old/episode-info.template.sh deleted file mode 100755 index 96c3190..0000000 --- a/viu_media/assets/scripts/fzf/old/episode-info.template.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# -# Episode Preview Info Script Template -# This script formats and displays episode information in the FZF preview pane. -# Some values are injected by python those with '{name}' syntax using .replace() - -draw_rule - -echo "{TITLE}" | fold -s -w "$WIDTH" - -draw_rule - -print_kv "Duration" "{DURATION}" -print_kv "Status" "{STATUS}" - -draw_rule - -print_kv "Total Episodes" "{EPISODES}" -print_kv "Next Episode" "{NEXT_EPISODE}" - -draw_rule - -print_kv "Progress" "{USER_PROGRESS}" -print_kv "List Status" "{USER_STATUS}" - -draw_rule - -print_kv "Start Date" "{START_DATE}" -print_kv "End Date" "{END_DATE}" - -draw_rule diff --git a/viu_media/assets/scripts/fzf/old/info.template.sh b/viu_media/assets/scripts/fzf/old/info.template.sh deleted file mode 100644 index ad96b77..0000000 --- a/viu_media/assets/scripts/fzf/old/info.template.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/sh -# -# Viu Preview Info Script Template -# This script formats and displays the textual information in the FZF preview pane. -# Some values are injected by python those with '{name}' syntax using .replace() - -draw_rule - -print_kv "Title" "{TITLE}" - -draw_rule - -# Emojis take up double the space -score_multiplier=1 -if ! [ "{SCORE}" = "N/A" ]; then - score_multiplier=2 -fi -print_kv "Score" "{SCORE}" $score_multiplier - -print_kv "Favourites" "{FAVOURITES}" -print_kv "Popularity" "{POPULARITY}" -print_kv "Status" "{STATUS}" - -draw_rule - -print_kv "Episodes" "{EPISODES}" -print_kv "Next Episode" "{NEXT_EPISODE}" -print_kv "Duration" "{DURATION}" - -draw_rule - -print_kv "Genres" "{GENRES}" -print_kv "Format" "{FORMAT}" - -draw_rule - -print_kv "List Status" "{USER_STATUS}" -print_kv "Progress" "{USER_PROGRESS}" - -draw_rule - -print_kv "Start Date" "{START_DATE}" -print_kv "End Date" "{END_DATE}" - -draw_rule - -print_kv "Studios" "{STUDIOS}" -print_kv "Synonymns" "{SYNONYMNS}" -print_kv "Tags" "{TAGS}" - -draw_rule - -# Synopsis -echo "{SYNOPSIS}" | fold -s -w "$WIDTH" diff --git a/viu_media/assets/scripts/fzf/old/preview.template.sh b/viu_media/assets/scripts/fzf/old/preview.template.sh deleted file mode 100755 index d948079..0000000 --- a/viu_media/assets/scripts/fzf/old/preview.template.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/bin/sh -# -# FZF Preview Script Template -# -# This script is a template. The placeholders in curly braces, like {NAME} -# are dynamically filled by python using .replace() - -WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80 -IMAGE_RENDERER="{IMAGE_RENDERER}" - -generate_sha256() { - local input - - # Check if input is passed as an argument or piped - if [ -n "$1" ]; then - input="$1" - else - input=$(cat) - fi - - if command -v sha256sum &>/dev/null; then - echo -n "$input" | sha256sum | awk '{print $1}' - elif command -v shasum &>/dev/null; then - echo -n "$input" | shasum -a 256 | awk '{print $1}' - elif command -v sha256 &>/dev/null; then - echo -n "$input" | sha256 | awk '{print $1}' - elif command -v openssl &>/dev/null; then - echo -n "$input" | openssl dgst -sha256 | awk '{print $2}' - else - echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n' - fi -} - -fzf_preview() { - file=$1 - - dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES} - if [ "$dim" = x ]; then - dim=$(stty size /dev/null 2>&1; then - kitten icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - elif command -v icat >/dev/null 2>&1; then - icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - else - kitty icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - fi - - elif [ -n "$GHOSTTY_BIN_DIR" ]; then - dim=$((FZF_PREVIEW_COLUMNS - 1))x${FZF_PREVIEW_LINES} - if command -v kitten >/dev/null 2>&1; then - kitten icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - elif command -v icat >/dev/null 2>&1; then - icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" - else - chafa -s "$dim" "$file" - fi - elif command -v chafa >/dev/null 2>&1; then - case "$PLATFORM" in - android) chafa -s "$dim" "$file" ;; - windows) chafa -f sixel -s "$dim" "$file" ;; - *) chafa -s "$dim" "$file" ;; - esac - echo - - elif command -v imgcat >/dev/null; then - imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file" - - else - echo please install a terminal image viewer - echo either icat for kitty terminal and wezterm or imgcat or chafa - fi -} - - -# --- Helper function for printing a key-value pair, aligning the value to the right --- -print_kv() { - local key="$1" - local value="$2" - local key_len=${#key} - local value_len=${#value} - local multiplier="${3:-1}" - - # Correctly calculate padding by accounting for the key, the ": ", and the value. - local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier)) - - # If the text is too long to fit, just add a single space for separation. - if [ "$padding_len" -lt 1 ]; then - padding_len=1 - value=$(echo "$value"| fold -s -w "$((WIDTH - key_len - 3))") - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - else - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - fi -} - -# --- Draw a rule across the screen --- -# TODO: figure out why this method does not work in fzf -draw_rule() { - local rule - # Generate the line of '─' characters, removing the trailing newline `tr` adds. - rule=$(printf '%*s' "$WIDTH" | tr ' ' '─' | tr -d '\n') - # Print the rule with colors and a single, clean newline. - printf "{C_RULE}%s{RESET}\\n" "$rule" -} - - -draw_rule(){ - ll=2 - while [ $ll -le $FZF_PREVIEW_COLUMNS ];do - echo -n -e "{C_RULE}─{RESET}" - ((ll++)) - done - echo -} - -# Generate the same cache key that the Python worker uses -# {PREFIX} is used only on episode previews to make sure they are unique -title={} -hash=$(generate_sha256 "{PREFIX}$title") - -# -# --- Display image if configured and the cached file exists --- -# -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then - image_file="{IMAGE_CACHE_PATH}{PATH_SEP}$hash.png" - if [ -f "$image_file" ]; then - fzf_preview "$image_file" - else - echo "🖼️ Loading image..." - fi - echo # Add a newline for spacing -fi -# Display text info if configured and the cached file exists -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then - info_file="{INFO_CACHE_PATH}{PATH_SEP}$hash" - if [ -f "$info_file" ]; then - source "$info_file" - else - echo "📝 Loading details..." - fi -fi diff --git a/viu_media/assets/scripts/fzf/old/review-info.template.sh b/viu_media/assets/scripts/fzf/old/review-info.template.sh deleted file mode 100644 index fb7cbbc..0000000 --- a/viu_media/assets/scripts/fzf/old/review-info.template.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# -# Viu Review Info Script Template -# This script formats and displays review details in the FZF preview pane. -# Python injects the actual data values into the placeholders. - -draw_rule - -print_kv "Review By" "{REVIEWER_NAME}" - -draw_rule - -print_kv "Summary" "{REVIEW_SUMMARY}" - -draw_rule - -echo "{REVIEW_BODY}" | fold -s -w "$WIDTH" - -draw_rule diff --git a/viu_media/assets/scripts/fzf/old/review-preview.template.sh b/viu_media/assets/scripts/fzf/old/review-preview.template.sh deleted file mode 100644 index 3b8db56..0000000 --- a/viu_media/assets/scripts/fzf/old/review-preview.template.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/sh -# -# FZF Preview Script Template -# -# This script is a template. The placeholders in curly braces, like {NAME} -# are dynamically filled by python using .replace() - -WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80 -IMAGE_RENDERER="{IMAGE_RENDERER}" - -generate_sha256() { - local input - - # Check if input is passed as an argument or piped - if [ -n "$1" ]; then - input="$1" - else - input=$(cat) - fi - - if command -v sha256sum &>/dev/null; then - echo -n "$input" | sha256sum | awk '{print $1}' - elif command -v shasum &>/dev/null; then - echo -n "$input" | shasum -a 256 | awk '{print $1}' - elif command -v sha256 &>/dev/null; then - echo -n "$input" | sha256 | awk '{print $1}' - elif command -v openssl &>/dev/null; then - echo -n "$input" | openssl dgst -sha256 | awk '{print $2}' - else - echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n' - fi -} - - -print_kv() { - local key="$1" - local value="$2" - local key_len=${#key} - local value_len=${#value} - local multiplier="${3:-1}" - - # Correctly calculate padding by accounting for the key, the ": ", and the value. - local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier)) - - # If the text is too long to fit, just add a single space for separation. - if [ "$padding_len" -lt 1 ]; then - padding_len=1 - value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))") - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - else - printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value" - fi -} - - -draw_rule(){ - ll=2 - while [ $ll -le $FZF_PREVIEW_COLUMNS ];do - echo -n -e "{C_RULE}─{RESET}" - ((ll++)) - done - echo -} - -title={} -hash=$(generate_sha256 "$title") - -if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then - info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash" - if [ -f "$info_file" ]; then - source "$info_file" - else - echo "📝 Loading details..." - fi -fi diff --git a/viu_media/assets/scripts/fzf/old/search.template.sh b/viu_media/assets/scripts/fzf/old/search.template.sh deleted file mode 100755 index e461447..0000000 --- a/viu_media/assets/scripts/fzf/old/search.template.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/bin/bash -# -# FZF Dynamic Search Script Template -# -# This script is a template for dynamic search functionality in fzf. -# The placeholders in curly braces, like {QUERY} are dynamically filled by Python using .replace() - -# Configuration variables (injected by Python) -GRAPHQL_ENDPOINT="{GRAPHQL_ENDPOINT}" -CACHE_DIR="{CACHE_DIR}" -SEARCH_RESULTS_FILE="{SEARCH_RESULTS_FILE}" -AUTH_HEADER="{AUTH_HEADER}" - -# Get the current query from fzf -QUERY="{{q}}" - -# If query is empty, exit with empty results -if [ -z "$QUERY" ]; then - echo "" - exit 0 -fi - -# Create GraphQL variables -VARIABLES=$(cat < "$SEARCH_RESULTS_FILE" - -# Parse and display results -if command -v jq >/dev/null 2>&1; then - # Use jq for faster and more reliable JSON parsing - echo "$RESPONSE" | jq -r ' - if .errors then - "❌ Search error: " + (.errors | tostring) - elif (.data.Page.media // []) | length == 0 then - "❌ No results found" - else - .data.Page.media[] | (.title.english // .title.romaji // .title.native // "Unknown") - end - ' 2>/dev/null || echo "❌ Parse error" -else - # Fallback to Python for JSON parsing - echo "$RESPONSE" | python3 -c " -import json -import sys - -try: - data = json.load(sys.stdin) - - if 'errors' in data: - print('❌ Search error: ' + str(data['errors'])) - sys.exit(1) - - if 'data' not in data or 'Page' not in data['data'] or 'media' not in data['data']['Page']: - print('❌ No results found') - sys.exit(0) - - media_list = data['data']['Page']['media'] - - if not media_list: - print('❌ No results found') - sys.exit(0) - - for media in media_list: - title = media.get('title', {}) - english_title = title.get('english') or title.get('romaji') or title.get('native', 'Unknown') - year = media.get('startDate', {}).get('year', 'Unknown') if media.get('startDate') else 'Unknown' - status = media.get('status', 'Unknown') - genres = ', '.join(media.get('genres', [])[:3]) or 'Unknown' - - # Format: Title (Year) [Status] - Genres - print(f'{english_title} ({year}) [{status}] - {genres}') - -except Exception as e: - print(f'❌ Parse error: {str(e)}') - sys.exit(1) -" -fi From 3b008696d58cce80e65ba3128b1c366dc7a7dae1 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:27:30 +0300 Subject: [PATCH 43/48] style: remove unused imports --- viu_media/cli/utils/preview.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index cce9c19..32bd73b 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -9,7 +9,7 @@ import httpx from viu_media.core.utils import formatter from ...core.config import AppConfig -from ...core.constants import APP_CACHE_DIR, PLATFORM, SCRIPTS_DIR +from ...core.constants import APP_CACHE_DIR, SCRIPTS_DIR from ...core.utils.file import AtomicWriter from ...libs.media_api.types import ( AiringScheduleResult, @@ -17,7 +17,6 @@ from ...libs.media_api.types import ( MediaItem, MediaReview, ) -from . import ansi from .preview_workers import PreviewWorkerManager @@ -524,7 +523,7 @@ def get_airing_schedule_preview( preview_file = PREVIEWS_CACHE_DIR / "airing-schedule-preview-script.py" preview_file.write_text(preview_script, encoding="utf-8") - preview_script_final = f"{sys.executable} {preview_file} {{}}" + # preview_script_final = f"{sys.executable} {preview_file} {{}}" # NOTE: disabled cause not very useful return "" From 6b8dfba57e69c32937040d77a81265341f987338 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:30:31 +0300 Subject: [PATCH 44/48] fix: remove double quotes --- viu_media/assets/scripts/fzf/search.py | 3 +-- viu_media/cli/interactive/menu/media/dynamic_search.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/viu_media/assets/scripts/fzf/search.py b/viu_media/assets/scripts/fzf/search.py index 97e3d73..1c6b287 100755 --- a/viu_media/assets/scripts/fzf/search.py +++ b/viu_media/assets/scripts/fzf/search.py @@ -7,7 +7,6 @@ # filled by Python using .replace() during runtime. import json -import os import sys from pathlib import Path from urllib import request @@ -19,7 +18,7 @@ SEARCH_RESULTS_FILE = Path("{SEARCH_RESULTS_FILE}") AUTH_HEADER = "{AUTH_HEADER}" # The GraphQL query is injected as a properly escaped JSON string -GRAPHQL_QUERY = {GRAPHQL_QUERY} +GRAPHQL_QUERY = "{GRAPHQL_QUERY}" # --- Get Query from fzf --- # fzf passes the current query as the first argument when using --bind change:reload diff --git a/viu_media/cli/interactive/menu/media/dynamic_search.py b/viu_media/cli/interactive/menu/media/dynamic_search.py index e0c484e..9cc62ef 100644 --- a/viu_media/cli/interactive/menu/media/dynamic_search.py +++ b/viu_media/cli/interactive/menu/media/dynamic_search.py @@ -29,7 +29,7 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: search_query = gql.SEARCH_MEDIA.read_text(encoding="utf-8") # Escape the GraphQL query as a JSON string literal for Python script - search_query_json = json.dumps(search_query) + search_query_json = json.dumps(search_query).replace('"', "") # Prepare the search script auth_header = "" From 54233aca793a9209e178cafc894a0fa22b32b110 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:42:53 +0300 Subject: [PATCH 45/48] feat: remove redundancy and stick to ansi_utils --- .../assets/scripts/fzf/dynamic_preview.py | 76 ++++--------------- 1 file changed, 13 insertions(+), 63 deletions(-) diff --git a/viu_media/assets/scripts/fzf/dynamic_preview.py b/viu_media/assets/scripts/fzf/dynamic_preview.py index dc338f3..7772e36 100755 --- a/viu_media/assets/scripts/fzf/dynamic_preview.py +++ b/viu_media/assets/scripts/fzf/dynamic_preview.py @@ -15,45 +15,13 @@ from hashlib import sha256 from pathlib import Path # Import the utility functions -try: - from _ansi_utils import ( - get_terminal_width, - print_rule, - print_table_row, - strip_markdown, - wrap_text, - ) - - ANSI_UTILS_AVAILABLE = True -except ImportError: - ANSI_UTILS_AVAILABLE = False - - # Fallback if _ansi_utils is not available - def get_terminal_width(): - return int(os.environ.get("FZF_PREVIEW_COLUMNS", "80")) - - def print_rule(sep_color): - r, g, b = map(int, sep_color.split(",")) - width = get_terminal_width() - print(f"\x1b[38;2;{r};{g};{b}m" + ("─" * width) + "\x1b[0m") - - def print_table_row(key, value, header_color, _key_width, _value_width): - r, g, b = map(int, header_color.split(",")) - print(f"\x1b[38;2;{r};{g};{b};1m{key}\x1b[0m: {value}") - - def strip_markdown(text): - import re - - text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) - text = re.sub(r"__(.+?)__", r"\1", text) - text = re.sub(r"\*(.+?)\*", r"\1", text) - text = re.sub(r"_(.+?)_", r"\1", text) - return text - - def wrap_text(text, width): - import textwrap - - return "\n".join(textwrap.wrap(text, width)) +from _ansi_utils import ( + get_terminal_width, + print_rule, + print_table_row, + strip_markdown, + wrap_text, +) # --- Template Variables (Injected by Python) --- @@ -408,10 +376,7 @@ def main(): print_rule(SEPARATOR_COLOR) for key, value in rows: - if ANSI_UTILS_AVAILABLE: - print_table_row(key, value, HEADER_COLOR, 0, 0) - else: - print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + print_table_row(key, value, HEADER_COLOR, 0, 0) rows = [ ("Episodes", str(episodes)), @@ -420,10 +385,7 @@ def main(): print_rule(SEPARATOR_COLOR) for key, value in rows: - if ANSI_UTILS_AVAILABLE: - print_table_row(key, value, HEADER_COLOR, 0, 0) - else: - print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + print_table_row(key, value, HEADER_COLOR, 0, 0) rows = [ ("Genres", genres), @@ -432,10 +394,7 @@ def main(): print_rule(SEPARATOR_COLOR) for key, value in rows: - if ANSI_UTILS_AVAILABLE: - print_table_row(key, value, HEADER_COLOR, 0, 0) - else: - print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + print_table_row(key, value, HEADER_COLOR, 0, 0) rows = [ ("Start Date", start_date), @@ -444,10 +403,7 @@ def main(): print_rule(SEPARATOR_COLOR) for key, value in rows: - if ANSI_UTILS_AVAILABLE: - print_table_row(key, value, HEADER_COLOR, 0, 0) - else: - print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + print_table_row(key, value, HEADER_COLOR, 0, 0) rows = [ ("Studios", studios), @@ -455,10 +411,7 @@ def main(): print_rule(SEPARATOR_COLOR) for key, value in rows: - if ANSI_UTILS_AVAILABLE: - print_table_row(key, value, HEADER_COLOR, 0, 0) - else: - print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + print_table_row(key, value, HEADER_COLOR, 0, 0) rows = [ ("Synonyms", synonyms), @@ -466,10 +419,7 @@ def main(): print_rule(SEPARATOR_COLOR) for key, value in rows: - if ANSI_UTILS_AVAILABLE: - print_table_row(key, value, HEADER_COLOR, 0, 0) - else: - print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) + print_table_row(key, value, HEADER_COLOR, 0, 0) print_rule(SEPARATOR_COLOR) print(wrap_text(description, term_width)) From d38dc3194f57f74b6561f9071c8de264e512c09d Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:43:18 +0300 Subject: [PATCH 46/48] feat: export ansi utils to preview root dir when doing dynamic previews --- viu_media/cli/utils/preview.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index 32bd73b..aae731b 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -6,6 +6,7 @@ from typing import Dict, List, Optional import httpx +from viu_media.cli.interactive.menu.media.dynamic_search import SEARCH_CACHE_DIR from viu_media.core.utils import formatter from ...core.config import AppConfig @@ -546,7 +547,19 @@ def get_dynamic_anime_preview(config: AppConfig) -> str: # Ensure cache directories exist IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True) INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - _ensure_ansi_utils_in_cache() + source = FZF_SCRIPTS_DIR / "_ansi_utils.py" + dest = PREVIEWS_CACHE_DIR / "_ansi_utils.py" + + if source.exists() and ( + not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime + ): + try: + import shutil + + shutil.copy2(source, dest) + logger.debug(f"Copied _ansi_utils.py to {INFO_CACHE_DIR}") + except Exception as e: + logger.warning(f"Failed to copy _ansi_utils.py to cache: {e}") HEADER_COLOR = config.fzf.preview_header_color.split(",") SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",") From 41aaf92bae127537be139c3e40062a137c7cb585 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 2 Dec 2025 17:44:46 +0300 Subject: [PATCH 47/48] style: remove unused import --- viu_media/cli/utils/preview.py | 1 - 1 file changed, 1 deletion(-) diff --git a/viu_media/cli/utils/preview.py b/viu_media/cli/utils/preview.py index aae731b..06400f6 100644 --- a/viu_media/cli/utils/preview.py +++ b/viu_media/cli/utils/preview.py @@ -6,7 +6,6 @@ from typing import Dict, List, Optional import httpx -from viu_media.cli.interactive.menu.media.dynamic_search import SEARCH_CACHE_DIR from viu_media.core.utils import formatter from ...core.config import AppConfig From bf06d7ee2c3942ec4edc90b62261a8a891c61de9 Mon Sep 17 00:00:00 2001 From: Benedict Xavier <81157281+Benexl@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:46:58 +0300 Subject: [PATCH 48/48] Update viu_media/assets/scripts/fzf/media_info.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- viu_media/assets/scripts/fzf/media_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viu_media/assets/scripts/fzf/media_info.py b/viu_media/assets/scripts/fzf/media_info.py index c3c4b27..fc3fc13 100644 --- a/viu_media/assets/scripts/fzf/media_info.py +++ b/viu_media/assets/scripts/fzf/media_info.py @@ -74,7 +74,7 @@ for key, value in rows: print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) rows = [ - ("Synonymns", "{SYNONYMNS}"), + ("Synonyms", "{SYNONYMNS}"), ] print_rule(SEPARATOR_COLOR)