diff --git a/fastanime/cli/interactive/menus/main.py b/fastanime/cli/interactive/menus/main.py index 001e7be..45e0852 100644 --- a/fastanime/cli/interactive/menus/main.py +++ b/fastanime/cli/interactive/menus/main.py @@ -6,6 +6,7 @@ from rich.console import Console from ....libs.api.params import ApiSearchParams, UserListParams from ....libs.api.types import MediaSearchResult, MediaStatus, UserListStatusType from ...utils.feedback import create_feedback_manager, execute_with_feedback +from ...utils.auth_utils import format_auth_menu_header, check_authentication_required from ..session import Context, session from ..state import ControlFlow, MediaApiState, State @@ -65,7 +66,7 @@ def main(ctx: Context, state: State) -> State | ControlFlow: choice_str = ctx.selector.choose( prompt="Select Category", choices=list(options.keys()), - header="FastAnime Main Menu", + header=format_auth_menu_header(ctx.media_api, "FastAnime Main Menu", icons), ) if not choice_str: @@ -180,13 +181,11 @@ def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAc def action(): feedback = create_feedback_manager(ctx.config.general.icons) - # Check authentication (commented code from original) - # if not ctx.media_api.user_profile: - # feedback.warning( - # f"Please log in to view your '{status.title()}' list", - # "You need to authenticate with AniList to access your personal lists" - # ) - # return "CONTINUE", None + # Check authentication + if not check_authentication_required( + ctx.media_api, feedback, f"view your {status.lower()} list" + ): + return "CONTINUE", None def fetch_data(): return ctx.media_api.fetch_user_list( diff --git a/fastanime/cli/interactive/menus/media_actions.py b/fastanime/cli/interactive/menus/media_actions.py index 6787df8..78d04fb 100644 --- a/fastanime/cli/interactive/menus/media_actions.py +++ b/fastanime/cli/interactive/menus/media_actions.py @@ -7,6 +7,7 @@ from ....libs.api.params import UpdateListEntryParams from ....libs.api.types import MediaItem from ....libs.players.params import PlayerParams from ...utils.feedback import create_feedback_manager, execute_with_feedback +from ...utils.auth_utils import check_authentication_required, get_auth_status_indicator from ..session import Context, session from ..state import ControlFlow, ProviderState, State @@ -21,6 +22,14 @@ def media_actions(ctx: Context, state: State) -> State | ControlFlow: """ icons = ctx.config.general.icons + # Get authentication status for display + auth_status, user_profile = get_auth_status_indicator(ctx.media_api, icons) + + # Create header with auth status + anime = state.media_api.anime + anime_title = anime.title.english or anime.title.romaji if anime else "Unknown" + header = f"Actions for: {anime_title}\n{auth_status}" + # TODO: Add 'Recommendations' and 'Relations' here later. options: Dict[str, MenuAction] = { f"{'▶️ ' if icons else ''}Stream": _stream(ctx, state), @@ -33,7 +42,7 @@ def media_actions(ctx: Context, state: State) -> State | ControlFlow: # --- Prompt and Execute --- choice_str = ctx.selector.choose( - prompt="Select Action", choices=list(options.keys()) + prompt="Select Action", choices=list(options.keys()), header=header ) if choice_str and choice_str in options: @@ -90,13 +99,21 @@ def _add_to_list(ctx: Context, state: State) -> MenuAction: anime = state.media_api.anime if not anime: return ControlFlow.CONTINUE + + # Check authentication before proceeding + if not check_authentication_required( + ctx.media_api, feedback, "add anime to your list" + ): + return ControlFlow.CONTINUE + choices = ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"] status = ctx.selector.choose("Select list status:", choices=choices) if status: + # status is now guaranteed to be one of the valid choices _update_user_list_with_feedback( ctx, anime, - UpdateListEntryParams(media_id=anime.id, status=status), + UpdateListEntryParams(media_id=anime.id, status=status), # type: ignore feedback, ) return ControlFlow.CONTINUE @@ -110,6 +127,11 @@ def _score_anime(ctx: Context, state: State) -> MenuAction: anime = state.media_api.anime if not anime: return ControlFlow.CONTINUE + + # Check authentication before proceeding + if not check_authentication_required(ctx.media_api, feedback, "score anime"): + return ControlFlow.CONTINUE + score_str = ctx.selector.ask("Enter score (0.0 - 10.0):") try: score = float(score_str) if score_str else 0.0 @@ -180,13 +202,8 @@ def _update_user_list_with_feedback( ctx: Context, anime: MediaItem, params: UpdateListEntryParams, feedback ): """Helper to call the API to update a user's list with comprehensive feedback.""" - # Check authentication (commented code from original) - # if not ctx.media_api.user_profile: - # feedback.warning( - # "You must be logged in to modify your list", - # "Please authenticate with AniList to manage your anime lists" - # ) - # return + # Authentication check is handled by the calling functions now + # This function assumes authentication has already been verified def update_operation(): return ctx.media_api.update_list_entry(params) diff --git a/fastanime/cli/interactive/menus/results.py b/fastanime/cli/interactive/menus/results.py index 74a08fb..df01edb 100644 --- a/fastanime/cli/interactive/menus/results.py +++ b/fastanime/cli/interactive/menus/results.py @@ -1,6 +1,7 @@ from rich.console import Console from ....libs.api.types import MediaItem +from ...utils.auth_utils import get_auth_status_indicator from ..session import Context, session from ..state import ControlFlow, MediaApiState, State @@ -47,11 +48,16 @@ def results(ctx: Context, state: State) -> State | ControlFlow: choices.append("Previous Page") choices.append("Back") + # Create header with auth status + auth_status, _ = get_auth_status_indicator(ctx.media_api, ctx.config.general.icons) + header = f"Search Results ({len(anime_items)} anime)\n{auth_status}" + # --- Prompt User --- choice_str = ctx.selector.choose( prompt="Select Anime", choices=choices, preview=preview_command, + header=header, ) if not choice_str: diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index 6b2190c..2c427bd 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -67,15 +67,43 @@ class Session: from ...libs.providers.anime.provider import create_provider from ...libs.selectors import create_selector + # Create API client + media_api = create_api_client(config.general.api_client, config) + + # Attempt to load saved user authentication + self._load_saved_authentication(media_api) + self._context = Context( config=config, provider=create_provider(config.general.provider), selector=create_selector(config), player=create_player(config), - media_api=create_api_client(config.general.api_client, config), + media_api=media_api, ) logger.info("Application context reloaded.") + def _load_saved_authentication(self, media_api): + """Attempt to load saved user authentication.""" + try: + from ..auth.manager import AuthManager + + auth_manager = AuthManager() + user_data = auth_manager.load_user_profile() + + if user_data and user_data.get("token"): + # Try to authenticate with the saved token + profile = media_api.authenticate(user_data["token"]) + if profile: + logger.info(f"Successfully authenticated as {profile.name}") + else: + logger.warning("Saved authentication token is invalid or expired") + else: + logger.debug("No saved authentication found") + + except Exception as e: + logger.error(f"Failed to load saved authentication: {e}") + # Continue without authentication rather than failing completely + def _edit_config(self): """Handles the logic for editing the config file and reloading the context.""" from ..utils.feedback import create_feedback_manager diff --git a/fastanime/cli/utils/auth_utils.py b/fastanime/cli/utils/auth_utils.py new file mode 100644 index 0000000..b591594 --- /dev/null +++ b/fastanime/cli/utils/auth_utils.py @@ -0,0 +1,116 @@ +""" +Authentication utilities for the interactive CLI. +Provides functions to check authentication status and display user information. +""" + +from typing import Optional + +from ...libs.api.base import BaseApiClient +from ...libs.api.types import UserProfile +from .feedback import FeedbackManager + + +def get_auth_status_indicator( + api_client: BaseApiClient, icons_enabled: bool = True +) -> tuple[str, Optional[UserProfile]]: + """ + Get authentication status indicator for display in menus. + + Returns: + tuple of (status_text, user_profile or None) + """ + user_profile = getattr(api_client, "user_profile", None) + + if user_profile: + # User is authenticated + icon = "🟢 " if icons_enabled else "● " + status_text = f"{icon}Logged in as {user_profile.name}" + return status_text, user_profile + else: + # User is not authenticated + icon = "🔴 " if icons_enabled else "○ " + status_text = f"{icon}Not logged in" + return status_text, None + + +def format_user_info_header( + user_profile: Optional[UserProfile], icons_enabled: bool = True +) -> str: + """ + Format user information for display in menu headers. + + Returns: + Formatted string with user info or empty string if not authenticated + """ + if not user_profile: + return "" + + icon = "👤 " if icons_enabled else "" + return f"{icon}User: {user_profile.name} (ID: {user_profile.id})" + + +def check_authentication_required( + api_client: BaseApiClient, + feedback: FeedbackManager, + operation_name: str = "this action", +) -> bool: + """ + Check if user is authenticated and show appropriate feedback if not. + + Returns: + True if authenticated, False if not (with feedback shown) + """ + user_profile = getattr(api_client, "user_profile", None) + + if not user_profile: + feedback.warning( + f"Authentication required for {operation_name}", + "Please log in to your AniList account using 'fastanime anilist auth' to access this feature", + ) + return False + + return True + + +def format_auth_menu_header( + api_client: BaseApiClient, base_header: str, icons_enabled: bool = True +) -> str: + """ + Format menu header with authentication status. + + Args: + api_client: The API client to check authentication status + base_header: Base header text (e.g., "FastAnime Main Menu") + icons_enabled: Whether to show icons + + Returns: + Formatted header with authentication status + """ + status_text, user_profile = get_auth_status_indicator(api_client, icons_enabled) + + if user_profile: + return f"{base_header}\n{status_text}" + else: + return f"{base_header}\n{status_text} - Some features require authentication" + + +def prompt_for_authentication( + feedback: FeedbackManager, operation_name: str = "continue" +) -> bool: + """ + Prompt user about authentication requirement and offer guidance. + + Returns: + True if user wants to continue anyway, False if they want to stop + """ + feedback.info( + "Authentication Required", + f"To {operation_name}, you need to log in to your AniList account", + ) + + feedback.info( + "How to authenticate:", + "Run 'fastanime anilist auth' in your terminal to log in", + ) + + return feedback.confirm("Continue without authentication?", default=False) diff --git a/test_auth_display.py b/test_auth_display.py new file mode 100644 index 0000000..8836205 --- /dev/null +++ b/test_auth_display.py @@ -0,0 +1,84 @@ +""" +Test script to verify the authentication system works correctly. +This tests the auth utilities and their integration with the feedback system. +""" + +import sys +from pathlib import Path + +# Add the project root to the path so we can import fastanime modules +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from fastanime.cli.utils.auth_utils import ( + get_auth_status_indicator, + format_user_info_header, + check_authentication_required, + format_auth_menu_header, + prompt_for_authentication, +) +from fastanime.cli.utils.feedback import create_feedback_manager +from fastanime.libs.api.types import UserProfile + + +class MockApiClient: + """Mock API client for testing authentication utilities.""" + + def __init__(self, authenticated=False): + if authenticated: + self.user_profile = UserProfile( + id=12345, + name="TestUser", + avatar_url="https://example.com/avatar.jpg", + banner_url="https://example.com/banner.jpg", + ) + else: + self.user_profile = None + + +def test_auth_status_display(): + """Test authentication status display functionality.""" + print("=== Testing Authentication Status Display ===\n") + + feedback = create_feedback_manager(icons_enabled=True) + + print("1. Testing authentication status when NOT logged in:") + mock_api_not_auth = MockApiClient(authenticated=False) + status_text, user_profile = get_auth_status_indicator(mock_api_not_auth, True) + print(f" Status: {status_text}") + print(f" User Profile: {user_profile}") + + print("\n2. Testing authentication status when logged in:") + mock_api_auth = MockApiClient(authenticated=True) + status_text, user_profile = get_auth_status_indicator(mock_api_auth, True) + print(f" Status: {status_text}") + print(f" User Profile: {user_profile}") + + print("\n3. Testing user info header formatting:") + header = format_user_info_header(user_profile, True) + print(f" Header: {header}") + + print("\n4. Testing menu header formatting:") + auth_header = format_auth_menu_header(mock_api_auth, "Test Menu", True) + print(f" Auth Header:\n{auth_header}") + + print("\n5. Testing authentication check (not authenticated):") + is_auth = check_authentication_required( + mock_api_not_auth, feedback, "test operation" + ) + print(f" Authentication passed: {is_auth}") + + print("\n6. Testing authentication check (authenticated):") + is_auth = check_authentication_required(mock_api_auth, feedback, "test operation") + print(f" Authentication passed: {is_auth}") + + print("\n7. Testing authentication prompt:") + # Note: This will show interactive prompts if run in a terminal + # prompt_for_authentication(feedback, "access your anime list") + print(" Skipped interactive prompt test - uncomment to test manually") + + print("\n=== Authentication Tests Completed! ===") + + +if __name__ == "__main__": + test_auth_status_display()