From b8f77d80e9bc9d7d220e132bfcd7a7d0b95660b8 Mon Sep 17 00:00:00 2001 From: benexl Date: Wed, 31 Dec 2025 18:43:59 +0300 Subject: [PATCH] feat: implement restore mode for dynamic search with last query and cached results --- viu_media/assets/scripts/fzf/search.py | 4 ++ .../interactive/menu/media/dynamic_search.py | 54 +++++++++++++++++++ viu_media/libs/selectors/base.py | 4 ++ viu_media/libs/selectors/fzf/selector.py | 14 ++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/viu_media/assets/scripts/fzf/search.py b/viu_media/assets/scripts/fzf/search.py index 01f5dc8..3bb83db 100755 --- a/viu_media/assets/scripts/fzf/search.py +++ b/viu_media/assets/scripts/fzf/search.py @@ -30,6 +30,7 @@ from _filter_parser import parse_filters # --- Template Variables (Injected by Python) --- GRAPHQL_ENDPOINT = "{GRAPHQL_ENDPOINT}" SEARCH_RESULTS_FILE = Path("{SEARCH_RESULTS_FILE}") +LAST_QUERY_FILE = Path("{LAST_QUERY_FILE}") AUTH_HEADER = "{AUTH_HEADER}" # The GraphQL query is injected as a properly escaped JSON string @@ -176,6 +177,9 @@ def main(): try: with open(SEARCH_RESULTS_FILE, "w", encoding="utf-8") as f: json.dump(response, f, ensure_ascii=False, indent=2) + # Also save the raw query so it can be restored when going back + with open(LAST_QUERY_FILE, "w", encoding="utf-8") as f: + f.write(RAW_QUERY) except IOError as e: print(f"❌ Failed to save results: {e}") 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 000603e..6b518fc 100644 --- a/viu_media/cli/interactive/menu/media/dynamic_search.py +++ b/viu_media/cli/interactive/menu/media/dynamic_search.py @@ -13,11 +13,38 @@ logger = logging.getLogger(__name__) SEARCH_CACHE_DIR = APP_CACHE_DIR / "previews" / "dynamic-search" SEARCH_RESULTS_FILE = SEARCH_CACHE_DIR / "current_search_results.json" +LAST_QUERY_FILE = SEARCH_CACHE_DIR / "last_query.txt" +RESTORE_MODE_FILE = SEARCH_CACHE_DIR / ".restore_mode" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf" SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.py").read_text(encoding="utf-8") FILTER_PARSER_SCRIPT = FZF_SCRIPTS_DIR / "_filter_parser.py" +def _load_cached_titles() -> list[str]: + """Load titles from cached search results for display in fzf.""" + if not SEARCH_RESULTS_FILE.exists(): + return [] + + 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", []) + titles = [] + for media in media_list: + title_obj = media.get("title", {}) + title = ( + title_obj.get("english") + or title_obj.get("romaji") + or title_obj.get("native") + or "Unknown" + ) + titles.append(title) + return titles + except (IOError, json.JSONDecodeError): + return [] + + @session.menu def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: """Dynamic search menu that provides real-time search results.""" @@ -27,6 +54,12 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: # Ensure cache directory exists SEARCH_CACHE_DIR.mkdir(parents=True, exist_ok=True) + # Check if we're in restore mode (coming back from media_actions) + restore_mode = RESTORE_MODE_FILE.exists() + if restore_mode: + # Clear the restore flag + RESTORE_MODE_FILE.unlink(missing_ok=True) + # Read the GraphQL search query from .....libs.media_api.anilist import gql @@ -46,6 +79,7 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: "GRAPHQL_ENDPOINT": "https://graphql.anilist.co", "GRAPHQL_QUERY": search_query_json, "SEARCH_RESULTS_FILE": SEARCH_RESULTS_FILE.as_posix(), + "LAST_QUERY_FILE": LAST_QUERY_FILE.as_posix(), "AUTH_HEADER": auth_header, } @@ -71,6 +105,19 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: # Header hint for filter syntax filter_hint = "💡 Filters: @genre:action @status:airing @year:2024 @sort:score (type @help for more)" + # Only load previous query if we're in restore mode (coming back from media_actions) + initial_query = None + cached_results = None + if restore_mode: + # Load previous query + if LAST_QUERY_FILE.exists(): + try: + initial_query = LAST_QUERY_FILE.read_text(encoding="utf-8").strip() + except IOError: + pass + # Load cached results to display immediately without network request + cached_results = _load_cached_titles() + try: # Prepare preview functionality preview_command = None @@ -85,12 +132,16 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: search_command=search_command_final, preview=preview_command, header=filter_hint, + initial_query=initial_query, + initial_results=cached_results, ) else: choice = ctx.selector.search( prompt="Search Anime", search_command=search_command_final, header=filter_hint, + initial_query=initial_query, + initial_results=cached_results, ) except NotImplementedError: feedback.error("Dynamic search is not supported by your current selector") @@ -129,6 +180,9 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective: logger.error(f"Could not find selected media for choice: {choice}") return InternalDirective.MAIN + # Set restore mode flag so we can restore state when user goes back + RESTORE_MODE_FILE.touch() + # Navigate to media actions with the selected item return State( menu_name=MenuName.MEDIA_ACTIONS, diff --git a/viu_media/libs/selectors/base.py b/viu_media/libs/selectors/base.py index 9f4d83e..7c4063a 100644 --- a/viu_media/libs/selectors/base.py +++ b/viu_media/libs/selectors/base.py @@ -88,6 +88,8 @@ class BaseSelector(ABC): *, preview: Optional[str] = None, header: Optional[str] = None, + initial_query: Optional[str] = None, + initial_results: Optional[List[str]] = None, ) -> str | None: """ Provides dynamic search functionality that reloads results based on user input. @@ -97,6 +99,8 @@ class BaseSelector(ABC): search_command: The command to execute for searching/reloading results. preview: An optional command or string for a preview window. header: An optional header to display above the choices. + initial_query: An optional initial query to pre-populate the search. + initial_results: Optional list of results to display initially (avoids network request). Returns: The string of the chosen item. diff --git a/viu_media/libs/selectors/fzf/selector.py b/viu_media/libs/selectors/fzf/selector.py index af107fd..fa52c87 100644 --- a/viu_media/libs/selectors/fzf/selector.py +++ b/viu_media/libs/selectors/fzf/selector.py @@ -117,7 +117,7 @@ class FzfSelector(BaseSelector): lines = result.stdout.strip().splitlines() return lines[-1] if lines else (default or "") - def search(self, prompt, search_command, *, preview=None, header=None): + def search(self, prompt, search_command, *, preview=None, header=None, initial_query=None, initial_results=None): """Enhanced search using fzf's --reload flag for dynamic search.""" # Build the header with optional custom header line display_header = self.header @@ -137,12 +137,22 @@ class FzfSelector(BaseSelector): "--ansi", ] + # If there's an initial query, set it + if initial_query: + commands.extend(["--query", initial_query]) + # Only trigger reload on start if we don't have cached results + if not initial_results: + commands.extend(["--bind", f"start:reload({search_command})"]) + if preview: commands.extend(["--preview", preview]) + # Use cached results as initial input if provided (avoids network request) + fzf_input = "\n".join(initial_results) if initial_results else "" + result = subprocess.run( commands, - input="", + input=fzf_input, stdout=subprocess.PIPE, text=True, encoding="utf-8",