feat: menus

This commit is contained in:
Benexl
2025-07-22 00:47:42 +03:00
parent 0e6aeeea18
commit 0ce27f8e50
22 changed files with 235 additions and 539 deletions

View File

@@ -16,79 +16,41 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
provider_anime = state.provider.anime
anilist_anime = state.media_api.anime
config = ctx.config
console = Console()
console.clear()
feedback = ctx.services.feedback
feedback.clear_console()
if not provider_anime or not anilist_anime:
console.print("[bold red]Error: Anime details are missing.[/bold red]")
feedback.error("Error: Anime details are missing.")
return ControlFlow.BACK
# Get the list of episode strings based on the configured translation type
available_episodes = getattr(
provider_anime.episodes, config.stream.translation_type, []
)
if not available_episodes:
console.print(
f"[bold yellow]No '{config.stream.translation_type}' episodes found for this anime.[/bold yellow]"
feedback.warning(
f"No '{config.stream.translation_type}' episodes found for this anime."
)
return ControlFlow.BACK
chosen_episode: str | None = None
if config.stream.continue_from_watch_history:
# Use our new watch history system
from ...utils.watch_history_tracker import get_continue_episode, track_episode_viewing
# Try to get continue episode from watch history
if config.stream.preferred_watch_history == "local":
chosen_episode = get_continue_episode(anilist_anime, available_episodes, prefer_history=True)
if chosen_episode:
click.echo(
f"[cyan]Continuing from local watch history. Auto-selecting episode {chosen_episode}.[/cyan]"
)
# Fallback to AniList progress if local history doesn't have info or preference is remote
if not chosen_episode and config.stream.preferred_watch_history == "remote":
progress = (
anilist_anime.user_status.progress
if anilist_anime.user_status and anilist_anime.user_status.progress
else 0
)
# Calculate the next episode based on progress
next_episode_num = str(progress + 1)
if next_episode_num in available_episodes:
click.echo(
f"[cyan]Continuing from AniList history. Auto-selecting episode {next_episode_num}.[/cyan]"
)
chosen_episode = next_episode_num
else:
# If the next episode isn't available, fall back to the last watched one
last_watched_num = str(progress)
if last_watched_num in available_episodes:
click.echo(
f"[cyan]Next episode ({next_episode_num}) not found. Falling back to last watched episode {last_watched_num}.[/cyan]"
)
chosen_episode = last_watched_num
else:
click.echo(
f"[yellow]Could not find episode based on your watch history. Please select manually.[/yellow]"
)
# TODO: implement watch history logic
pass
if not chosen_episode:
choices = [*sorted(available_episodes, key=float), "Back"]
# Get episode preview command if preview is enabled
preview_command = None
if ctx.config.general.preview != "none":
from ...utils.previews import get_episode_preview
preview_command = get_episode_preview(available_episodes, anilist_anime, ctx.config)
preview_command = get_episode_preview(
available_episodes, anilist_anime, ctx.config
)
chosen_episode_str = ctx.selector.choose(
prompt="Select Episode",
choices=choices,
preview=preview_command
prompt="Select Episode", choices=choices, preview=preview_command
)
if not chosen_episode_str or chosen_episode_str == "Back":
@@ -97,13 +59,12 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
chosen_episode = chosen_episode_str
# Track episode selection in watch history (if enabled in config)
if config.stream.continue_from_watch_history and config.stream.preferred_watch_history == "local":
from ...utils.watch_history_tracker import track_episode_viewing
try:
episode_num = int(chosen_episode)
track_episode_viewing(anilist_anime, episode_num, start_tracking=True)
except (ValueError, AttributeError):
pass # Skip tracking if episode number is invalid
if (
config.stream.continue_from_watch_history
and config.stream.preferred_watch_history == "local"
):
# TODO: implement watch history logic
pass
return State(
menu_name="SERVERS",

View File

@@ -1,15 +1,13 @@
import logging
import random
from typing import Callable, Dict, Tuple
from rich.console import Console
from ....libs.api.params import ApiSearchParams, UserListParams
from ....libs.api.types import MediaSearchResult, MediaStatus, UserListStatusType
from ...utils.auth.utils import check_authentication_required, format_auth_menu_header
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__)
MenuAction = Callable[
[],
Tuple[str, MediaSearchResult | None, ApiSearchParams | None, UserListParams | None],
@@ -23,10 +21,10 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
Displays top-level categories for the user to browse and select.
"""
icons = ctx.config.general.icons
feedback = create_feedback_manager(icons)
console = Console()
console.clear()
feedback = ctx.services.feedback
feedback.clear_console()
# TODO: Make them just return the modified state or control flow
options: Dict[str, MenuAction] = {
# --- Search-based Actions ---
f"{'🔥 ' if icons else ''}Trending": _create_media_list_action(
@@ -51,41 +49,21 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
f"{'🎲 ' if icons else ''}Random": _create_random_media_list(ctx),
f"{'🔎 ' if icons else ''}Search": _create_search_media_list(ctx),
# --- Authenticated User List Actions ---
f"{'📺 ' if icons else ''}Watching": _create_user_list_action(ctx, "CURRENT"),
f"{'📑 ' if icons else ''}Planned": _create_user_list_action(ctx, "PLANNING"),
f"{'📺 ' if icons else ''}Watching": _create_user_list_action(ctx, "watching"),
f"{'📑 ' if icons else ''}Planned": _create_user_list_action(ctx, "planning"),
f"{'' if icons else ''}Completed": _create_user_list_action(
ctx, "COMPLETED"
ctx, "completed"
),
f"{'⏸️ ' if icons else ''}Paused": _create_user_list_action(ctx, "PAUSED"),
f"{'🚮 ' if icons else ''}Dropped": _create_user_list_action(ctx, "DROPPED"),
f"{'⏸️ ' if icons else ''}Paused": _create_user_list_action(ctx, "paused"),
f"{'🚮 ' if icons else ''}Dropped": _create_user_list_action(ctx, "dropped"),
f"{'🔁 ' if icons else ''}Rewatching": _create_user_list_action(
ctx, "REPEATING"
ctx, "repeating"
),
# --- List Management ---
f"{'📚 ' if icons else ''}AniList Lists Manager": lambda: (
"ANILIST_LISTS",
None,
None,
None,
),
f"{'📖 ' if icons else ''}Local Watch History": lambda: (
"WATCH_HISTORY",
None,
None,
None,
),
# --- Authentication and Account Management ---
f"{'🔐 ' if icons else ''}Authentication": lambda: ("AUTH", None, None, None),
# --- Control Flow and Utility Options ---
f"{'🔧 ' if icons else ''}Session Management": lambda: (
"SESSION_MANAGEMENT",
None,
None,
None,
),
f"{'📝 ' if icons else ''}Edit Config": lambda: (
"RELOAD_CONFIG",
None,
f"{'🔁 ' if icons else ''}Recent": lambda: (
"RESULTS",
ctx.services.media_registry.get_recently_watched(
ctx.config.anilist.per_page
),
None,
None,
),
@@ -95,7 +73,6 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
choice_str = ctx.selector.choose(
prompt="Select Category",
choices=list(options.keys()),
header=format_auth_menu_header(ctx.media_api, "FastAnime Main Menu", icons),
)
if not choice_str:
@@ -145,93 +122,42 @@ def _create_media_list_action(
"""A factory to create menu actions for fetching media lists"""
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
# Create the search parameters
search_params = ApiSearchParams(
sort=sort, per_page=ctx.config.anilist.per_page, status=status
)
def fetch_data():
return ctx.media_api.search_media(search_params)
result = ctx.media_api.search_media(search_params)
success, result = execute_with_feedback(
fetch_data,
feedback,
"fetch anime list",
loading_msg="Fetching anime",
success_msg="Anime list loaded successfully",
)
# Return the search parameters along with the result for pagination
return (
("RESULTS", result, search_params, None)
if success
else ("CONTINUE", None, None, None)
)
return ("RESULTS", result, search_params, None)
return action
def _create_random_media_list(ctx: Context) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
# Create the search parameters
search_params = ApiSearchParams(
id_in=random.sample(range(1, 160000), k=50),
per_page=ctx.config.anilist.per_page,
)
def fetch_data():
return ctx.media_api.search_media(search_params)
result = ctx.media_api.search_media(search_params)
success, result = execute_with_feedback(
fetch_data,
feedback,
"fetch random anime",
loading_msg="Fetching random anime",
success_msg="Random anime loaded successfully",
)
# Return the search parameters along with the result for pagination
return (
("RESULTS", result, search_params, None)
if success
else ("CONTINUE", None, None, None)
)
return ("RESULTS", result, search_params, None)
return action
def _create_search_media_list(ctx: Context) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
query = ctx.selector.ask("Search for Anime")
if not query:
return "CONTINUE", None, None, None
# Create the search parameters
search_params = ApiSearchParams(query=query)
result = ctx.media_api.search_media(search_params)
def fetch_data():
return ctx.media_api.search_media(search_params)
success, result = execute_with_feedback(
fetch_data,
feedback,
"search anime",
loading_msg=f"Searching for '{query}'",
success_msg=f"Search results for '{query}' loaded successfully",
)
# Return the search parameters along with the result for pagination
return (
("RESULTS", result, search_params, None)
if success
else ("CONTINUE", None, None, None)
)
return ("RESULTS", result, search_params, None)
return action
@@ -240,35 +166,17 @@ def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAc
"""A factory to create menu actions for fetching user lists, handling authentication."""
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
# Check authentication
if not check_authentication_required(
ctx.media_api, feedback, f"view your {status.lower()} list"
):
if not ctx.media_api.is_authenticated():
logger.warning("Not authenticated")
return "CONTINUE", None, None, None
# Create the user list parameters
user_list_params = UserListParams(
status=status, per_page=ctx.config.anilist.per_page
)
def fetch_data():
return ctx.media_api.fetch_user_list(user_list_params)
result = ctx.media_api.fetch_user_list(user_list_params)
success, result = execute_with_feedback(
fetch_data,
feedback,
f"fetch {status.lower()} list",
loading_msg=f"Fetching your {status.lower()} list",
success_msg=f"Your {status.lower()} list loaded successfully",
)
# Return the user list parameters along with the result for pagination
return (
("RESULTS", result, None, user_list_params)
if success
else ("CONTINUE", None, None, None)
)
return ("RESULTS", result, None, user_list_params)
return action

View File

@@ -6,8 +6,6 @@ from rich.console import Console
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
@@ -16,35 +14,25 @@ MenuAction = Callable[[], State | ControlFlow]
@session.menu
def media_actions(ctx: Context, state: State) -> State | ControlFlow:
"""
Displays actions for a single, selected anime, such as streaming,
viewing details, or managing its status on the user's list.
"""
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.
# TODO: Add media list management
# TODO: cross reference for none implemented features
options: Dict[str, MenuAction] = {
f"{'▶️ ' if icons else ''}Stream": _stream(ctx, state),
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state),
f"{' ' if icons else ''}Add/Update List": _add_to_list(ctx, state),
f"{'' if icons else ''}Score Anime": _score_anime(ctx, state),
f"{'<EFBFBD> ' if icons else ''}Manage in Lists": _manage_in_lists(ctx, state),
f"{'<EFBFBD>📚 ' if icons else ''}Add to Local History": _add_to_local_history(ctx, state),
f"{' ' if icons else ''}View Info": _view_info(ctx, state),
f"{'🔙 ' if icons else ''}Back to Results": lambda: ControlFlow.BACK,
}
# --- Prompt and Execute ---
choice_str = ctx.selector.choose(
prompt="Select Action", choices=list(options.keys()), header=header
prompt="Select Action",
choices=list(options.keys()),
)
if choice_str and choice_str in options:
@@ -67,7 +55,7 @@ def _stream(ctx: Context, state: State) -> MenuAction:
def _watch_trailer(ctx: Context, state: State) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
feedback = ctx.services.feedback
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
@@ -79,17 +67,8 @@ def _watch_trailer(ctx: Context, state: State) -> MenuAction:
else:
trailer_url = f"https://www.youtube.com/watch?v={anime.trailer.id}"
def play_trailer():
ctx.player.play(PlayerParams(url=trailer_url, title=""))
ctx.player.play(PlayerParams(url=trailer_url, title=""))
execute_with_feedback(
play_trailer,
feedback,
"play trailer",
loading_msg=f"Playing trailer for '{anime.title.english or anime.title.romaji}'",
success_msg="Trailer started successfully",
show_loading=False,
)
return ControlFlow.CONTINUE
return action
@@ -97,25 +76,28 @@ def _watch_trailer(ctx: Context, state: State) -> MenuAction:
def _add_to_list(ctx: Context, state: State) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
feedback = ctx.services.feedback
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"
):
if not ctx.media_api.is_authenticated():
return ControlFlow.CONTINUE
choices = ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"]
choices = [
"watching",
"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(
_update_user_list(
ctx,
anime,
UpdateListEntryParams(media_id=anime.id, status=status), # type: ignore
UpdateListEntryParams(media_id=anime.id, status=status), # pyright:ignore
feedback,
)
return ControlFlow.CONTINUE
@@ -125,13 +107,13 @@ def _add_to_list(ctx: Context, state: State) -> MenuAction:
def _score_anime(ctx: Context, state: State) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
feedback = ctx.services.feedback
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"):
if not ctx.media_api.is_authenticated():
return ControlFlow.CONTINUE
score_str = ctx.selector.ask("Enter score (0.0 - 10.0):")
@@ -139,7 +121,7 @@ def _score_anime(ctx: Context, state: State) -> MenuAction:
score = float(score_str) if score_str else 0.0
if not 0.0 <= score <= 10.0:
raise ValueError("Score out of range.")
_update_user_list_with_feedback(
_update_user_list(
ctx,
anime,
UpdateListEntryParams(media_id=anime.id, score=score),
@@ -159,8 +141,8 @@ def _view_info(ctx: Context, state: State) -> MenuAction:
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
# Placeholder for a more detailed info screen if needed.
# For now, we'll just print key details.
# TODO: Make this nice and include all other media item fields
from rich import box
from rich.panel import Panel
from rich.text import Text
@@ -185,133 +167,10 @@ def _view_info(ctx: Context, state: State) -> MenuAction:
return action
def _update_user_list(ctx: Context, anime: MediaItem, params: UpdateListEntryParams):
"""Helper to call the API to update a user's list and show feedback."""
# if not ctx.media_api.user_profile:
# click.echo("[bold yellow]You must be logged in to modify your list.[/]")
# return
success = ctx.media_api.update_list_entry(params)
if success:
click.echo(
f"[bold green]Successfully updated '{anime.title.english or anime.title.romaji}' on your list![/]"
)
else:
click.echo("[bold red]Failed to update list entry.[/bold red]")
def _update_user_list_with_feedback(
def _update_user_list(
ctx: Context, anime: MediaItem, params: UpdateListEntryParams, feedback
):
"""Helper to call the API to update a user's list with comprehensive feedback."""
# 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)
anime_title = anime.title.english or anime.title.romaji
success, result = execute_with_feedback(
update_operation,
feedback,
"update anime list",
loading_msg=f"Updating '{anime_title}' on your list",
success_msg=f"Successfully updated '{anime_title}' on your list!",
error_msg="Failed to update list entry",
show_loading=False,
)
def _add_to_local_history(ctx: Context, state: State) -> MenuAction:
"""Add anime to local watch history with status selection."""
def action() -> State | ControlFlow:
anime = state.media_api.anime
if not anime:
click.echo("[bold red]No anime data available.[/bold red]")
return ControlFlow.CONTINUE
feedback = create_feedback_manager(ctx.config.general.icons)
# Check if already in watch history
from ...utils.watch_history_manager import WatchHistoryManager
history_manager = WatchHistoryManager()
existing_entry = history_manager.get_entry(anime.id)
if existing_entry:
# Ask if user wants to update existing entry
if not feedback.confirm(f"'{existing_entry.get_display_title()}' is already in your local watch history. Update it?"):
return ControlFlow.CONTINUE
# Status selection
statuses = ["watching", "completed", "planning", "paused", "dropped"]
status_choices = [status.title() for status in statuses]
chosen_status = ctx.selector.choose(
"Select status for local watch history:",
choices=status_choices + ["Cancel"]
)
if not chosen_status or chosen_status == "Cancel":
return ControlFlow.CONTINUE
status = chosen_status.lower()
# Episode number if applicable
episode = 0
if status in ["watching", "completed"]:
if anime.episodes and anime.episodes > 1:
episode_str = ctx.selector.ask(f"Enter current episode (1-{anime.episodes}, default: 0):")
try:
episode = int(episode_str) if episode_str else 0
episode = max(0, min(episode, anime.episodes))
except ValueError:
episode = 0
# Mark as completed if status is completed
if status == "completed" and anime.episodes:
episode = anime.episodes
# Add to watch history
from ...utils.watch_history_tracker import watch_tracker
success = watch_tracker.add_anime_to_history(anime, status)
if success and episode > 0:
# Update episode progress
history_manager.mark_episode_watched(anime.id, episode, 1.0 if status == "completed" else 0.0)
if success:
feedback.success(f"Added '{anime.title.english or anime.title.romaji}' to local watch history with status: {status}")
else:
feedback.error("Failed to add anime to local watch history")
if ctx.media_api.is_authenticated():
return ControlFlow.CONTINUE
return action
def _manage_in_lists(ctx: Context, state: State) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
# Check authentication before proceeding
if not check_authentication_required(
ctx.media_api, feedback, "manage anime in your lists"
):
return ControlFlow.CONTINUE
# Navigate to AniList anime details with this specific anime
return State(
menu_name="ANILIST_ANIME_DETAILS",
data={
"anime": anime,
"list_status": "CURRENT", # Default status, will be updated when loaded
"return_page": 1,
"from_media_actions": True # Flag to return here instead of lists
}
)
return action
ctx.media_api.update_list_entry(params)

View File

@@ -1,27 +1,18 @@
from typing import TYPE_CHECKING
import click
from rich.console import Console
from rich.progress import Progress
from thefuzz import fuzz
from ....libs.providers.anime.params import SearchParams
from ...utils.feedback import create_feedback_manager, execute_with_feedback
from ....libs.providers.anime.types import SearchResult
from ..session import Context, session
from ..state import ControlFlow, ProviderState, State
if TYPE_CHECKING:
from ....libs.providers.anime.types import SearchResult
@session.menu
def provider_search(ctx: Context, state: State) -> State | ControlFlow:
"""
Searches for the selected AniList anime on the configured provider.
This state allows the user to confirm the correct provider entry before
proceeding to list episodes.
"""
feedback = create_feedback_manager(ctx.config.general.icons)
feedback = ctx.services.feedback
anilist_anime = state.media_api.anime
if not anilist_anime:
feedback.error("No AniList anime to search for", "Please select an anime first")
@@ -30,8 +21,7 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
provider = ctx.provider
selector = ctx.selector
config = ctx.config
console = Console()
console.clear()
feedback.clear_console()
anilist_title = anilist_anime.title.english or anilist_anime.title.romaji
if not anilist_title:
@@ -41,34 +31,19 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
)
return ControlFlow.BACK
# --- Perform Search on Provider ---
def search_provider():
return provider.search(
SearchParams(
query=anilist_title, translation_type=config.stream.translation_type
)
provider_search_results = provider.search(
SearchParams(
query=anilist_title, translation_type=config.stream.translation_type
)
success, provider_search_results = execute_with_feedback(
search_provider,
feedback,
"search provider",
loading_msg=f"Searching for '{anilist_title}' on {provider.__class__.__name__}",
success_msg=f"Found results on {provider.__class__.__name__}",
)
if (
not success
or not provider_search_results
or not provider_search_results.results
):
if not provider_search_results or not provider_search_results.results:
feedback.warning(
f"Could not find '{anilist_title}' on {provider.__class__.__name__}",
"Try another provider from the config or go back to search again",
)
return ControlFlow.BACK
# --- Map results for selection ---
provider_results_map: dict[str, SearchResult] = {
result.title: result for result in provider_search_results.results
}
@@ -82,7 +57,7 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
provider_results_map.keys(),
key=lambda p_title: fuzz.ratio(p_title.lower(), anilist_title.lower()),
)
console.print(f"[cyan]Auto-selecting best match:[/] {best_match_title}")
feedback.info("Auto-selecting best match: {best_match_title}")
selected_provider_anime = provider_results_map[best_match_title]
else:
choices = list(provider_results_map.keys())
@@ -108,8 +83,8 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
full_provider_anime = provider.get(AnimeParams(id=selected_provider_anime.id))
if not full_provider_anime:
console.print(
f"[bold red]Failed to fetch details for '{selected_provider_anime.title}'.[/bold red]"
feedback.warning(
f"Failed to fetch details for '{selected_provider_anime.title}'."
)
return ControlFlow.BACK

View File

@@ -1,88 +1,74 @@
from rich.console import Console
from ....libs.api.types import MediaItem
from ....libs.api.params import ApiSearchParams, UserListParams
from ...utils.auth.utils import get_auth_status_indicator
from ...utils.feedback import create_feedback_manager, execute_with_feedback
from ....libs.api.types import MediaItem
from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State
@session.menu
def results(ctx: Context, state: State) -> State | ControlFlow:
"""
Displays a paginated list of anime from a search or category query.
Allows the user to select an anime to view its actions or navigate pages.
"""
search_results = state.media_api.search_results
console = Console()
console.clear()
feedback = ctx.services.feedback
feedback.clear_console()
if not search_results or not search_results.media:
console.print(
"[bold yellow]No anime found for the given criteria.[/bold yellow]"
)
feedback.info("No anime found for the given criteria")
return ControlFlow.BACK
# --- Prepare choices and previews ---
anime_items = search_results.media
formatted_titles = [
_format_anime_choice(anime, ctx.config) for anime in anime_items
]
# Map formatted titles back to the original MediaItem objects
anime_map = dict(zip(formatted_titles, anime_items))
preview_command = None
if ctx.config.general.preview != "none":
# This function will start background jobs to cache preview data
from ...utils.previews import get_anime_preview
preview_command = get_anime_preview(anime_items, formatted_titles, ctx.config)
# --- Build Navigation and Final Choice List ---
choices = formatted_titles
page_info = search_results.page_info
# Add pagination controls if available with more descriptive text
if page_info.has_next_page:
choices.append(f"{'➡️ ' if ctx.config.general.icons else ''}Next Page (Page {page_info.current_page + 1})")
choices.append(
f"{'➡️ ' if ctx.config.general.icons else ''}Next Page (Page {page_info.current_page + 1})"
)
if page_info.current_page > 1:
choices.append(f"{'⬅️ ' if ctx.config.general.icons else ''}Previous Page (Page {page_info.current_page - 1})")
choices.append(
f"{'⬅️ ' if ctx.config.general.icons else ''}Previous Page (Page {page_info.current_page - 1})"
)
choices.append("Back")
# Create header with auth status and pagination info
auth_status, _ = get_auth_status_indicator(ctx.media_api, ctx.config.general.icons)
pagination_info = f"Page {page_info.current_page}"
if page_info.total > 0 and page_info.per_page > 0:
total_pages = (page_info.total + page_info.per_page - 1) // page_info.per_page
pagination_info += f" of ~{total_pages}"
header = f"Search Results ({len(anime_items)} anime) - {pagination_info}\n{auth_status}"
# --- Prompt User ---
choice_str = ctx.selector.choose(
prompt="Select Anime",
choices=choices,
preview=preview_command,
header=header,
)
if not choice_str:
return ControlFlow.EXIT
# --- Handle User Selection ---
if choice_str == "Back":
return ControlFlow.BACK
# Handle pagination - check for both old and new formats
if (choice_str == "Next Page" or choice_str == "Previous Page" or
choice_str.startswith("Next Page (") or choice_str.startswith("Previous Page (")):
if (
choice_str == "Next Page"
or choice_str == "Previous Page"
or choice_str.startswith("Next Page (")
or choice_str.startswith("Previous Page (")
):
page_delta = 1 if choice_str.startswith("Next Page") else -1
# Implement pagination logic
return _handle_pagination(ctx, state, page_delta)
# If an anime was selected, transition to the MEDIA_ACTIONS state
selected_anime = anime_map.get(choice_str)
if selected_anime:
return State(
@@ -91,7 +77,6 @@ def results(ctx: Context, state: State) -> State | ControlFlow:
search_results=state.media_api.search_results, # Carry over the list
anime=selected_anime, # Set the newly selected item
),
# Persist provider state if it exists
provider=state.provider,
)
@@ -125,36 +110,38 @@ def _format_anime_choice(anime: MediaItem, config) -> str:
return display_title
def _handle_pagination(ctx: Context, state: State, page_delta: int) -> State | ControlFlow:
def _handle_pagination(
ctx: Context, state: State, page_delta: int
) -> State | ControlFlow:
"""
Handle pagination by fetching the next or previous page of results.
Args:
ctx: The application context
state: Current state containing search results and original parameters
page_delta: +1 for next page, -1 for previous page
Returns:
New State with updated search results or ControlFlow.CONTINUE on error
"""
feedback = create_feedback_manager(ctx.config.general.icons)
feedback = ctx.services.feedback
if not state.media_api.search_results:
feedback.error("No search results available for pagination")
return ControlFlow.CONTINUE
current_page = state.media_api.search_results.page_info.current_page
new_page = current_page + page_delta
# Validate page bounds
if new_page < 1:
feedback.warning("Already at the first page")
return ControlFlow.CONTINUE
if page_delta > 0 and not state.media_api.search_results.page_info.has_next_page:
feedback.warning("No more pages available")
return ControlFlow.CONTINUE
# Determine which type of search to perform based on stored parameters
if state.media_api.original_api_params:
# Media search (trending, popular, search, etc.)
@@ -167,13 +154,15 @@ def _handle_pagination(ctx: Context, state: State, page_delta: int) -> State | C
return ControlFlow.CONTINUE
def _fetch_media_page(ctx: Context, state: State, page: int, feedback) -> State | ControlFlow:
def _fetch_media_page(
ctx: Context, state: State, page: int, feedback
) -> State | ControlFlow:
"""Fetch a specific page for media search results."""
original_params = state.media_api.original_api_params
if not original_params:
feedback.error("No original API parameters found")
return ControlFlow.CONTINUE
# Create new parameters with updated page number
new_params = ApiSearchParams(
query=original_params.query,
@@ -203,23 +192,9 @@ def _fetch_media_page(ctx: Context, state: State, page: int, feedback) -> State
type=original_params.type,
on_list=original_params.on_list,
)
def fetch_data():
return ctx.media_api.search_media(new_params)
success, result = execute_with_feedback(
fetch_data,
feedback,
f"fetch page {page}",
loading_msg=f"Loading page {page}",
success_msg=f"Page {page} loaded successfully",
show_loading=False,
)
if not success or not result:
return ControlFlow.CONTINUE
# Return new state with updated results
result = ctx.media_api.search_media(new_params)
return State(
menu_name="RESULTS",
media_api=MediaApiState(
@@ -231,36 +206,24 @@ def _fetch_media_page(ctx: Context, state: State, page: int, feedback) -> State
)
def _fetch_user_list_page(ctx: Context, state: State, page: int, feedback) -> State | ControlFlow:
def _fetch_user_list_page(
ctx: Context, state: State, page: int, feedback
) -> State | ControlFlow:
"""Fetch a specific page for user list results."""
original_params = state.media_api.original_user_list_params
if not original_params:
feedback.error("No original user list parameters found")
return ControlFlow.CONTINUE
# Create new parameters with updated page number
new_params = UserListParams(
status=original_params.status,
page=page,
per_page=original_params.per_page,
)
def fetch_data():
return ctx.media_api.fetch_user_list(new_params)
success, result = execute_with_feedback(
fetch_data,
feedback,
f"fetch page {page} of {original_params.status.lower()} list",
loading_msg=f"Loading page {page}",
success_msg=f"Page {page} loaded successfully",
show_loading=False,
)
if not success or not result:
return ControlFlow.CONTINUE
# Return new state with updated results
result = ctx.media_api.fetch_user_list(new_params)
return State(
menu_name="RESULTS",
media_api=MediaApiState(

View File

@@ -1,6 +1,5 @@
from typing import Dict, List
import click
from rich.console import Console
from rich.progress import Progress
@@ -100,6 +99,10 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
headers=selected_server.headers,
)
)
if state.media_api.anime and state.provider.episode_number:
ctx.services.watch_history.track(
state.media_api.anime, state.provider.episode_number, player_result
)
return State(
menu_name="PLAYER_CONTROLS",

View File

@@ -121,7 +121,7 @@ class Session:
try:
self._run_main_loop()
except Exception as e:
self._context.services.session.save_session(self._history)
self._context.services.session.create_crash_backup(self._history)
raise
self._context.services.session.save_session(self._history)