mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-01-19 08:00:49 -08:00
feat: implement session management functionality with save/load capabilities and error handling
This commit is contained in:
@@ -59,7 +59,8 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
|
||||
ctx, "REPEATING"
|
||||
),
|
||||
# --- Control Flow and Utility Options ---
|
||||
f"{'📝 ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None),
|
||||
f"{'<EFBFBD> ' if icons else ''}Session Management": lambda: ("SESSION_MANAGEMENT", None),
|
||||
f"{'<EFBFBD>📝 ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None),
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: ("EXIT", None),
|
||||
}
|
||||
|
||||
@@ -81,6 +82,8 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
|
||||
return ControlFlow.EXIT
|
||||
if next_menu_name == "RELOAD_CONFIG":
|
||||
return ControlFlow.RELOAD_CONFIG
|
||||
if next_menu_name == "SESSION_MANAGEMENT":
|
||||
return State(menu_name="SESSION_MANAGEMENT")
|
||||
if next_menu_name == "CONTINUE":
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
241
fastanime/cli/interactive/menus/session_management.py
Normal file
241
fastanime/cli/interactive/menus/session_management.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
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"
|
||||
@@ -2,8 +2,9 @@ import importlib.util
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, List
|
||||
from typing import Callable, List
|
||||
|
||||
import click
|
||||
|
||||
@@ -14,6 +15,7 @@ from ...libs.players.base import BasePlayer
|
||||
from ...libs.providers.anime.base import BaseAnimeProvider
|
||||
from ...libs.selectors.base import BaseSelector
|
||||
from ..config import ConfigLoader
|
||||
from ..utils.session_manager import SessionManager
|
||||
from .state import ControlFlow, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -59,6 +61,8 @@ class Session:
|
||||
self._context: Context | None = None
|
||||
self._history: List[State] = []
|
||||
self._menus: dict[str, Menu] = {}
|
||||
self._session_manager = SessionManager()
|
||||
self._auto_save_enabled = True
|
||||
|
||||
def _load_context(self, config: AppConfig):
|
||||
"""Initializes all shared services based on the provided configuration."""
|
||||
@@ -152,14 +156,60 @@ class Session:
|
||||
config: The initial application configuration.
|
||||
resume_path: Optional path to a saved session file to resume from.
|
||||
"""
|
||||
from ..utils.feedback import create_feedback_manager
|
||||
|
||||
feedback = create_feedback_manager(True) # Always use icons for session messages
|
||||
|
||||
self._load_context(config)
|
||||
|
||||
# Handle session recovery
|
||||
if resume_path:
|
||||
self.resume(resume_path)
|
||||
elif not self._history:
|
||||
# Start with the main menu if history is empty
|
||||
self.resume(resume_path, feedback)
|
||||
elif self._session_manager.has_crash_backup():
|
||||
# Offer to resume from crash backup
|
||||
if feedback.confirm(
|
||||
"Found a crash backup from a previous session. Would you like to resume?",
|
||||
default=True
|
||||
):
|
||||
crash_history = self._session_manager.load_crash_backup(feedback)
|
||||
if crash_history:
|
||||
self._history = crash_history
|
||||
feedback.info("Session restored from crash backup")
|
||||
# Clear the crash backup after successful recovery
|
||||
self._session_manager.clear_crash_backup()
|
||||
elif self._session_manager.has_auto_save():
|
||||
# Offer to resume from auto-save
|
||||
if feedback.confirm(
|
||||
"Found an auto-saved session. Would you like to resume?",
|
||||
default=False
|
||||
):
|
||||
auto_history = self._session_manager.load_auto_save(feedback)
|
||||
if auto_history:
|
||||
self._history = auto_history
|
||||
feedback.info("Session restored from auto-save")
|
||||
|
||||
# Start with main menu if no history
|
||||
if not self._history:
|
||||
self._history.append(State(menu_name="MAIN"))
|
||||
|
||||
# Create crash backup before starting
|
||||
if self._auto_save_enabled:
|
||||
self._session_manager.create_crash_backup(self._history)
|
||||
|
||||
try:
|
||||
self._run_main_loop()
|
||||
except KeyboardInterrupt:
|
||||
feedback.warning("Session interrupted by user")
|
||||
self._handle_session_exit(feedback, interrupted=True)
|
||||
except Exception as e:
|
||||
feedback.error("Session crashed unexpectedly", str(e))
|
||||
self._handle_session_exit(feedback, crashed=True)
|
||||
raise
|
||||
else:
|
||||
self._handle_session_exit(feedback, normal_exit=True)
|
||||
|
||||
def _run_main_loop(self):
|
||||
"""Run the main session loop."""
|
||||
while self._history:
|
||||
current_state = self._history[-1]
|
||||
menu_to_run = self._menus.get(current_state.menu_name)
|
||||
@@ -170,6 +220,10 @@ class Session:
|
||||
)
|
||||
break
|
||||
|
||||
# Auto-save periodically (every 5 state changes)
|
||||
if self._auto_save_enabled and len(self._history) % 5 == 0:
|
||||
self._session_manager.auto_save_session(self._history)
|
||||
|
||||
# Execute the menu function, which returns the next step.
|
||||
next_step = menu_to_run.execute(self._context, current_state)
|
||||
|
||||
@@ -187,37 +241,109 @@ class Session:
|
||||
# if the state is main menu we should reset the history
|
||||
if next_step.menu_name == "MAIN":
|
||||
self._history = [next_step]
|
||||
# A new state was returned, push it to history for the next loop.
|
||||
self._history.append(next_step)
|
||||
else:
|
||||
# A new state was returned, push it to history for the next loop.
|
||||
self._history.append(next_step)
|
||||
else:
|
||||
logger.error(
|
||||
f"Menu '{current_state.menu_name}' returned invalid type: {type(next_step)}"
|
||||
)
|
||||
break
|
||||
|
||||
def _handle_session_exit(self, feedback, normal_exit=False, interrupted=False, crashed=False):
|
||||
"""Handle session cleanup on exit."""
|
||||
if self._auto_save_enabled and self._history:
|
||||
if normal_exit:
|
||||
# Clear auto-save on normal exit
|
||||
self._session_manager.clear_auto_save()
|
||||
self._session_manager.clear_crash_backup()
|
||||
feedback.info("Session completed normally")
|
||||
elif interrupted:
|
||||
# Save session on interruption
|
||||
self._session_manager.auto_save_session(self._history)
|
||||
feedback.info("Session auto-saved due to interruption")
|
||||
elif crashed:
|
||||
# Keep crash backup on crash
|
||||
feedback.error("Session backup maintained for recovery")
|
||||
|
||||
click.echo("Exiting interactive session.")
|
||||
|
||||
def save(self, file_path: Path):
|
||||
"""Serializes the session history to a JSON file."""
|
||||
history_dicts = [state.model_dump(mode="json") for state in self._history]
|
||||
try:
|
||||
file_path.write_text(str(history_dicts))
|
||||
logger.info(f"Session saved to {file_path}")
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to save session: {e}")
|
||||
def save(self, file_path: Path, session_name: str = None, description: str = None):
|
||||
"""
|
||||
Save session history to a file with comprehensive metadata and error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to save the session
|
||||
session_name: Optional name for the session
|
||||
description: Optional description for the session
|
||||
"""
|
||||
from ..utils.feedback import create_feedback_manager
|
||||
|
||||
feedback = create_feedback_manager(True)
|
||||
return self._session_manager.save_session(
|
||||
self._history,
|
||||
file_path,
|
||||
session_name=session_name,
|
||||
description=description,
|
||||
feedback=feedback
|
||||
)
|
||||
|
||||
def resume(self, file_path: Path):
|
||||
"""Loads a session history from a JSON file."""
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Resume file not found: {file_path}")
|
||||
return
|
||||
try:
|
||||
history_dicts = file_path.read_text()
|
||||
self._history = [State.model_validate(d) for d in history_dicts]
|
||||
logger.info(f"Session resumed from {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume session: {e}")
|
||||
self._history = [] # Reset history on failure
|
||||
def resume(self, file_path: Path, feedback=None):
|
||||
"""
|
||||
Load session history from a file with comprehensive error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the session file
|
||||
feedback: Optional feedback manager for user notifications
|
||||
"""
|
||||
if not feedback:
|
||||
from ..utils.feedback import create_feedback_manager
|
||||
feedback = create_feedback_manager(True)
|
||||
|
||||
history = self._session_manager.load_session(file_path, feedback)
|
||||
if history:
|
||||
self._history = history
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_saved_sessions(self):
|
||||
"""List all saved sessions with their metadata."""
|
||||
return self._session_manager.list_saved_sessions()
|
||||
|
||||
def cleanup_old_sessions(self, max_sessions: int = 10):
|
||||
"""Clean up old session files, keeping only the most recent ones."""
|
||||
return self._session_manager.cleanup_old_sessions(max_sessions)
|
||||
|
||||
def enable_auto_save(self, enabled: bool = True):
|
||||
"""Enable or disable auto-save functionality."""
|
||||
self._auto_save_enabled = enabled
|
||||
|
||||
def get_session_stats(self) -> dict:
|
||||
"""Get statistics about the current session."""
|
||||
return {
|
||||
"current_states": len(self._history),
|
||||
"current_menu": self._history[-1].menu_name if self._history else None,
|
||||
"auto_save_enabled": self._auto_save_enabled,
|
||||
"has_auto_save": self._session_manager.has_auto_save(),
|
||||
"has_crash_backup": self._session_manager.has_crash_backup()
|
||||
}
|
||||
|
||||
def create_manual_backup(self, backup_name: str = None):
|
||||
"""Create a manual backup of the current session."""
|
||||
from ..utils.feedback import create_feedback_manager
|
||||
from ...core.constants import APP_DIR
|
||||
|
||||
feedback = create_feedback_manager(True)
|
||||
backup_name = backup_name or f"manual_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
backup_path = APP_DIR / "sessions" / f"{backup_name}.json"
|
||||
|
||||
return self._session_manager.save_session(
|
||||
self._history,
|
||||
backup_path,
|
||||
session_name=backup_name,
|
||||
description="Manual backup created by user",
|
||||
feedback=feedback
|
||||
)
|
||||
|
||||
@property
|
||||
def menu(self) -> Callable[[MenuFunction], MenuFunction]:
|
||||
|
||||
Reference in New Issue
Block a user