From be4cc58e471e57574c9e4b2378ef0fe253ce2eff Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 14 Jul 2025 22:44:27 +0300 Subject: [PATCH] feat: pagination --- fastanime/cli/interactive/menus/main.py | 72 +++++---- fastanime/cli/interactive/menus/results.py | 176 +++++++++++++++++++-- fastanime/cli/interactive/state.py | 5 + 3 files changed, 214 insertions(+), 39 deletions(-) diff --git a/fastanime/cli/interactive/menus/main.py b/fastanime/cli/interactive/menus/main.py index 98b93c4..b58405f 100644 --- a/fastanime/cli/interactive/menus/main.py +++ b/fastanime/cli/interactive/menus/main.py @@ -10,7 +10,7 @@ from ...utils.auth_utils import format_auth_menu_header, check_authentication_re from ..session import Context, session from ..state import ControlFlow, MediaApiState, State -MenuAction = Callable[[], Tuple[str, MediaSearchResult | None]] +MenuAction = Callable[[], Tuple[str, MediaSearchResult | None, ApiSearchParams | None, UserListParams | None]] @session.menu @@ -59,13 +59,13 @@ def main(ctx: Context, state: State) -> State | ControlFlow: ctx, "REPEATING" ), # --- Local Watch History --- - f"{'📖 ' if icons else ''}Local Watch History": lambda: ("WATCH_HISTORY", None), + f"{'📖 ' if icons else ''}Local Watch History": lambda: ("WATCH_HISTORY", None, None, None), # --- Authentication and Account Management --- - f"{'🔐 ' if icons else ''}Authentication": lambda: ("AUTH", None), + f"{'🔐 ' if icons else ''}Authentication": lambda: ("AUTH", None, None, None), # --- Control Flow and Utility Options --- - f"{'🔧 ' if icons else ''}Session Management": lambda: ("SESSION_MANAGEMENT", None), - f"{'📝 ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None), - f"{'❌ ' if icons else ''}Exit": lambda: ("EXIT", None), + f"{'🔧 ' if icons else ''}Session Management": lambda: ("SESSION_MANAGEMENT", None, None, None), + f"{'📝 ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None, None, None), + f"{'❌ ' if icons else ''}Exit": lambda: ("EXIT", None, None, None), } choice_str = ctx.selector.choose( @@ -80,7 +80,7 @@ def main(ctx: Context, state: State) -> State | ControlFlow: # --- Action Handling --- selected_action = options[choice_str] - next_menu_name, result_data = selected_action() + next_menu_name, result_data, api_params, user_list_params = selected_action() if next_menu_name == "EXIT": return ControlFlow.EXIT @@ -105,7 +105,11 @@ def main(ctx: Context, state: State) -> State | ControlFlow: # On success, transition to the RESULTS menu state. return State( menu_name="RESULTS", - media_api=MediaApiState(search_results=result_data), + media_api=MediaApiState( + search_results=result_data, + original_api_params=api_params, + original_user_list_params=user_list_params, + ), ) @@ -117,12 +121,13 @@ def _create_media_list_action( def action(): feedback = create_feedback_manager(ctx.config.general.icons) + # Create the search parameters + search_params = ApiSearchParams( + sort=sort, per_page=ctx.config.anilist.per_page, status=status + ) + def fetch_data(): - return ctx.media_api.search_media( - ApiSearchParams( - sort=sort, per_page=ctx.config.anilist.per_page, status=status - ) - ) + return ctx.media_api.search_media(search_params) success, result = execute_with_feedback( fetch_data, @@ -132,7 +137,8 @@ def _create_media_list_action( success_msg="Anime list loaded successfully", ) - return "RESULTS" if success else "CONTINUE", result + # Return the search parameters along with the result for pagination + return ("RESULTS", result, search_params, None) if success else ("CONTINUE", None, None, None) return action @@ -141,13 +147,14 @@ def _create_random_media_list(ctx: Context) -> MenuAction: def action(): feedback = create_feedback_manager(ctx.config.general.icons) + # Create the search parameters + search_params = ApiSearchParams( + id_in=random.sample(range(1, 160000), k=50), + per_page=ctx.config.anilist.per_page, + ) + def fetch_data(): - return ctx.media_api.search_media( - ApiSearchParams( - id_in=random.sample(range(1, 160000), k=50), - per_page=ctx.config.anilist.per_page, - ) - ) + return ctx.media_api.search_media(search_params) success, result = execute_with_feedback( fetch_data, @@ -157,7 +164,8 @@ def _create_random_media_list(ctx: Context) -> MenuAction: success_msg="Random anime loaded successfully", ) - return "RESULTS" if success else "CONTINUE", result + # Return the search parameters along with the result for pagination + return ("RESULTS", result, search_params, None) if success else ("CONTINUE", None, None, None) return action @@ -168,10 +176,13 @@ def _create_search_media_list(ctx: Context) -> MenuAction: query = ctx.selector.ask("Search for Anime") if not query: - return "CONTINUE", None + return "CONTINUE", None, None, None + + # Create the search parameters + search_params = ApiSearchParams(query=query) def fetch_data(): - return ctx.media_api.search_media(ApiSearchParams(query=query)) + return ctx.media_api.search_media(search_params) success, result = execute_with_feedback( fetch_data, @@ -181,7 +192,8 @@ def _create_search_media_list(ctx: Context) -> MenuAction: success_msg=f"Search results for '{query}' loaded successfully", ) - return "RESULTS" if success else "CONTINUE", result + # Return the search parameters along with the result for pagination + return ("RESULTS", result, search_params, None) if success else ("CONTINUE", None, None, None) return action @@ -196,12 +208,13 @@ def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAc if not check_authentication_required( ctx.media_api, feedback, f"view your {status.lower()} list" ): - return "CONTINUE", None + return "CONTINUE", None, None, None + + # Create the user list parameters + user_list_params = UserListParams(status=status, per_page=ctx.config.anilist.per_page) def fetch_data(): - return ctx.media_api.fetch_user_list( - UserListParams(status=status, per_page=ctx.config.anilist.per_page) - ) + return ctx.media_api.fetch_user_list(user_list_params) success, result = execute_with_feedback( fetch_data, @@ -211,6 +224,7 @@ def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAc success_msg=f"Your {status.lower()} list loaded successfully", ) - return "RESULTS" if success else "CONTINUE", result + # Return the user list parameters along with the result for pagination + return ("RESULTS", result, None, user_list_params) if success else ("CONTINUE", None, None, None) return action diff --git a/fastanime/cli/interactive/menus/results.py b/fastanime/cli/interactive/menus/results.py index df01edb..4e256dc 100644 --- a/fastanime/cli/interactive/menus/results.py +++ b/fastanime/cli/interactive/menus/results.py @@ -1,7 +1,9 @@ from rich.console import Console from ....libs.api.types import MediaItem +from ....libs.api.params import ApiSearchParams, UserListParams from ...utils.auth_utils import get_auth_status_indicator +from ...utils.feedback import create_feedback_manager, execute_with_feedback from ..session import Context, session from ..state import ControlFlow, MediaApiState, State @@ -41,16 +43,21 @@ def results(ctx: Context, state: State) -> State | ControlFlow: choices = formatted_titles page_info = search_results.page_info - # Add pagination controls if available + # Add pagination controls if available with more descriptive text if page_info.has_next_page: - choices.append("Next Page") + choices.append(f"{'➡️ ' if ctx.config.general.icons else ''}Next Page (Page {page_info.current_page + 1})") if page_info.current_page > 1: - choices.append("Previous Page") + choices.append(f"{'⬅️ ' if ctx.config.general.icons else ''}Previous Page (Page {page_info.current_page - 1})") choices.append("Back") - # Create header with auth status + # Create header with auth status and pagination info auth_status, _ = get_auth_status_indicator(ctx.media_api, ctx.config.general.icons) - header = f"Search Results ({len(anime_items)} anime)\n{auth_status}" + pagination_info = f"Page {page_info.current_page}" + if page_info.total > 0: + total_pages = (page_info.total + page_info.per_page - 1) // page_info.per_page + pagination_info += f" of ~{total_pages}" + + header = f"Search Results ({len(anime_items)} anime) - {pagination_info}\n{auth_status}" # --- Prompt User --- choice_str = ctx.selector.choose( @@ -67,11 +74,13 @@ def results(ctx: Context, state: State) -> State | ControlFlow: if choice_str == "Back": return ControlFlow.BACK - if choice_str == "Next Page" or choice_str == "Previous Page": - page_delta = 1 if choice_str == "Next Page" else -1 - - # TODO: implement next page logic - return ControlFlow.CONTINUE + # Handle pagination - check for both old and new formats + if (choice_str == "Next Page" or choice_str == "Previous Page" or + choice_str.startswith("Next Page (") or choice_str.startswith("Previous Page (")): + page_delta = 1 if choice_str.startswith("Next Page") else -1 + + # Implement pagination logic + return _handle_pagination(ctx, state, page_delta) # If an anime was selected, transition to the MEDIA_ACTIONS state selected_anime = anime_map.get(choice_str) @@ -114,3 +123,150 @@ def _format_anime_choice(anime: MediaItem, config) -> str: display_title += f" {icon}{unwatched} new{icon}" return display_title + + +def _handle_pagination(ctx: Context, state: State, page_delta: int) -> State | ControlFlow: + """ + Handle pagination by fetching the next or previous page of results. + + Args: + ctx: The application context + state: Current state containing search results and original parameters + page_delta: +1 for next page, -1 for previous page + + Returns: + New State with updated search results or ControlFlow.CONTINUE on error + """ + feedback = create_feedback_manager(ctx.config.general.icons) + + if not state.media_api.search_results: + feedback.error("No search results available for pagination") + return ControlFlow.CONTINUE + + current_page = state.media_api.search_results.page_info.current_page + new_page = current_page + page_delta + + # Validate page bounds + if new_page < 1: + feedback.warning("Already at the first page") + return ControlFlow.CONTINUE + + if page_delta > 0 and not state.media_api.search_results.page_info.has_next_page: + feedback.warning("No more pages available") + return ControlFlow.CONTINUE + + # Determine which type of search to perform based on stored parameters + if state.media_api.original_api_params: + # Media search (trending, popular, search, etc.) + return _fetch_media_page(ctx, state, new_page, feedback) + elif state.media_api.original_user_list_params: + # User list search (watching, completed, etc.) + return _fetch_user_list_page(ctx, state, new_page, feedback) + else: + feedback.error("No original search parameters found for pagination") + return ControlFlow.CONTINUE + + +def _fetch_media_page(ctx: Context, state: State, page: int, feedback) -> State | ControlFlow: + """Fetch a specific page for media search results.""" + original_params = state.media_api.original_api_params + if not original_params: + feedback.error("No original API parameters found") + return ControlFlow.CONTINUE + + # Create new parameters with updated page number + new_params = ApiSearchParams( + query=original_params.query, + page=page, + per_page=original_params.per_page, + sort=original_params.sort, + id_in=original_params.id_in, + genre_in=original_params.genre_in, + genre_not_in=original_params.genre_not_in, + tag_in=original_params.tag_in, + tag_not_in=original_params.tag_not_in, + status_in=original_params.status_in, + status=original_params.status, + status_not_in=original_params.status_not_in, + popularity_greater=original_params.popularity_greater, + popularity_lesser=original_params.popularity_lesser, + averageScore_greater=original_params.averageScore_greater, + averageScore_lesser=original_params.averageScore_lesser, + seasonYear=original_params.seasonYear, + season=original_params.season, + startDate_greater=original_params.startDate_greater, + startDate_lesser=original_params.startDate_lesser, + startDate=original_params.startDate, + endDate_greater=original_params.endDate_greater, + endDate_lesser=original_params.endDate_lesser, + format_in=original_params.format_in, + type=original_params.type, + on_list=original_params.on_list, + ) + + def fetch_data(): + return ctx.media_api.search_media(new_params) + + success, result = execute_with_feedback( + fetch_data, + feedback, + f"fetch page {page}", + loading_msg=f"Loading page {page}", + success_msg=f"Page {page} loaded successfully", + show_loading=False, + ) + + if not success or not result: + return ControlFlow.CONTINUE + + # Return new state with updated results + return State( + menu_name="RESULTS", + media_api=MediaApiState( + search_results=result, + original_api_params=original_params, # Keep original params for further pagination + original_user_list_params=state.media_api.original_user_list_params, + ), + provider=state.provider, # Preserve provider state if it exists + ) + + +def _fetch_user_list_page(ctx: Context, state: State, page: int, feedback) -> State | ControlFlow: + """Fetch a specific page for user list results.""" + original_params = state.media_api.original_user_list_params + if not original_params: + feedback.error("No original user list parameters found") + return ControlFlow.CONTINUE + + # Create new parameters with updated page number + new_params = UserListParams( + status=original_params.status, + page=page, + per_page=original_params.per_page, + ) + + def fetch_data(): + return ctx.media_api.fetch_user_list(new_params) + + success, result = execute_with_feedback( + fetch_data, + feedback, + f"fetch page {page} of {original_params.status.lower()} list", + loading_msg=f"Loading page {page}", + success_msg=f"Page {page} loaded successfully", + show_loading=False, + ) + + if not success or not result: + return ControlFlow.CONTINUE + + # Return new state with updated results + return State( + menu_name="RESULTS", + media_api=MediaApiState( + search_results=result, + original_api_params=state.media_api.original_api_params, + original_user_list_params=original_params, # Keep original params for further pagination + ), + provider=state.provider, # Preserve provider state if it exists + ) diff --git a/fastanime/cli/interactive/state.py b/fastanime/cli/interactive/state.py index 11d38b7..ba0de62 100644 --- a/fastanime/cli/interactive/state.py +++ b/fastanime/cli/interactive/state.py @@ -9,6 +9,7 @@ from ...libs.api.types import ( MediaStatus, UserListStatusType, ) +from ...libs.api.params import ApiSearchParams, UserListParams # Add this import from ...libs.players.types import PlayerResult from ...libs.providers.anime.types import Anime, SearchResults, Server @@ -76,6 +77,10 @@ class MediaApiState(BaseModel): user_media_status: Optional[UserListStatusType] = None media_status: Optional[MediaStatus] = None anime: Optional[MediaItem] = None + + # Add pagination support: store original search parameters to enable page navigation + original_api_params: Optional[ApiSearchParams] = None + original_user_list_params: Optional[UserListParams] = None model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)