mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-01-05 09:17:28 -08:00
chore: cleanup
This commit is contained in:
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Interactive authentication menu for AniList OAuth login/logout and user profile management.
|
||||
Implements Step 5: AniList Authentication Flow
|
||||
"""
|
||||
|
||||
import webbrowser
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ....libs.media_api.types import UserProfile
|
||||
from ...auth.manager import AuthManager
|
||||
from ...utils.feedback import create_feedback_manager, execute_with_feedback
|
||||
from ..session import Context, session
|
||||
from ..state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def auth(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""
|
||||
Interactive authentication menu for managing AniList login/logout and viewing user profile.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Get current authentication status
|
||||
user_profile = getattr(ctx.media_api, "user_profile", None)
|
||||
auth_manager = AuthManager()
|
||||
|
||||
# Display current authentication status
|
||||
_display_auth_status(console, user_profile, icons)
|
||||
|
||||
# Menu options based on authentication status
|
||||
if user_profile:
|
||||
options = [
|
||||
f"{'👤 ' if icons else ''}View Profile Details",
|
||||
f"{'🔓 ' if icons else ''}Logout",
|
||||
f"{'↩️ ' if icons else ''}Back to Main Menu",
|
||||
]
|
||||
else:
|
||||
options = [
|
||||
f"{'🔐 ' if icons else ''}Login to AniList",
|
||||
f"{'❓ ' if icons else ''}How to Get Token",
|
||||
f"{'↩️ ' if icons else ''}Back to Main Menu",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Authentication Action",
|
||||
choices=options,
|
||||
header="AniList Authentication Menu",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Handle menu choices
|
||||
if "Login to AniList" in choice:
|
||||
return _handle_login(ctx, auth_manager, feedback, icons)
|
||||
elif "Logout" in choice:
|
||||
return _handle_logout(ctx, auth_manager, feedback, icons)
|
||||
elif "View Profile Details" in choice:
|
||||
_display_user_profile_details(console, user_profile, icons)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return InternalDirective.RELOAD
|
||||
elif "How to Get Token" in choice:
|
||||
_display_token_help(console, icons)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return InternalDirective.RELOAD
|
||||
else: # Back to Main Menu
|
||||
return InternalDirective.BACK
|
||||
|
||||
|
||||
def _display_auth_status(
|
||||
console: Console, user_profile: Optional[UserProfile], icons: bool
|
||||
):
|
||||
"""Display current authentication status in a nice panel."""
|
||||
if user_profile:
|
||||
status_icon = "🟢" if icons else "[green]●[/green]"
|
||||
status_text = f"{status_icon} Authenticated"
|
||||
user_info = f"Logged in as: [bold cyan]{user_profile.name}[/bold cyan]\nUser ID: {user_profile.id}"
|
||||
else:
|
||||
status_icon = "🔴" if icons else "[red]○[/red]"
|
||||
status_text = f"{status_icon} Not Authenticated"
|
||||
user_info = "Log in to access personalized features like:\n• Your anime lists (Watching, Completed, etc.)\n• Progress tracking\n• List management"
|
||||
|
||||
panel = Panel(
|
||||
user_info,
|
||||
title=f"Authentication Status: {status_text}",
|
||||
border_style="green" if user_profile else "red",
|
||||
)
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def _handle_login(
|
||||
ctx: Context, auth_manager: AuthManager, feedback, icons: bool
|
||||
) -> State | InternalDirective:
|
||||
"""Handle the interactive login process."""
|
||||
|
||||
def perform_login():
|
||||
# Open browser to AniList OAuth page
|
||||
oauth_url = "https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
|
||||
|
||||
if feedback.confirm(
|
||||
"Open AniList authorization page in browser?", default=True
|
||||
):
|
||||
try:
|
||||
webbrowser.open(oauth_url)
|
||||
feedback.info(
|
||||
"Browser opened",
|
||||
"Complete the authorization process in your browser",
|
||||
)
|
||||
except Exception:
|
||||
feedback.warning(
|
||||
"Could not open browser automatically",
|
||||
f"Please manually visit: {oauth_url}",
|
||||
)
|
||||
else:
|
||||
feedback.info("Manual authorization", f"Please visit: {oauth_url}")
|
||||
|
||||
# Get token from user
|
||||
feedback.info(
|
||||
"Token Input", "Paste the token from the browser URL after '#access_token='"
|
||||
)
|
||||
token = ctx.selector.ask("Enter your AniList Access Token")
|
||||
|
||||
if not token or not token.strip():
|
||||
feedback.error("Login cancelled", "No token provided")
|
||||
return None
|
||||
|
||||
# Authenticate with the API
|
||||
profile = ctx.media_api.authenticate(token.strip())
|
||||
|
||||
if not profile:
|
||||
feedback.error(
|
||||
"Authentication failed", "The token may be invalid or expired"
|
||||
)
|
||||
return None
|
||||
|
||||
# Save credentials using the auth manager
|
||||
auth_manager.save_user_profile(profile, token.strip())
|
||||
return profile
|
||||
|
||||
success, profile = execute_with_feedback(
|
||||
perform_login,
|
||||
feedback,
|
||||
"authenticate",
|
||||
loading_msg="Validating token with AniList",
|
||||
success_msg="Successfully logged in! 🎉"
|
||||
if icons
|
||||
else "Successfully logged in!",
|
||||
error_msg="Login failed",
|
||||
show_loading=True,
|
||||
)
|
||||
|
||||
if success and profile:
|
||||
feedback.success(
|
||||
f"Logged in as {profile.name}" if profile else "Successfully logged in"
|
||||
)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
|
||||
def _handle_logout(
|
||||
ctx: Context, auth_manager: AuthManager, feedback, icons: bool
|
||||
) -> State | InternalDirective:
|
||||
"""Handle the logout process with confirmation."""
|
||||
if not feedback.confirm(
|
||||
"Are you sure you want to logout?",
|
||||
"This will remove your saved AniList token and log you out",
|
||||
default=False,
|
||||
):
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
def perform_logout():
|
||||
# Clear from auth manager
|
||||
if hasattr(auth_manager, "logout"):
|
||||
auth_manager.logout()
|
||||
else:
|
||||
auth_manager.clear_user_profile()
|
||||
|
||||
# Clear from API client
|
||||
ctx.media_api.token = None
|
||||
ctx.media_api.user_profile = None
|
||||
if hasattr(ctx.media_api, "http_client"):
|
||||
ctx.media_api.http_client.headers.pop("Authorization", None)
|
||||
|
||||
return True
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
perform_logout,
|
||||
feedback,
|
||||
"logout",
|
||||
loading_msg="Logging out",
|
||||
success_msg="Successfully logged out 👋"
|
||||
if icons
|
||||
else "Successfully logged out",
|
||||
error_msg="Logout failed",
|
||||
show_loading=False,
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
return InternalDirective.CONFIG_EDIT
|
||||
|
||||
|
||||
def _display_user_profile_details(
|
||||
console: Console, user_profile: UserProfile, icons: bool
|
||||
):
|
||||
"""Display detailed user profile information."""
|
||||
if not user_profile:
|
||||
console.print("[red]No user profile available[/red]")
|
||||
return
|
||||
|
||||
# Create a detailed profile table
|
||||
table = Table(title=f"{'👤 ' if icons else ''}User Profile: {user_profile.name}")
|
||||
table.add_column("Property", style="cyan", no_wrap=True)
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
table.add_row("Name", user_profile.name)
|
||||
table.add_row("User ID", str(user_profile.id))
|
||||
|
||||
if user_profile.avatar_url:
|
||||
table.add_row("Avatar URL", user_profile.avatar_url)
|
||||
|
||||
if user_profile.banner_url:
|
||||
table.add_row("Banner URL", user_profile.banner_url)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
# Show available features
|
||||
features_panel = Panel(
|
||||
"Available Features:\n"
|
||||
f"{'📺 ' if icons else '• '}Access your anime lists (Watching, Completed, etc.)\n"
|
||||
f"{'✏️ ' if icons else '• '}Update watch progress and scores\n"
|
||||
f"{'➕ ' if icons else '• '}Add/remove anime from your lists\n"
|
||||
f"{'🔄 ' if icons else '• '}Sync progress with AniList\n"
|
||||
f"{'🔔 ' if icons else '• '}Access AniList notifications",
|
||||
title="Available with Authentication",
|
||||
border_style="green",
|
||||
)
|
||||
console.print(features_panel)
|
||||
|
||||
|
||||
def _display_token_help(console: Console, icons: bool):
|
||||
"""Display help information about getting an AniList token."""
|
||||
help_text = """
|
||||
[bold cyan]How to get your AniList Access Token:[/bold cyan]
|
||||
|
||||
[bold]Step 1:[/bold] Visit the AniList authorization page
|
||||
https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token
|
||||
|
||||
[bold]Step 2:[/bold] Log in to your AniList account if prompted
|
||||
|
||||
[bold]Step 3:[/bold] Click "Authorize" to grant FastAnime access
|
||||
|
||||
[bold]Step 4:[/bold] Copy the token from the browser URL
|
||||
Look for the part after "#access_token=" in the address bar
|
||||
|
||||
[bold]Step 5:[/bold] Paste the token when prompted in FastAnime
|
||||
|
||||
[yellow]Note:[/yellow] The token will be stored securely and used for all AniList features.
|
||||
You only need to do this once unless you revoke access or the token expires.
|
||||
|
||||
[yellow]Privacy:[/yellow] FastAnime only requests minimal permissions needed for
|
||||
list management and does not access sensitive account information.
|
||||
"""
|
||||
|
||||
panel = Panel(
|
||||
help_text,
|
||||
title=f"{'❓ ' if icons else ''}AniList Token Help",
|
||||
border_style="blue",
|
||||
)
|
||||
console.print()
|
||||
console.print(panel)
|
||||
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
Session management menu for the interactive CLI.
|
||||
Provides options to save, load, and manage session state.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ....core.constants import APP_DIR
|
||||
from ...utils.feedback import create_feedback_manager
|
||||
from ..session import Context, session
|
||||
from ..state import ControlFlow, State
|
||||
|
||||
MenuAction = Callable[[], str]
|
||||
|
||||
|
||||
@session.menu
|
||||
def session_management(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
Session management menu for saving, loading, and managing session state.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Show current session stats
|
||||
_display_session_info(console, icons)
|
||||
|
||||
options: Dict[str, MenuAction] = {
|
||||
f"{'💾 ' if icons else ''}Save Current Session": lambda: _save_session(
|
||||
ctx, feedback
|
||||
),
|
||||
f"{'📂 ' if icons else ''}Load Session": lambda: _load_session(ctx, feedback),
|
||||
f"{'📋 ' if icons else ''}List Saved Sessions": lambda: _list_sessions(
|
||||
ctx, feedback
|
||||
),
|
||||
f"{'🗑️ ' if icons else ''}Cleanup Old Sessions": lambda: _cleanup_sessions(
|
||||
ctx, feedback
|
||||
),
|
||||
f"{'💾 ' if icons else ''}Create Manual Backup": lambda: _create_backup(
|
||||
ctx, feedback
|
||||
),
|
||||
f"{'⚙️ ' if icons else ''}Session Settings": lambda: _session_settings(
|
||||
ctx, feedback
|
||||
),
|
||||
f"{'🔙 ' if icons else ''}Back to Main Menu": lambda: "BACK",
|
||||
}
|
||||
|
||||
choice_str = ctx.selector.choose(
|
||||
prompt="Select Session Action",
|
||||
choices=list(options.keys()),
|
||||
header="Session Management",
|
||||
)
|
||||
|
||||
if not choice_str:
|
||||
return ControlFlow.BACK
|
||||
|
||||
result = options[choice_str]()
|
||||
|
||||
if result == "BACK":
|
||||
return ControlFlow.BACK
|
||||
else:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _display_session_info(console: Console, icons: bool):
|
||||
"""Display current session information."""
|
||||
session_stats = session.get_session_stats()
|
||||
|
||||
table = Table(title=f"{'📊 ' if icons else ''}Current Session Info")
|
||||
table.add_column("Property", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
table.add_row("Current States", str(session_stats["current_states"]))
|
||||
table.add_row("Current Menu", session_stats["current_menu"] or "None")
|
||||
table.add_row(
|
||||
"Auto-Save", "Enabled" if session_stats["auto_save_enabled"] else "Disabled"
|
||||
)
|
||||
table.add_row("Has Auto-Save", "Yes" if session_stats["has_auto_save"] else "No")
|
||||
table.add_row(
|
||||
"Has Crash Backup", "Yes" if session_stats["has_crash_backup"] else "No"
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def _save_session(ctx: Context, feedback) -> str:
|
||||
"""Save the current session."""
|
||||
session_name = ctx.selector.ask("Enter session name (optional):")
|
||||
description = ctx.selector.ask("Enter session description (optional):")
|
||||
|
||||
if not session_name:
|
||||
session_name = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
sessions_dir = APP_DIR / "sessions"
|
||||
file_path = sessions_dir / f"{session_name}.json"
|
||||
|
||||
if file_path.exists():
|
||||
if not feedback.confirm(f"Session '{session_name}' already exists. Overwrite?"):
|
||||
feedback.info("Save cancelled")
|
||||
return "CONTINUE"
|
||||
|
||||
success = session.save(file_path, session_name, description or "")
|
||||
if success:
|
||||
feedback.success(f"Session saved as '{session_name}'")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _load_session(ctx: Context, feedback) -> str:
|
||||
"""Load a saved session."""
|
||||
sessions = session.list_saved_sessions()
|
||||
|
||||
if not sessions:
|
||||
feedback.warning("No saved sessions found")
|
||||
return "CONTINUE"
|
||||
|
||||
# Create choices with session info
|
||||
choices = []
|
||||
session_map = {}
|
||||
|
||||
for sess in sessions:
|
||||
choice_text = f"{sess['name']} - {sess['description'][:50]}{'...' if len(sess['description']) > 50 else ''}"
|
||||
choices.append(choice_text)
|
||||
session_map[choice_text] = sess
|
||||
|
||||
choices.append("Cancel")
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
"Select session to load:", choices=choices, header="Available Sessions"
|
||||
)
|
||||
|
||||
if not choice or choice == "Cancel":
|
||||
return "CONTINUE"
|
||||
|
||||
selected_session = session_map[choice]
|
||||
file_path = Path(selected_session["path"])
|
||||
|
||||
if feedback.confirm(
|
||||
f"Load session '{selected_session['name']}'? This will replace your current session."
|
||||
):
|
||||
success = session.resume(file_path, feedback)
|
||||
if success:
|
||||
feedback.info("Session loaded successfully. Returning to main menu.")
|
||||
# Return to main menu after loading
|
||||
return "MAIN"
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _list_sessions(ctx: Context, feedback) -> str:
|
||||
"""List all saved sessions."""
|
||||
sessions = session.list_saved_sessions()
|
||||
|
||||
if not sessions:
|
||||
feedback.info("No saved sessions found")
|
||||
return "CONTINUE"
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Saved Sessions")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Description", style="yellow")
|
||||
table.add_column("States", style="green")
|
||||
table.add_column("Created", style="blue")
|
||||
|
||||
for sess in sessions:
|
||||
# Format the created date
|
||||
created = sess["created"]
|
||||
if "T" in created:
|
||||
created = created.split("T")[0] # Just show the date part
|
||||
|
||||
table.add_row(
|
||||
sess["name"],
|
||||
sess["description"][:40] + "..."
|
||||
if len(sess["description"]) > 40
|
||||
else sess["description"],
|
||||
str(sess["state_count"]),
|
||||
created,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
feedback.pause_for_user()
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _cleanup_sessions(ctx: Context, feedback) -> str:
|
||||
"""Clean up old sessions."""
|
||||
sessions = session.list_saved_sessions()
|
||||
|
||||
if len(sessions) <= 5:
|
||||
feedback.info("No cleanup needed. You have 5 or fewer sessions.")
|
||||
return "CONTINUE"
|
||||
|
||||
max_sessions_str = ctx.selector.ask("How many sessions to keep? (default: 10)")
|
||||
try:
|
||||
max_sessions = int(max_sessions_str) if max_sessions_str else 10
|
||||
except ValueError:
|
||||
feedback.error("Invalid number entered")
|
||||
return "CONTINUE"
|
||||
|
||||
if feedback.confirm(f"Delete sessions older than the {max_sessions} most recent?"):
|
||||
deleted_count = session.cleanup_old_sessions(max_sessions)
|
||||
feedback.success(f"Deleted {deleted_count} old sessions")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _create_backup(ctx: Context, feedback) -> str:
|
||||
"""Create a manual backup."""
|
||||
backup_name = ctx.selector.ask("Enter backup name (optional):")
|
||||
|
||||
success = session.create_manual_backup(backup_name or "")
|
||||
if success:
|
||||
feedback.success("Manual backup created successfully")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _session_settings(ctx: Context, feedback) -> str:
|
||||
"""Configure session settings."""
|
||||
current_auto_save = session._auto_save_enabled
|
||||
|
||||
choices = [
|
||||
f"Auto-Save: {'Enabled' if current_auto_save else 'Disabled'}",
|
||||
"Clear Auto-Save File",
|
||||
"Clear Crash Backup",
|
||||
"Back",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose("Session Settings:", choices=choices)
|
||||
|
||||
if choice and choice.startswith("Auto-Save"):
|
||||
new_setting = not current_auto_save
|
||||
session.enable_auto_save(new_setting)
|
||||
feedback.success(f"Auto-save {'enabled' if new_setting else 'disabled'}")
|
||||
|
||||
elif choice == "Clear Auto-Save File":
|
||||
if feedback.confirm("Clear the auto-save file?"):
|
||||
session._session_manager.clear_auto_save()
|
||||
feedback.success("Auto-save file cleared")
|
||||
|
||||
elif choice == "Clear Crash Backup":
|
||||
if feedback.confirm("Clear the crash backup file?"):
|
||||
session._session_manager.clear_crash_backup()
|
||||
feedback.success("Crash backup cleared")
|
||||
|
||||
return "CONTINUE"
|
||||
@@ -1,827 +0,0 @@
|
||||
"""
|
||||
AniList Watch List Operations Menu
|
||||
Implements Step 8: Remote Watch List Operations
|
||||
|
||||
Provides comprehensive AniList list management including:
|
||||
- Viewing user lists (Watching, Completed, Planning, etc.)
|
||||
- Interactive list selection and navigation
|
||||
- Adding/removing anime from lists
|
||||
- List statistics and overview
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ....libs.media_api.params import UpdateUserMediaListEntryParams, UserListParams
|
||||
from ....libs.media_api.types import MediaItem, MediaSearchResult, UserListItem
|
||||
from ...utils.feedback import create_feedback_manager, execute_with_feedback
|
||||
from ..session import Context, session
|
||||
from ..state import ControlFlow, MediaApiState, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@session.menu
|
||||
def anilist_lists(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
Main AniList lists management menu.
|
||||
Shows all user lists with statistics and navigation options.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Check authentication
|
||||
if not ctx.media_api.user_profile:
|
||||
feedback.error(
|
||||
"Authentication Required",
|
||||
"You must be logged in to access your AniList lists. Please authenticate first.",
|
||||
)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return State(menu_name="AUTH")
|
||||
|
||||
# Display user profile and lists overview
|
||||
_display_lists_overview(console, ctx, icons)
|
||||
|
||||
# Menu options
|
||||
options = [
|
||||
f"{'📺 ' if icons else ''}Currently Watching",
|
||||
f"{'📋 ' if icons else ''}Planning to Watch",
|
||||
f"{'✅ ' if icons else ''}Completed",
|
||||
f"{'⏸️ ' if icons else ''}Paused",
|
||||
f"{'🚮 ' if icons else ''}Dropped",
|
||||
f"{'🔁 ' if icons else ''}Rewatching",
|
||||
f"{'📊 ' if icons else ''}View All Lists Statistics",
|
||||
f"{'🔍 ' if icons else ''}Search Across All Lists",
|
||||
f"{'➕ ' if icons else ''}Add Anime to List",
|
||||
f"{'↩️ ' if icons else ''}Back to Main Menu",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select List Action",
|
||||
choices=options,
|
||||
header=f"AniList Lists - {ctx.media_api.user_profile.name}",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Handle menu choices
|
||||
if "Currently Watching" in choice:
|
||||
return _navigate_to_list(ctx, "CURRENT")
|
||||
elif "Planning to Watch" in choice:
|
||||
return _navigate_to_list(ctx, "PLANNING")
|
||||
elif "Completed" in choice:
|
||||
return _navigate_to_list(ctx, "COMPLETED")
|
||||
elif "Paused" in choice:
|
||||
return _navigate_to_list(ctx, "PAUSED")
|
||||
elif "Dropped" in choice:
|
||||
return _navigate_to_list(ctx, "DROPPED")
|
||||
elif "Rewatching" in choice:
|
||||
return _navigate_to_list(ctx, "REPEATING")
|
||||
elif "View All Lists Statistics" in choice:
|
||||
return _show_all_lists_stats(ctx, feedback, icons)
|
||||
elif "Search Across All Lists" in choice:
|
||||
return _search_all_lists(ctx, feedback, icons)
|
||||
elif "Add Anime to List" in choice:
|
||||
return _add_anime_to_list(ctx, feedback, icons)
|
||||
else: # Back to Main Menu
|
||||
return ControlFlow.BACK
|
||||
|
||||
|
||||
@session.menu
|
||||
def anilist_list_view(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
View and manage a specific AniList list (e.g., Watching, Completed).
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Get list status from state data
|
||||
list_status = state.data.get("list_status") if state.data else "CURRENT"
|
||||
page = state.data.get("page", 1) if state.data else 1
|
||||
|
||||
# Fetch list data
|
||||
def fetch_list():
|
||||
return ctx.media_api.search_media_list(
|
||||
UserListParams(status=list_status, page=page, per_page=20)
|
||||
)
|
||||
|
||||
success, result = execute_with_feedback(
|
||||
fetch_list,
|
||||
feedback,
|
||||
f"fetch {_status_to_display_name(list_status)} list",
|
||||
loading_msg=f"Loading {_status_to_display_name(list_status)} list...",
|
||||
success_msg=f"Loaded {_status_to_display_name(list_status)} list",
|
||||
error_msg=f"Failed to load {_status_to_display_name(list_status)} list",
|
||||
)
|
||||
|
||||
if not success or not result:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Display list contents
|
||||
_display_list_contents(console, result, list_status, page, icons)
|
||||
|
||||
# Menu options
|
||||
options = [
|
||||
f"{'👁️ ' if icons else ''}View/Edit Anime Details",
|
||||
f"{'🔄 ' if icons else ''}Refresh List",
|
||||
f"{'➕ ' if icons else ''}Add New Anime",
|
||||
f"{'🗑️ ' if icons else ''}Remove from List",
|
||||
]
|
||||
|
||||
# Add pagination options
|
||||
if result.page_info.has_next_page:
|
||||
options.append(f"{'➡️ ' if icons else ''}Next Page")
|
||||
if page > 1:
|
||||
options.append(f"{'⬅️ ' if icons else ''}Previous Page")
|
||||
|
||||
options.extend(
|
||||
[
|
||||
f"{'📊 ' if icons else ''}List Statistics",
|
||||
f"{'↩️ ' if icons else ''}Back to Lists Menu",
|
||||
]
|
||||
)
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Action",
|
||||
choices=options,
|
||||
header=f"{_status_to_display_name(list_status)} - Page {page}",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Handle menu choices
|
||||
if "View/Edit Anime Details" in choice:
|
||||
return _select_anime_for_details(ctx, result, list_status, page)
|
||||
elif "Refresh List" in choice:
|
||||
return ControlFlow.CONTINUE
|
||||
elif "Add New Anime" in choice:
|
||||
return _add_anime_to_specific_list(ctx, list_status, feedback, icons)
|
||||
elif "Remove from List" in choice:
|
||||
return _remove_anime_from_list(ctx, result, list_status, page, feedback, icons)
|
||||
elif "Next Page" in choice:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": page + 1},
|
||||
)
|
||||
elif "Previous Page" in choice:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": page - 1},
|
||||
)
|
||||
elif "List Statistics" in choice:
|
||||
return _show_list_statistics(ctx, list_status, feedback, icons)
|
||||
else: # Back to Lists Menu
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
@session.menu
|
||||
def anilist_anime_details(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
View and edit details for a specific anime in a user's list.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Get anime and list info from state
|
||||
if not state.data:
|
||||
return ControlFlow.BACK
|
||||
|
||||
anime = state.data.get("anime")
|
||||
list_status = state.data.get("list_status")
|
||||
return_page = state.data.get("return_page", 1)
|
||||
from_media_actions = state.data.get("from_media_actions", False)
|
||||
|
||||
if not anime:
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Display anime details
|
||||
_display_anime_list_details(console, anime, icons)
|
||||
|
||||
# Menu options
|
||||
options = [
|
||||
f"{'✏️ ' if icons else ''}Edit Progress",
|
||||
f"{'⭐ ' if icons else ''}Edit Rating",
|
||||
f"{'📝 ' if icons else ''}Edit Status",
|
||||
f"{'🎬 ' if icons else ''}Watch/Stream",
|
||||
f"{'🗑️ ' if icons else ''}Remove from List",
|
||||
f"{'↩️ ' if icons else ''}Back to List",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Action",
|
||||
choices=options,
|
||||
header=f"{anime.title.english or anime.title.romaji}",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page},
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
# Handle menu choices
|
||||
if "Edit Progress" in choice:
|
||||
return _edit_anime_progress(
|
||||
ctx, anime, list_status, return_page, feedback, from_media_actions
|
||||
)
|
||||
elif "Edit Rating" in choice:
|
||||
return _edit_anime_rating(
|
||||
ctx, anime, list_status, return_page, feedback, from_media_actions
|
||||
)
|
||||
elif "Edit Status" in choice:
|
||||
return _edit_anime_status(
|
||||
ctx, anime, list_status, return_page, feedback, from_media_actions
|
||||
)
|
||||
elif "Watch/Stream" in choice:
|
||||
return _stream_anime(ctx, anime)
|
||||
elif "Remove from List" in choice:
|
||||
return _confirm_remove_anime(
|
||||
ctx, anime, list_status, return_page, feedback, icons, from_media_actions
|
||||
)
|
||||
else: # Back to List/Media Actions
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page},
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _display_lists_overview(console: Console, ctx: Context, icons: bool):
|
||||
"""Display overview of all user lists with counts."""
|
||||
user = ctx.media_api.user_profile
|
||||
|
||||
# Create overview panel
|
||||
overview_text = f"[bold cyan]{user.name}[/bold cyan]'s AniList Management\n"
|
||||
overview_text += f"User ID: {user.id}\n\n"
|
||||
overview_text += "Manage your anime lists, track progress, and sync with AniList"
|
||||
|
||||
panel = Panel(
|
||||
overview_text,
|
||||
title=f"{'📚 ' if icons else ''}AniList Lists Overview",
|
||||
border_style="cyan",
|
||||
)
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def _display_list_contents(
|
||||
console: Console,
|
||||
result: MediaSearchResult,
|
||||
list_status: str,
|
||||
page: int,
|
||||
icons: bool,
|
||||
):
|
||||
"""Display the contents of a specific list in a table."""
|
||||
if not result.media:
|
||||
console.print(
|
||||
f"[yellow]No anime found in {_status_to_display_name(list_status)} list[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
table = Table(title=f"{_status_to_display_name(list_status)} - Page {page}")
|
||||
table.add_column("Title", style="cyan", no_wrap=False, width=40)
|
||||
table.add_column("Episodes", justify="center", width=10)
|
||||
table.add_column("Progress", justify="center", width=10)
|
||||
table.add_column("Score", justify="center", width=8)
|
||||
table.add_column("Status", justify="center", width=12)
|
||||
|
||||
for i, anime in enumerate(result.media, 1):
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
episodes = str(anime.episodes or "?")
|
||||
|
||||
# Get list entry details if available
|
||||
progress = "?"
|
||||
score = "?"
|
||||
status = _status_to_display_name(list_status)
|
||||
|
||||
# Note: In a real implementation, you'd get these from the MediaList entry
|
||||
# For now, we'll show placeholders
|
||||
if hasattr(anime, "media_list_entry") and anime.media_list_entry:
|
||||
progress = str(anime.media_list_entry.progress or 0)
|
||||
score = str(anime.media_list_entry.score or "-")
|
||||
|
||||
table.add_row(f"{i}. {title}", episodes, progress, score, status)
|
||||
|
||||
console.print(table)
|
||||
console.print(
|
||||
f"\nShowing {len(result.media)} anime from {_status_to_display_name(list_status)} list"
|
||||
)
|
||||
|
||||
# Show pagination info
|
||||
if result.page_info.has_next_page:
|
||||
console.print("[dim]More results available on next page[/dim]")
|
||||
|
||||
|
||||
def _display_anime_list_details(console: Console, anime: MediaItem, icons: bool):
|
||||
"""Display detailed information about an anime in the user's list."""
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
|
||||
details_text = f"[bold]{title}[/bold]\n\n"
|
||||
details_text += f"Episodes: {anime.episodes or 'Unknown'}\n"
|
||||
details_text += f"Status: {anime.status or 'Unknown'}\n"
|
||||
details_text += (
|
||||
f"Genres: {', '.join(anime.genres) if anime.genres else 'Unknown'}\n"
|
||||
)
|
||||
|
||||
if anime.description:
|
||||
# Truncate description for display
|
||||
desc = (
|
||||
anime.description[:300] + "..."
|
||||
if len(anime.description) > 300
|
||||
else anime.description
|
||||
)
|
||||
details_text += f"\nDescription:\n{desc}"
|
||||
|
||||
# Add list-specific information if available
|
||||
if hasattr(anime, "media_list_entry") and anime.media_list_entry:
|
||||
entry = anime.media_list_entry
|
||||
details_text += "\n\n[bold cyan]Your List Info:[/bold cyan]\n"
|
||||
details_text += f"Progress: {entry.progress or 0} episodes\n"
|
||||
details_text += f"Score: {entry.score or 'Not rated'}\n"
|
||||
details_text += f"Status: {_status_to_display_name(entry.status) if hasattr(entry, 'status') else 'Unknown'}\n"
|
||||
|
||||
panel = Panel(
|
||||
details_text,
|
||||
title=f"{'📺 ' if icons else ''}Anime Details",
|
||||
border_style="blue",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
def _navigate_to_list(ctx: Context, list_status: UserListItem) -> State:
|
||||
"""Navigate to a specific list view."""
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW", data={"list_status": list_status, "page": 1}
|
||||
)
|
||||
|
||||
|
||||
def _select_anime_for_details(
|
||||
ctx: Context, result: MediaSearchResult, list_status: str, page: int
|
||||
) -> State | ControlFlow:
|
||||
"""Let user select an anime from the list to view/edit details."""
|
||||
if not result.media:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Create choices from anime list
|
||||
choices = []
|
||||
for i, anime in enumerate(result.media, 1):
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
choices.append(f"{i}. {title}")
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select anime to view/edit",
|
||||
choices=choices,
|
||||
header="Select Anime",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Extract index and get selected anime
|
||||
try:
|
||||
index = int(choice.split(".")[0]) - 1
|
||||
selected_anime = result.media[index]
|
||||
|
||||
return State(
|
||||
menu_name="ANILIST_ANIME_DETAILS",
|
||||
data={
|
||||
"anime": selected_anime,
|
||||
"list_status": list_status,
|
||||
"return_page": page,
|
||||
},
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _edit_anime_progress(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
from_media_actions: bool = False,
|
||||
) -> State | ControlFlow:
|
||||
"""Edit the progress (episodes watched) for an anime."""
|
||||
current_progress = 0
|
||||
if hasattr(anime, "media_list_entry") and anime.media_list_entry:
|
||||
current_progress = anime.media_list_entry.progress or 0
|
||||
|
||||
max_episodes = anime.episodes or 999
|
||||
|
||||
try:
|
||||
new_progress = click.prompt(
|
||||
f"Enter new progress (0-{max_episodes}, current: {current_progress})",
|
||||
type=int,
|
||||
default=current_progress,
|
||||
)
|
||||
|
||||
if new_progress < 0 or new_progress > max_episodes:
|
||||
feedback.error(
|
||||
"Invalid progress", f"Progress must be between 0 and {max_episodes}"
|
||||
)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Update via API
|
||||
def update_progress():
|
||||
return ctx.media_api.update_list_entry(
|
||||
UpdateUserMediaListEntryParams(media_id=anime.id, progress=new_progress)
|
||||
)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
update_progress,
|
||||
feedback,
|
||||
"update progress",
|
||||
loading_msg="Updating progress...",
|
||||
success_msg=f"Progress updated to {new_progress} episodes",
|
||||
error_msg="Failed to update progress",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page},
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _edit_anime_rating(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
from_media_actions: bool = False,
|
||||
) -> State | ControlFlow:
|
||||
"""Edit the rating/score for an anime."""
|
||||
current_score = 0.0
|
||||
if hasattr(anime, "media_list_entry") and anime.media_list_entry:
|
||||
current_score = anime.media_list_entry.score or 0.0
|
||||
|
||||
try:
|
||||
new_score = click.prompt(
|
||||
f"Enter new rating (0.0-10.0, current: {current_score})",
|
||||
type=float,
|
||||
default=current_score,
|
||||
)
|
||||
|
||||
if new_score < 0.0 or new_score > 10.0:
|
||||
feedback.error("Invalid rating", "Rating must be between 0.0 and 10.0")
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Update via API
|
||||
def update_score():
|
||||
return ctx.media_api.update_list_entry(
|
||||
UpdateUserMediaListEntryParams(media_id=anime.id, score=new_score)
|
||||
)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
update_score,
|
||||
feedback,
|
||||
"update rating",
|
||||
loading_msg="Updating rating...",
|
||||
success_msg=f"Rating updated to {new_score}/10",
|
||||
error_msg="Failed to update rating",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page},
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _edit_anime_status(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
from_media_actions: bool = False,
|
||||
) -> State | ControlFlow:
|
||||
"""Edit the list status for an anime."""
|
||||
status_options = [
|
||||
"CURRENT (Currently Watching)",
|
||||
"PLANNING (Plan to Watch)",
|
||||
"COMPLETED (Completed)",
|
||||
"PAUSED (Paused)",
|
||||
"DROPPED (Dropped)",
|
||||
"REPEATING (Rewatching)",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select new status",
|
||||
choices=status_options,
|
||||
header="Change List Status",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
new_status = choice.split(" ")[0]
|
||||
|
||||
# Update via API
|
||||
def update_status():
|
||||
return ctx.media_api.update_list_entry(
|
||||
UpdateUserMediaListEntryParams(media_id=anime.id, status=new_status)
|
||||
)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
update_status,
|
||||
feedback,
|
||||
"update status",
|
||||
loading_msg="Updating status...",
|
||||
success_msg=f"Status updated to {_status_to_display_name(new_status)}",
|
||||
error_msg="Failed to update status",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
# If status changed, return to main lists menu since the anime
|
||||
# is no longer in the current list
|
||||
if new_status != list_status:
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page},
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _confirm_remove_anime(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
icons: bool,
|
||||
from_media_actions: bool = False,
|
||||
) -> State | ControlFlow:
|
||||
"""Confirm and remove an anime from the user's list."""
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
|
||||
if not feedback.confirm(
|
||||
f"Remove '{title}' from your {_status_to_display_name(list_status)} list?",
|
||||
default=False,
|
||||
):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Remove via API
|
||||
def remove_anime():
|
||||
return ctx.media_api.delete_list_entry(anime.id)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
remove_anime,
|
||||
feedback,
|
||||
"remove anime",
|
||||
loading_msg="Removing anime from list...",
|
||||
success_msg=f"'{title}' removed from list",
|
||||
error_msg="Failed to remove anime from list",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page},
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _stream_anime(ctx: Context, anime: MediaItem) -> State:
|
||||
"""Navigate to streaming interface for the selected anime."""
|
||||
return State(
|
||||
menu_name="RESULTS",
|
||||
data=MediaApiState(
|
||||
results=[anime], # Pass as single-item list
|
||||
query=anime.title.english or anime.title.romaji or "Unknown",
|
||||
page=1,
|
||||
api_params=None,
|
||||
user_list_params=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _show_all_lists_stats(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
|
||||
"""Show comprehensive statistics across all user lists."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# This would require fetching data from all lists
|
||||
# For now, show a placeholder implementation
|
||||
stats_text = "[bold cyan]📊 Your AniList Statistics[/bold cyan]\n\n"
|
||||
stats_text += "[dim]Loading comprehensive list statistics...[/dim]\n"
|
||||
stats_text += "[dim]This feature requires fetching data from all lists.[/dim]"
|
||||
|
||||
panel = Panel(
|
||||
stats_text,
|
||||
title=f"{'📊 ' if icons else ''}AniList Statistics",
|
||||
border_style="green",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _search_all_lists(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
|
||||
"""Search across all user lists."""
|
||||
try:
|
||||
query = click.prompt("Enter search query", type=str)
|
||||
if not query.strip():
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# This would require implementing search across all lists
|
||||
feedback.info(
|
||||
"Search functionality",
|
||||
"Cross-list search will be implemented in a future update",
|
||||
)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _add_anime_to_list(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
|
||||
"""Add a new anime to one of the user's lists."""
|
||||
try:
|
||||
query = click.prompt("Enter anime name to search", type=str)
|
||||
if not query.strip():
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Navigate to search with intent to add to list
|
||||
return State(
|
||||
menu_name="PROVIDER_SEARCH", data={"query": query, "add_to_list_mode": True}
|
||||
)
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _add_anime_to_specific_list(
|
||||
ctx: Context, list_status: str, feedback, icons: bool
|
||||
) -> State | ControlFlow:
|
||||
"""Add a new anime to a specific list."""
|
||||
try:
|
||||
query = click.prompt("Enter anime name to search", type=str)
|
||||
if not query.strip():
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Navigate to search with specific list target
|
||||
return State(
|
||||
menu_name="PROVIDER_SEARCH",
|
||||
data={"query": query, "target_list": list_status},
|
||||
)
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _remove_anime_from_list(
|
||||
ctx: Context,
|
||||
result: MediaSearchResult,
|
||||
list_status: str,
|
||||
page: int,
|
||||
feedback,
|
||||
icons: bool,
|
||||
) -> State | ControlFlow:
|
||||
"""Select and remove an anime from the current list."""
|
||||
if not result.media:
|
||||
feedback.info("Empty list", "No anime to remove from this list")
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Create choices from anime list
|
||||
choices = []
|
||||
for i, anime in enumerate(result.media, 1):
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
choices.append(f"{i}. {title}")
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select anime to remove",
|
||||
choices=choices,
|
||||
header="Remove Anime from List",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Extract index and get selected anime
|
||||
try:
|
||||
index = int(choice.split(".")[0]) - 1
|
||||
selected_anime = result.media[index]
|
||||
|
||||
return _confirm_remove_anime(
|
||||
ctx, selected_anime, list_status, page, feedback, icons
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _show_list_statistics(
|
||||
ctx: Context, list_status: str, feedback, icons: bool
|
||||
) -> State | ControlFlow:
|
||||
"""Show statistics for a specific list."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
list_name = _status_to_display_name(list_status)
|
||||
|
||||
stats_text = f"[bold cyan]📊 {list_name} Statistics[/bold cyan]\n\n"
|
||||
stats_text += "[dim]Loading list statistics...[/dim]\n"
|
||||
stats_text += "[dim]This feature requires comprehensive list analysis.[/dim]"
|
||||
|
||||
panel = Panel(
|
||||
stats_text,
|
||||
title=f"{'📊 ' if icons else ''}{list_name} Stats",
|
||||
border_style="blue",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _status_to_display_name(status: str) -> str:
|
||||
"""Convert API status to human-readable display name."""
|
||||
status_map = {
|
||||
"CURRENT": "Currently Watching",
|
||||
"PLANNING": "Planning to Watch",
|
||||
"COMPLETED": "Completed",
|
||||
"PAUSED": "Paused",
|
||||
"DROPPED": "Dropped",
|
||||
"REPEATING": "Rewatching",
|
||||
}
|
||||
return status_map.get(status, status)
|
||||
|
||||
|
||||
# Import click for user input
|
||||
import click
|
||||
@@ -1,572 +0,0 @@
|
||||
"""
|
||||
Watch History Management Menu for the interactive CLI.
|
||||
Provides comprehensive watch history viewing, editing, and management capabilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Dict, List
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ....core.constants import APP_DATA_DIR
|
||||
from ...utils.feedback import create_feedback_manager
|
||||
from ...utils.watch_history_manager import WatchHistoryManager
|
||||
from ...utils.watch_history_types import WatchHistoryEntry
|
||||
from ..session import Context, session
|
||||
from ..state import InternalDirective, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MenuAction = Callable[[], str]
|
||||
|
||||
|
||||
@session.menu
|
||||
def watch_history(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""
|
||||
Watch history management menu for viewing and managing local watch history.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Initialize watch history manager
|
||||
history_manager = WatchHistoryManager()
|
||||
|
||||
# Show watch history stats
|
||||
_display_history_stats(console, history_manager, icons)
|
||||
|
||||
options: Dict[str, MenuAction] = {
|
||||
f"{'📺 ' if icons else ''}Currently Watching": lambda: _view_watching(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'✅ ' if icons else ''}Completed Anime": lambda: _view_completed(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'🕒 ' if icons else ''}Recently Watched": lambda: _view_recent(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'📋 ' if icons else ''}View All History": lambda: _view_all_history(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'🔍 ' if icons else ''}Search History": lambda: _search_history(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'✏️ ' if icons else ''}Edit Entry": lambda: _edit_entry(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'🗑️ ' if icons else ''}Remove Entry": lambda: _remove_entry(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'📊 ' if icons else ''}View Statistics": lambda: _view_stats(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'💾 ' if icons else ''}Export History": lambda: _export_history(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'📥 ' if icons else ''}Import History": lambda: _import_history(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'🧹 ' if icons else ''}Clear All History": lambda: _clear_history(
|
||||
ctx, history_manager, feedback
|
||||
),
|
||||
f"{'🔙 ' if icons else ''}Back to Main Menu": lambda: "BACK",
|
||||
}
|
||||
|
||||
choice_str = ctx.selector.choose(
|
||||
prompt="Select Watch History Action",
|
||||
choices=list(options.keys()),
|
||||
header="Watch History Management",
|
||||
)
|
||||
|
||||
if not choice_str:
|
||||
return InternalDirective.BACK
|
||||
|
||||
result = options[choice_str]()
|
||||
|
||||
if result == "BACK":
|
||||
return InternalDirective.BACK
|
||||
else:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
|
||||
def _display_history_stats(
|
||||
console: Console, history_manager: WatchHistoryManager, icons: bool
|
||||
):
|
||||
"""Display current watch history statistics."""
|
||||
stats = history_manager.get_stats()
|
||||
|
||||
# Create a stats table
|
||||
table = Table(title=f"{'📊 ' if icons else ''}Watch History Overview")
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Count", style="green")
|
||||
|
||||
table.add_row("Total Anime", str(stats["total_entries"]))
|
||||
table.add_row("Currently Watching", str(stats["watching"]))
|
||||
table.add_row("Completed", str(stats["completed"]))
|
||||
table.add_row("Dropped", str(stats["dropped"]))
|
||||
table.add_row("Paused", str(stats["paused"]))
|
||||
table.add_row("Total Episodes", str(stats["total_episodes_watched"]))
|
||||
table.add_row("Last Updated", stats["last_updated"])
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def _view_watching(ctx: Context, history_manager: WatchHistoryManager, feedback) -> str:
|
||||
"""View currently watching anime."""
|
||||
entries = history_manager.get_watching_entries()
|
||||
|
||||
if not entries:
|
||||
feedback.info("No anime currently being watched")
|
||||
return "CONTINUE"
|
||||
|
||||
return _display_entries_list(ctx, entries, "Currently Watching", feedback)
|
||||
|
||||
|
||||
def _view_completed(
|
||||
ctx: Context, history_manager: WatchHistoryManager, feedback
|
||||
) -> str:
|
||||
"""View completed anime."""
|
||||
entries = history_manager.get_completed_entries()
|
||||
|
||||
if not entries:
|
||||
feedback.info("No completed anime found")
|
||||
return "CONTINUE"
|
||||
|
||||
return _display_entries_list(ctx, entries, "Completed Anime", feedback)
|
||||
|
||||
|
||||
def _view_recent(ctx: Context, history_manager: WatchHistoryManager, feedback) -> str:
|
||||
"""View recently watched anime."""
|
||||
entries = history_manager.get_recently_watched(20)
|
||||
|
||||
if not entries:
|
||||
feedback.info("No recent watch history found")
|
||||
return "CONTINUE"
|
||||
|
||||
return _display_entries_list(ctx, entries, "Recently Watched", feedback)
|
||||
|
||||
|
||||
def _view_all_history(
|
||||
ctx: Context, history_manager: WatchHistoryManager, feedback
|
||||
) -> str:
|
||||
"""View all watch history entries."""
|
||||
entries = history_manager.get_all_entries()
|
||||
|
||||
if not entries:
|
||||
feedback.info("No watch history found")
|
||||
return "CONTINUE"
|
||||
|
||||
# Sort by last watched date
|
||||
entries.sort(key=lambda x: x.last_watched, reverse=True)
|
||||
|
||||
return _display_entries_list(ctx, entries, "All Watch History", feedback)
|
||||
|
||||
|
||||
def _search_history(
|
||||
ctx: Context, history_manager: WatchHistoryManager, feedback
|
||||
) -> str:
|
||||
"""Search watch history by title."""
|
||||
query = ctx.selector.ask("Enter search query:")
|
||||
|
||||
if not query:
|
||||
return "CONTINUE"
|
||||
|
||||
entries = history_manager.search_entries(query)
|
||||
|
||||
if not entries:
|
||||
feedback.info(f"No anime found matching '{query}'")
|
||||
return "CONTINUE"
|
||||
|
||||
return _display_entries_list(
|
||||
ctx, entries, f"Search Results for '{query}'", feedback
|
||||
)
|
||||
|
||||
|
||||
def _display_entries_list(
|
||||
ctx: Context, entries: List[WatchHistoryEntry], title: str, feedback
|
||||
) -> str:
|
||||
"""Display a list of watch history entries and allow selection."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Create table for entries
|
||||
table = Table(title=title)
|
||||
table.add_column("Status", style="yellow", width=6)
|
||||
table.add_column("Title", style="cyan")
|
||||
table.add_column("Progress", style="green", width=12)
|
||||
table.add_column("Last Watched", style="blue", width=12)
|
||||
|
||||
choices = []
|
||||
entry_map = {}
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
# Format last watched date
|
||||
last_watched = entry.last_watched.strftime("%Y-%m-%d")
|
||||
|
||||
# Add to table
|
||||
table.add_row(
|
||||
entry.get_status_emoji(),
|
||||
entry.get_display_title(),
|
||||
entry.get_progress_display(),
|
||||
last_watched,
|
||||
)
|
||||
|
||||
# Create choice for selector
|
||||
choice_text = f"{entry.get_status_emoji()} {entry.get_display_title()} - {entry.get_progress_display()}"
|
||||
choices.append(choice_text)
|
||||
entry_map[choice_text] = entry
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if not choices:
|
||||
feedback.info("No entries to display")
|
||||
feedback.pause_for_user()
|
||||
return "CONTINUE"
|
||||
|
||||
choices.append("Back")
|
||||
|
||||
choice = ctx.selector.choose("Select an anime for details:", choices=choices)
|
||||
|
||||
if not choice or choice == "Back":
|
||||
return "CONTINUE"
|
||||
|
||||
selected_entry = entry_map[choice]
|
||||
return _show_entry_details(ctx, selected_entry, feedback)
|
||||
|
||||
|
||||
def _show_entry_details(ctx: Context, entry: WatchHistoryEntry, feedback) -> str:
|
||||
"""Show detailed information about a watch history entry."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Display detailed entry information
|
||||
console.print(f"[bold cyan]{entry.get_display_title()}[/bold cyan]")
|
||||
console.print(f"Status: {entry.get_status_emoji()} {entry.status.title()}")
|
||||
console.print(f"Progress: {entry.get_progress_display()}")
|
||||
console.print(f"Times Watched: {entry.times_watched}")
|
||||
console.print(f"First Watched: {entry.first_watched.strftime('%Y-%m-%d %H:%M')}")
|
||||
console.print(f"Last Watched: {entry.last_watched.strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
if entry.notes:
|
||||
console.print(f"Notes: {entry.notes}")
|
||||
|
||||
# Show media details if available
|
||||
media = entry.media_item
|
||||
if media.description:
|
||||
console.print(
|
||||
f"\nDescription: {media.description[:200]}{'...' if len(media.description) > 200 else ''}"
|
||||
)
|
||||
|
||||
if media.genres:
|
||||
console.print(f"Genres: {', '.join(media.genres)}")
|
||||
|
||||
if media.average_score:
|
||||
console.print(f"Score: {media.average_score}/100")
|
||||
|
||||
console.print()
|
||||
|
||||
# Action options
|
||||
actions = [
|
||||
"Mark Episode as Watched",
|
||||
"Change Status",
|
||||
"Edit Notes",
|
||||
"Remove from History",
|
||||
"Back to List",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose("Select action:", choices=actions)
|
||||
|
||||
if choice == "Mark Episode as Watched":
|
||||
return _mark_episode_watched(ctx, entry, feedback)
|
||||
elif choice == "Change Status":
|
||||
return _change_entry_status(ctx, entry, feedback)
|
||||
elif choice == "Edit Notes":
|
||||
return _edit_entry_notes(ctx, entry, feedback)
|
||||
elif choice == "Remove from History":
|
||||
return _confirm_remove_entry(ctx, entry, feedback)
|
||||
else:
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _mark_episode_watched(ctx: Context, entry: WatchHistoryEntry, feedback) -> str:
|
||||
"""Mark a specific episode as watched."""
|
||||
current_episode = entry.last_watched_episode
|
||||
max_episodes = entry.media_item.episodes or 999
|
||||
|
||||
episode_str = ctx.selector.ask(
|
||||
f"Enter episode number (current: {current_episode}, max: {max_episodes}):"
|
||||
)
|
||||
|
||||
try:
|
||||
episode = int(episode_str)
|
||||
if episode < 1 or (max_episodes and episode > max_episodes):
|
||||
feedback.error(
|
||||
f"Invalid episode number. Must be between 1 and {max_episodes}"
|
||||
)
|
||||
return "CONTINUE"
|
||||
|
||||
history_manager = WatchHistoryManager()
|
||||
success = history_manager.mark_episode_watched(entry.media_item.id, episode)
|
||||
|
||||
if success:
|
||||
feedback.success(f"Marked episode {episode} as watched")
|
||||
else:
|
||||
feedback.error("Failed to update watch progress")
|
||||
|
||||
except ValueError:
|
||||
feedback.error("Invalid episode number entered")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _change_entry_status(ctx: Context, entry: WatchHistoryEntry, feedback) -> str:
|
||||
"""Change the status of a watch history entry."""
|
||||
statuses = ["watching", "completed", "paused", "dropped", "planning"]
|
||||
current_status = entry.status
|
||||
|
||||
choices = [
|
||||
f"{status.title()} {'(current)' if status == current_status else ''}"
|
||||
for status in statuses
|
||||
]
|
||||
choices.append("Cancel")
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
f"Select new status (current: {current_status}):", choices=choices
|
||||
)
|
||||
|
||||
if not choice or choice == "Cancel":
|
||||
return "CONTINUE"
|
||||
|
||||
new_status = choice.split()[0].lower()
|
||||
|
||||
history_manager = WatchHistoryManager()
|
||||
success = history_manager.change_status(entry.media_item.id, new_status)
|
||||
|
||||
if success:
|
||||
feedback.success(f"Changed status to {new_status}")
|
||||
else:
|
||||
feedback.error("Failed to update status")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _edit_entry_notes(ctx: Context, entry: WatchHistoryEntry, feedback) -> str:
|
||||
"""Edit notes for a watch history entry."""
|
||||
current_notes = entry.notes or ""
|
||||
|
||||
new_notes = ctx.selector.ask(f"Enter notes (current: '{current_notes}'):")
|
||||
|
||||
if new_notes is None: # User cancelled
|
||||
return "CONTINUE"
|
||||
|
||||
history_manager = WatchHistoryManager()
|
||||
success = history_manager.update_notes(entry.media_item.id, new_notes)
|
||||
|
||||
if success:
|
||||
feedback.success("Notes updated successfully")
|
||||
else:
|
||||
feedback.error("Failed to update notes")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _confirm_remove_entry(ctx: Context, entry: WatchHistoryEntry, feedback) -> str:
|
||||
"""Confirm and remove a watch history entry."""
|
||||
if feedback.confirm(f"Remove '{entry.get_display_title()}' from watch history?"):
|
||||
history_manager = WatchHistoryManager()
|
||||
success = history_manager.remove_entry(entry.media_item.id)
|
||||
|
||||
if success:
|
||||
feedback.success("Entry removed from watch history")
|
||||
else:
|
||||
feedback.error("Failed to remove entry")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _edit_entry(ctx: Context, history_manager: WatchHistoryManager, feedback) -> str:
|
||||
"""Edit a watch history entry (select first)."""
|
||||
entries = history_manager.get_all_entries()
|
||||
|
||||
if not entries:
|
||||
feedback.info("No watch history entries to edit")
|
||||
return "CONTINUE"
|
||||
|
||||
# Sort by title for easier selection
|
||||
entries.sort(key=lambda x: x.get_display_title())
|
||||
|
||||
choices = [
|
||||
f"{entry.get_display_title()} - {entry.get_progress_display()}"
|
||||
for entry in entries
|
||||
]
|
||||
choices.append("Cancel")
|
||||
|
||||
choice = ctx.selector.choose("Select anime to edit:", choices=choices)
|
||||
|
||||
if not choice or choice == "Cancel":
|
||||
return "CONTINUE"
|
||||
|
||||
# Find the selected entry
|
||||
choice_title = choice.split(" - ")[0]
|
||||
selected_entry = next(
|
||||
(entry for entry in entries if entry.get_display_title() == choice_title), None
|
||||
)
|
||||
|
||||
if selected_entry:
|
||||
return _show_entry_details(ctx, selected_entry, feedback)
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _remove_entry(ctx: Context, history_manager: WatchHistoryManager, feedback) -> str:
|
||||
"""Remove a watch history entry (select first)."""
|
||||
entries = history_manager.get_all_entries()
|
||||
|
||||
if not entries:
|
||||
feedback.info("No watch history entries to remove")
|
||||
return "CONTINUE"
|
||||
|
||||
# Sort by title for easier selection
|
||||
entries.sort(key=lambda x: x.get_display_title())
|
||||
|
||||
choices = [
|
||||
f"{entry.get_display_title()} - {entry.get_progress_display()}"
|
||||
for entry in entries
|
||||
]
|
||||
choices.append("Cancel")
|
||||
|
||||
choice = ctx.selector.choose("Select anime to remove:", choices=choices)
|
||||
|
||||
if not choice or choice == "Cancel":
|
||||
return "CONTINUE"
|
||||
|
||||
# Find the selected entry
|
||||
choice_title = choice.split(" - ")[0]
|
||||
selected_entry = next(
|
||||
(entry for entry in entries if entry.get_display_title() == choice_title), None
|
||||
)
|
||||
|
||||
if selected_entry:
|
||||
return _confirm_remove_entry(ctx, selected_entry, feedback)
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _view_stats(ctx: Context, history_manager: WatchHistoryManager, feedback) -> str:
|
||||
"""View detailed watch history statistics."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
stats = history_manager.get_stats()
|
||||
|
||||
# Create detailed stats table
|
||||
table = Table(title="Detailed Watch History Statistics")
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
table.add_row("Total Anime Entries", str(stats["total_entries"]))
|
||||
table.add_row("Currently Watching", str(stats["watching"]))
|
||||
table.add_row("Completed", str(stats["completed"]))
|
||||
table.add_row("Dropped", str(stats["dropped"]))
|
||||
table.add_row("Paused", str(stats["paused"]))
|
||||
table.add_row("Total Episodes Watched", str(stats["total_episodes_watched"]))
|
||||
table.add_row("Last Updated", stats["last_updated"])
|
||||
|
||||
# Calculate additional stats
|
||||
if stats["total_entries"] > 0:
|
||||
completion_rate = (stats["completed"] / stats["total_entries"]) * 100
|
||||
table.add_row("Completion Rate", f"{completion_rate:.1f}%")
|
||||
|
||||
avg_episodes = stats["total_episodes_watched"] / stats["total_entries"]
|
||||
table.add_row("Avg Episodes per Anime", f"{avg_episodes:.1f}")
|
||||
|
||||
console.print(table)
|
||||
feedback.pause_for_user()
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _export_history(
|
||||
ctx: Context, history_manager: WatchHistoryManager, feedback
|
||||
) -> str:
|
||||
"""Export watch history to a file."""
|
||||
export_name = ctx.selector.ask("Enter export filename (without extension):")
|
||||
|
||||
if not export_name:
|
||||
return "CONTINUE"
|
||||
|
||||
export_path = APP_DATA_DIR / f"{export_name}.json"
|
||||
|
||||
if export_path.exists():
|
||||
if not feedback.confirm(
|
||||
f"File '{export_name}.json' already exists. Overwrite?"
|
||||
):
|
||||
return "CONTINUE"
|
||||
|
||||
success = history_manager.export_history(export_path)
|
||||
|
||||
if success:
|
||||
feedback.success(f"Watch history exported to {export_path}")
|
||||
else:
|
||||
feedback.error("Failed to export watch history")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _import_history(
|
||||
ctx: Context, history_manager: WatchHistoryManager, feedback
|
||||
) -> str:
|
||||
"""Import watch history from a file."""
|
||||
import_name = ctx.selector.ask("Enter import filename (without extension):")
|
||||
|
||||
if not import_name:
|
||||
return "CONTINUE"
|
||||
|
||||
import_path = APP_DATA_DIR / f"{import_name}.json"
|
||||
|
||||
if not import_path.exists():
|
||||
feedback.error(f"File '{import_name}.json' not found in {APP_DATA_DIR}")
|
||||
return "CONTINUE"
|
||||
|
||||
merge = feedback.confirm(
|
||||
"Merge with existing history? (No = Replace existing history)"
|
||||
)
|
||||
|
||||
success = history_manager.import_history(import_path, merge=merge)
|
||||
|
||||
if success:
|
||||
action = "merged with" if merge else "replaced"
|
||||
feedback.success(f"Watch history imported and {action} existing data")
|
||||
else:
|
||||
feedback.error("Failed to import watch history")
|
||||
|
||||
return "CONTINUE"
|
||||
|
||||
|
||||
def _clear_history(ctx: Context, history_manager: WatchHistoryManager, feedback) -> str:
|
||||
"""Clear all watch history with confirmation."""
|
||||
if not feedback.confirm(
|
||||
"Are you sure you want to clear ALL watch history? This cannot be undone."
|
||||
):
|
||||
return "CONTINUE"
|
||||
|
||||
if not feedback.confirm("Final confirmation: Clear all watch history?"):
|
||||
return "CONTINUE"
|
||||
|
||||
# Create backup before clearing
|
||||
backup_success = history_manager.backup_history()
|
||||
if backup_success:
|
||||
feedback.info("Backup created before clearing")
|
||||
|
||||
success = history_manager.clear_history()
|
||||
|
||||
if success:
|
||||
feedback.success("All watch history cleared")
|
||||
else:
|
||||
feedback.error("Failed to clear watch history")
|
||||
|
||||
return "CONTINUE"
|
||||
Reference in New Issue
Block a user