feat: stabilize the interactive workflow

This commit is contained in:
Benexl
2025-07-14 20:09:57 +03:00
parent e8491e3723
commit d1dfddf290
24 changed files with 617 additions and 406 deletions

View File

@@ -1,13 +1,11 @@
from typing import TYPE_CHECKING
import click
from rich.console import Console
from ..session import Context, session
from ..state import ControlFlow, ProviderState, State
if TYPE_CHECKING:
pass
@session.menu
def episodes(ctx: Context, state: State) -> State | ControlFlow:
@@ -18,9 +16,11 @@ 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()
if not provider_anime or not anilist_anime:
click.echo("[bold red]Error: Anime details are missing.[/bold red]")
console.print("[bold red]Error: Anime details are missing.[/bold red]")
return ControlFlow.BACK
# Get the list of episode strings based on the configured translation type
@@ -28,15 +28,14 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
provider_anime.episodes, config.stream.translation_type, []
)
if not available_episodes:
click.echo(
console.print(
f"[bold yellow]No '{config.stream.translation_type}' episodes found for this anime.[/bold yellow]"
)
return ControlFlow.BACK
chosen_episode: str | None = None
# --- "Continue from History" Logic ---
if config.stream.continue_from_watch_history:
if config.stream.continue_from_watch_history and False:
progress = (
anilist_anime.user_status.progress
if anilist_anime.user_status and anilist_anime.user_status.progress
@@ -64,7 +63,6 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
f"[yellow]Could not find episode based on your watch history. Please select manually.[/yellow]"
)
# --- Manual Selection Logic ---
if not chosen_episode:
choices = [*sorted(available_episodes, key=float), "Back"]
@@ -72,7 +70,7 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
# preview_command = get_episode_preview(...)
chosen_episode_str = ctx.selector.choose(
prompt="Select Episode", choices=choices, header=provider_anime.title
prompt="Select Episode", choices=choices
)
if not chosen_episode_str or chosen_episode_str == "Back":
@@ -80,8 +78,6 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
chosen_episode = chosen_episode_str
# --- Transition to Servers Menu ---
# Create a new state, updating the provider state with the chosen episode.
return State(
menu_name="SERVERS",
media_api=state.media_api,

View File

@@ -1,23 +1,14 @@
# fastanime/cli/interactive/menus/main.py
from __future__ import annotations
import random
from typing import TYPE_CHECKING, Callable, Dict, Tuple
from typing import Callable, Dict, Tuple
import click
from rich.console import Console
from rich.progress import Progress
from ....libs.api.params import ApiSearchParams, UserListParams
from ....libs.api.types import MediaSearchResult, MediaStatus, UserListStatusType
from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State
if TYPE_CHECKING:
from ....libs.api.types import MediaSearchResult
# A type alias for the actions this menu can perform.
# It returns a tuple: (NextMenuNameOrControlFlow, Optional[DataPayload])
MenuAction = Callable[[], Tuple[str, MediaSearchResult | None]]
@@ -28,66 +19,32 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
Displays top-level categories for the user to browse and select.
"""
icons = ctx.config.general.icons
api_client = ctx.media_api
per_page = ctx.config.anilist.per_page
console = Console()
console.clear()
# The lambdas now correctly use the versatile search_media for most actions.
options: Dict[str, MenuAction] = {
# --- Search-based Actions ---
f"{'🔥 ' if icons else ''}Trending": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(sort="TRENDING_DESC", per_page=per_page)
),
f"{'🔥 ' if icons else ''}Trending": _create_media_list_action(
ctx, "TRENDING_DESC"
),
f"{'' if icons else ''}Popular": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(sort="POPULARITY_DESC", per_page=per_page)
),
f"{'' if icons else ''}Popular": _create_media_list_action(
ctx, "POPULARITY_DESC"
),
f"{'💖 ' if icons else ''}Favourites": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(sort="FAVOURITES_DESC", per_page=per_page)
),
f"{'💖 ' if icons else ''}Favourites": _create_media_list_action(
ctx, "FAVOURITES_DESC"
),
f"{'💯 ' if icons else ''}Top Scored": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(sort="SCORE_DESC", per_page=per_page)
),
f"{'💯 ' if icons else ''}Top Scored": _create_media_list_action(
ctx, "SCORE_DESC"
),
f"{'🎬 ' if icons else ''}Upcoming": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(
status="NOT_YET_RELEASED", sort="POPULARITY_DESC", per_page=per_page
)
),
f"{'🎬 ' if icons else ''}Upcoming": _create_media_list_action(
ctx, "POPULARITY_DESC", "NOT_YET_RELEASED"
),
f"{'🔔 ' if icons else ''}Recently Updated": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(
status="RELEASING", sort="UPDATED_AT_DESC", per_page=per_page
)
),
),
f"{'🎲 ' if icons else ''}Random": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(
id_in=random.sample(range(1, 160000), k=50), per_page=per_page
)
),
),
f"{'🔎 ' if icons else ''}Search": lambda: (
"RESULTS",
api_client.search_media(
ApiSearchParams(query=ctx.selector.ask("Search for Anime"))
),
f"{'🔔 ' if icons else ''}Recently Updated": _create_media_list_action(
ctx, "UPDATED_AT_DESC"
),
# --- special case media list --
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"),
@@ -116,10 +73,7 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
# --- Action Handling ---
selected_action = options[choice_str]
with Progress(transient=True) as progress:
task = progress.add_task(f"[cyan]Fetching {choice_str.strip()}...", total=None)
next_menu_name, result_data = selected_action()
progress.update(task, completed=True)
next_menu_name, result_data = selected_action()
if next_menu_name == "EXIT":
return ControlFlow.EXIT
@@ -129,7 +83,7 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
return ControlFlow.CONTINUE
if not result_data:
click.echo(
console.print(
f"[bold red]Error:[/bold red] Failed to fetch data for '{choice_str.strip()}'."
)
return ControlFlow.CONTINUE
@@ -141,17 +95,62 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
)
def _create_user_list_action(ctx: Context, status: str) -> MenuAction:
"""A factory to create menu actions for fetching user lists, handling authentication."""
def _create_media_list_action(
ctx: Context, sort, status: MediaStatus | None = None
) -> MenuAction:
"""A factory to create menu actions for fetching media lists"""
def action() -> Tuple[str, MediaSearchResult | None]:
if not ctx.media_api.user_profile:
click.echo(
f"[bold yellow]Please log in to view your '{status.title()}' list.[/]"
def action():
with Progress(transient=True) as progress:
progress.add_task(f"[cyan]Fetching anime...", total=None)
return "RESULTS", ctx.media_api.search_media(
ApiSearchParams(
sort=sort, per_page=ctx.config.anilist.per_page, status=status
)
)
return action
def _create_random_media_list(ctx: Context) -> MenuAction:
def action():
with Progress(transient=True) as progress:
progress.add_task(f"[cyan]Fetching random anime...", total=None)
return "RESULTS", ctx.media_api.search_media(
ApiSearchParams(
id_in=random.sample(range(1, 160000), k=50),
per_page=ctx.config.anilist.per_page,
)
)
return action
def _create_search_media_list(ctx: Context) -> MenuAction:
def action():
query = ctx.selector.ask("Search for Anime")
if not query:
return "CONTINUE", None
with Progress(transient=True) as progress:
progress.add_task(f"[cyan]Searching for {query}...", total=None)
return "RESULTS", ctx.media_api.search_media(ApiSearchParams(query=query))
return action
def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAction:
"""A factory to create menu actions for fetching user lists, handling authentication."""
def action():
# if not ctx.media_api.user_profile:
# click.echo(
# f"[bold yellow]Please log in to view your '{status.title()}' list.[/]"
# )
# return "CONTINUE", None
with Progress(transient=True) as progress:
progress.add_task(f"[cyan]Fetching random anime...", total=None)
return "RESULTS", ctx.media_api.fetch_user_list(
UserListParams(status=status, per_page=ctx.config.anilist.per_page)
)
return "CONTINUE", None
return "RESULTS", ctx.media_api.fetch_user_list(
UserListParams(status=status, per_page=ctx.config.anilist.per_page)
)
return action

View File

@@ -1,16 +1,15 @@
from typing import TYPE_CHECKING, Callable, Dict, Tuple
from typing import Callable, Dict
import click
from InquirerPy.validator import EmptyInputValidator, NumberValidator
from rich.console import Console
from ....libs.api.params import UpdateListEntryParams
from ....libs.api.types import UserListStatusType
from ...utils.anilist import anilist_data_helper
from ....libs.api.types import MediaItem
from ....libs.players.params import PlayerParams
from ..session import Context, session
from ..state import ControlFlow, MediaApiState, ProviderState, State
from ..state import ControlFlow, ProviderState, State
if TYPE_CHECKING:
from ....libs.api.types import MediaItem
MenuAction = Callable[[], State | ControlFlow]
@session.menu
@@ -19,101 +18,21 @@ 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.
"""
anime = state.media_api.anime
if not anime:
click.echo("[bold red]Error: No anime selected.[/bold red]")
return ControlFlow.BACK
icons = ctx.config.general.icons
selector = ctx.selector
player = ctx.player
# --- Action Implementations ---
def stream() -> State | ControlFlow:
# This is the key transition to the provider-focused part of the app.
# We create a new state for the next menu, carrying over the selected
# anime's details for the provider to use.
return State(
menu_name="PROVIDER_SEARCH",
media_api=state.media_api, # Carry over the existing api state
provider=ProviderState(), # Initialize a fresh provider state
)
def watch_trailer() -> State | ControlFlow:
if not anime.trailer or not anime.trailer.id:
click.echo(
"[bold yellow]No trailer available for this anime.[/bold yellow]"
)
else:
trailer_url = f"https://www.youtube.com/watch?v={anime.trailer.id}"
click.echo(
f"Playing trailer for '{anime.title.english or anime.title.romaji}'..."
)
player.play(url=trailer_url, title=f"Trailer: {anime.title.english}")
return ControlFlow.CONTINUE
def add_to_list() -> State | ControlFlow:
choices = ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"]
status = selector.choose("Select list status:", choices=choices)
if status:
_update_user_list(
ctx,
anime,
UpdateListEntryParams(media_id=anime.id, status=status),
)
return ControlFlow.CONTINUE
def score_anime() -> State | ControlFlow:
score_str = selector.ask(
"Enter score (0.0 - 10.0):",
)
try:
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(
ctx, anime, UpdateListEntryParams(media_id=anime.id, score=score)
)
except (ValueError, TypeError):
click.echo(
"[bold red]Invalid score. Please enter a number between 0 and 10.[/bold red]"
)
return ControlFlow.CONTINUE
def view_info() -> State | ControlFlow:
# Placeholder for a more detailed info screen if needed.
# For now, we'll just print key details.
from rich import box
from rich.panel import Panel
from rich.text import Text
title = Text(anime.title.english or anime.title.romaji, style="bold cyan")
description = anilist_data_helper.clean_html(
anime.description or "No description."
)
genres = f"[bold]Genres:[/bold] {', '.join(anime.genres)}"
panel_content = f"{genres}\n\n{description}"
click.echo(Panel(panel_content, title=title, box=box.ROUNDED, expand=False))
selector.ask("Press Enter to continue...") # Pause to allow reading
return ControlFlow.CONTINUE
# --- Build Menu Options ---
options: Dict[str, Callable[[], State | ControlFlow]] = {
f"{'▶️ ' if icons else ''}Stream": stream,
f"{'📼 ' if icons else ''}Watch Trailer": watch_trailer,
f"{' ' if icons else ''}Add/Update List": add_to_list,
f"{'' if icons else ''}Score Anime": score_anime,
f"{' ' if icons else ''}View Info": view_info,
# TODO: Add 'Recommendations' and 'Relations' here later.
# TODO: Add 'Recommendations' and 'Relations' here later.
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"{' ' if icons else ''}View Info": _view_info(ctx, state),
f"{'🔙 ' if icons else ''}Back to Results": lambda: ControlFlow.BACK,
}
# --- Prompt and Execute ---
header = f"Actions for: {anime.title.english or anime.title.romaji}"
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:
@@ -122,11 +41,112 @@ def media_actions(ctx: Context, state: State) -> State | ControlFlow:
return ControlFlow.BACK
# --- Action Implementations ---
def _stream(ctx: Context, state: State) -> MenuAction:
def action():
return State(
menu_name="PROVIDER_SEARCH",
media_api=state.media_api, # Carry over the existing api state
provider=ProviderState(), # Initialize a fresh provider state
)
return action
def _watch_trailer(ctx: Context, state: State) -> MenuAction:
def action():
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
if not anime.trailer or not anime.trailer.id:
print("[bold yellow]No trailer available for this anime.[/bold yellow]")
else:
trailer_url = f"https://www.youtube.com/watch?v={anime.trailer.id}"
print(
f"Playing trailer for '{anime.title.english or anime.title.romaji}'..."
)
ctx.player.play(PlayerParams(url=trailer_url, title=""))
return ControlFlow.CONTINUE
return action
def _add_to_list(ctx: Context, state: State) -> MenuAction:
def action():
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
choices = ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"]
status = ctx.selector.choose("Select list status:", choices=choices)
if status:
_update_user_list(
ctx,
anime,
UpdateListEntryParams(media_id=anime.id, status=status),
)
return ControlFlow.CONTINUE
return action
def _score_anime(ctx: Context, state: State) -> MenuAction:
def action():
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
score_str = ctx.selector.ask("Enter score (0.0 - 10.0):")
try:
score = float(score_str) if score_str else 0.0
if not 0.0 <= score <= 10.0:
raise ValueError("Score out of range.")
_update_user_list(
ctx, anime, UpdateListEntryParams(media_id=anime.id, score=score)
)
except (ValueError, TypeError):
print(
"[bold red]Invalid score. Please enter a number between 0 and 10.[/bold red]"
)
return ControlFlow.CONTINUE
return action
def _view_info(ctx: Context, state: State) -> MenuAction:
def action():
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.
from rich import box
from rich.panel import Panel
from rich.text import Text
from ...utils import image
console = Console()
title = Text(anime.title.english or anime.title.romaji or "", style="bold cyan")
description = Text(anime.description or "NO description")
genres = Text(f"Genres: {', '.join(anime.genres)}")
panel_content = f"{genres}\n\n{description}"
console.clear()
if cover_image := anime.cover_image:
image.render_image(cover_image.large)
console.print(Panel(panel_content, title=title, box=box.ROUNDED, expand=True))
ctx.selector.ask("Press Enter to continue...")
return ControlFlow.CONTINUE
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
# 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:

View File

@@ -2,10 +2,11 @@ import threading
from typing import TYPE_CHECKING, Callable, Dict
import click
from rich.console import Console
from ....libs.api.params import UpdateListEntryParams
from ..session import Context, session
from ..state import ControlFlow, ProviderState, State
from ..state import ControlFlow, State
if TYPE_CHECKING:
from ....libs.providers.anime.types import Server
@@ -27,8 +28,8 @@ def _update_progress_in_background(ctx: Context, anime_id: int, progress: int):
"""Fires off a non-blocking request to update AniList progress."""
def task():
if not ctx.media_api.user_profile:
return
# if not ctx.media_api.user_profile:
# return
params = UpdateListEntryParams(media_id=anime_id, progress=progress)
ctx.media_api.update_list_entry(params)
# We don't need to show feedback here, it's a background task.
@@ -46,6 +47,8 @@ def player_controls(ctx: Context, state: State) -> State | ControlFlow:
config = ctx.config
player = ctx.player
selector = ctx.selector
console = Console()
console.clear()
provider_anime = state.provider.anime
anilist_anime = state.media_api.anime
@@ -63,7 +66,9 @@ def player_controls(ctx: Context, state: State) -> State | ControlFlow:
all_servers,
)
):
click.echo("[bold red]Error: Player state is incomplete. Returning.[/bold red]")
console.print(
"[bold red]Error: Player state is incomplete. Returning.[/bold red]"
)
return ControlFlow.BACK
# --- Post-Playback Logic ---
@@ -86,7 +91,7 @@ def player_controls(ctx: Context, state: State) -> State | ControlFlow:
current_index = available_episodes.index(current_episode_num)
if config.stream.auto_next and current_index < len(available_episodes) - 1:
click.echo("[cyan]Auto-playing next episode...[/cyan]")
console.print("[cyan]Auto-playing next episode...[/cyan]")
next_episode_num = available_episodes[current_index + 1]
return State(
menu_name="SERVERS",
@@ -108,7 +113,7 @@ def player_controls(ctx: Context, state: State) -> State | ControlFlow:
update={"episode_number": next_episode_num}
),
)
click.echo("[bold yellow]This is the last available episode.[/bold yellow]")
console.print("[bold yellow]This is the last available episode.[/bold yellow]")
return ControlFlow.CONTINUE
def replay() -> State | ControlFlow:

View File

@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
import click
from rich.console import Console
from rich.progress import Progress
from thefuzz import fuzz
@@ -27,10 +28,12 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
provider = ctx.provider
selector = ctx.selector
config = ctx.config
console = Console()
console.clear()
anilist_title = anilist_anime.title.english or anilist_anime.title.romaji
if not anilist_title:
click.echo(
console.print(
"[bold red]Error: Selected anime has no searchable title.[/bold red]"
)
return ControlFlow.BACK
@@ -48,10 +51,10 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
)
if not provider_search_results or not provider_search_results.results:
click.echo(
console.print(
f"[bold yellow]Could not find '{anilist_title}' on {provider.__class__.__name__}.[/bold yellow]"
)
click.echo("Try another provider from the config or go back.")
console.print("Try another provider from the config or go back.")
return ControlFlow.BACK
# --- Map results for selection ---
@@ -68,16 +71,14 @@ 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()),
)
click.echo(f"[cyan]Auto-selecting best match:[/] {best_match_title}")
console.print(f"[cyan]Auto-selecting best match:[/] {best_match_title}")
selected_provider_anime = provider_results_map[best_match_title]
else:
choices = list(provider_results_map.keys())
choices.append("Back")
chosen_title = selector.choose(
prompt=f"Confirm match for '{anilist_title}'",
choices=choices,
header="Provider Search Results",
prompt=f"Confirm match for '{anilist_title}'", choices=choices
)
if not chosen_title or chosen_title == "Back":
@@ -85,9 +86,6 @@ def provider_search(ctx: Context, state: State) -> State | ControlFlow:
selected_provider_anime = provider_results_map[chosen_title]
if not selected_provider_anime:
return ControlFlow.BACK
# --- Fetch Full Anime Details from Provider ---
with Progress(transient=True) as progress:
progress.add_task(
@@ -99,14 +97,11 @@ 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:
click.echo(
console.print(
f"[bold red]Failed to fetch details for '{selected_provider_anime.title}'.[/bold red]"
)
return ControlFlow.BACK
# --- Transition to Episodes Menu ---
# Create the next state, populating the 'provider' field for the first time
# while carrying over the 'media_api' state.
return State(
menu_name="EPISODES",
media_api=state.media_api,

View File

@@ -1,19 +1,10 @@
from typing import TYPE_CHECKING, List
import click
from rich.progress import Progress
from yt_dlp.utils import sanitize_filename
from rich.console import Console
from ...utils.anilist import (
anilist_data_helper, # Assuming this is the new location
)
from ...utils.previews import get_anime_preview
from ....libs.api.types import MediaItem
from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State
if TYPE_CHECKING:
from ....libs.api.types import MediaItem
@session.menu
def results(ctx: Context, state: State) -> State | ControlFlow:
@@ -22,8 +13,12 @@ def results(ctx: Context, state: State) -> State | ControlFlow:
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()
if not search_results or not search_results.media:
click.echo("[bold yellow]No anime found for the given criteria.[/bold yellow]")
console.print(
"[bold yellow]No anime found for the given criteria.[/bold yellow]"
)
return ControlFlow.BACK
# --- Prepare choices and previews ---
@@ -38,6 +33,8 @@ def results(ctx: Context, state: State) -> State | ControlFlow:
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 ---
@@ -55,7 +52,6 @@ def results(ctx: Context, state: State) -> State | ControlFlow:
choice_str = ctx.selector.choose(
prompt="Select Anime",
choices=choices,
header="AniList Results",
preview=preview_command,
)
@@ -119,5 +115,4 @@ def _format_anime_choice(anime: MediaItem, config) -> str:
icon = "🔹" if config.general.icons else "!"
display_title += f" {icon}{unwatched} new{icon}"
# Sanitize for use as a potential filename/cache key
return sanitize_filename(display_title, restricted=True)
return display_title

View File

@@ -1,18 +1,14 @@
from typing import TYPE_CHECKING, Dict, List
from typing import Dict, List
import click
from rich.console import Console
from rich.progress import Progress
from ....libs.players.params import PlayerParams
from ....libs.providers.anime.params import EpisodeStreamsParams
from ....libs.providers.anime.types import Server
from ..session import Context, session
from ..state import ControlFlow, ProviderState, State
if TYPE_CHECKING:
from ....cli.utils.utils import (
filter_by_quality, # You may need to create this helper
)
from ....libs.providers.anime.types import Server
from ..state import ControlFlow, State
def _filter_by_quality(links, quality):
@@ -34,9 +30,14 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
config = ctx.config
provider = ctx.provider
selector = ctx.selector
console = Console()
console.clear()
if not provider_anime or not episode_number:
click.echo("[bold red]Error: Anime or episode details are missing.[/bold red]")
console.print(
"[bold red]Error: Anime or episode details are missing.[/bold red]"
)
selector.ask("Enter to continue...")
return ControlFlow.BACK
# --- Fetch Server Streams ---
@@ -55,7 +56,7 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
all_servers: List[Server] = list(server_iterator) if server_iterator else []
if not all_servers:
click.echo(
console.print(
f"[bold yellow]No streaming servers found for this episode.[/bold yellow]"
)
return ControlFlow.BACK
@@ -67,10 +68,12 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
preferred_server = config.stream.server.lower()
if preferred_server == "top":
selected_server = all_servers[0]
click.echo(f"[cyan]Auto-selecting top server:[/] {selected_server.name}")
console.print(f"[cyan]Auto-selecting top server:[/] {selected_server.name}")
elif preferred_server in server_map:
selected_server = server_map[preferred_server]
click.echo(f"[cyan]Auto-selecting preferred server:[/] {selected_server.name}")
console.print(
f"[cyan]Auto-selecting preferred server:[/] {selected_server.name}"
)
else:
choices = [*server_map.keys(), "Back"]
chosen_name = selector.choose("Select Server", choices)
@@ -78,20 +81,16 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
return ControlFlow.BACK
selected_server = server_map[chosen_name]
if not selected_server:
return ControlFlow.BACK
# --- Select Stream Quality ---
stream_link_obj = _filter_by_quality(selected_server.links, config.stream.quality)
if not stream_link_obj:
click.echo(
console.print(
f"[bold red]No stream of quality '{config.stream.quality}' found on server '{selected_server.name}'.[/bold red]"
)
return ControlFlow.CONTINUE
# --- Launch Player ---
final_title = f"{provider_anime.title} - Ep {episode_number}"
click.echo(f"[bold green]Launching player for:[/] {final_title}")
console.print(f"[bold green]Launching player for:[/] {final_title}")
player_result = ctx.player.play(
PlayerParams(
@@ -99,12 +98,9 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
title=final_title,
subtitles=[sub.url for sub in selected_server.subtitles],
headers=selected_server.headers,
# start_time logic will be added in player_controls
)
)
# --- Transition to Player Controls ---
# We now have all the data for post-playback actions.
return State(
menu_name="PLAYER_CONTROLS",
media_api=state.media_api,
@@ -112,7 +108,7 @@ def servers(ctx: Context, state: State) -> State | ControlFlow:
update={
"servers": all_servers,
"selected_server": selected_server,
"last_player_result": player_result, # We should add this to ProviderState
"last_player_result": player_result,
}
),
)

View File

@@ -8,21 +8,21 @@ from typing import TYPE_CHECKING, Callable, List
import click
from ...core.config import AppConfig
from ...core.constants import USER_CONFIG_PATH
from ...core.constants import APP_DIR, USER_CONFIG_PATH
from ...libs.api.base import BaseApiClient
from ...libs.players.base import BasePlayer
from ...libs.providers.anime.base import BaseAnimeProvider
from ...libs.selectors.base import BaseSelector
from ..config import ConfigLoader
from .state import ControlFlow, State
if TYPE_CHECKING:
from ...libs.api.base import BaseApiClient
from ...libs.players.base import BasePlayer
from ...libs.providers.anime.base import BaseAnimeProvider
from ...libs.selectors.base import BaseSelector
logger = logging.getLogger(__name__)
# A type alias for the signature all menu functions must follow.
MenuFunction = Callable[["Context", State], "State | ControlFlow"]
MENUS_DIR = APP_DIR / "cli" / "interactive" / "menus"
@dataclass(frozen=True)
class Context:
@@ -113,10 +113,7 @@ class Session:
# Execute the menu function, which returns the next step.
next_step = menu_to_run.execute(self._context, current_state)
if isinstance(next_step, State):
# A new state was returned, push it to history for the next loop.
self._history.append(next_step)
elif isinstance(next_step, ControlFlow):
if isinstance(next_step, ControlFlow):
# A control command was issued.
if next_step == ControlFlow.EXIT:
break # Exit the loop
@@ -126,6 +123,12 @@ class Session:
elif next_step == ControlFlow.RELOAD_CONFIG:
self._edit_config()
# For CONTINUE, we do nothing, allowing the loop to re-run the current state.
elif isinstance(next_step, State):
# 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:
logger.error(
f"Menu '{current_state.menu_name}' returned invalid type: {type(next_step)}"
@@ -169,7 +172,7 @@ class Session:
return decorator
def load_menus_from_folder(self, package_path: Path):
def load_menus_from_folder(self, package_path: Path = MENUS_DIR):
"""
Dynamically imports all Python modules from a folder to register their menus.

View File

@@ -1,12 +1,16 @@
from enum import Enum, auto
from typing import Iterator, Optional
from typing import Iterator, List, Literal, Optional
from pydantic import BaseModel, ConfigDict
# Import the actual data models from your libs.
# These will be the data types held within our state models.
from ....libs.api.types import MediaItem, MediaSearchResult
from ....libs.providers.anime.types import Anime, SearchResults, Server
from ...libs.api.types import (
MediaItem,
MediaSearchResult,
MediaStatus,
UserListStatusType,
)
from ...libs.players.types import PlayerResult
from ...libs.providers.anime.types import Anime, SearchResults, Server
class ControlFlow(Enum):
@@ -47,6 +51,10 @@ class ProviderState(BaseModel):
search_results: Optional[SearchResults] = None
anime: Optional[Anime] = None
episode_streams: Optional[Iterator[Server]] = None
episode_number: Optional[str] = None
last_player_result: Optional[PlayerResult] = None
servers: Optional[List[Server]] = None
selected_server: Optional[Server] = None
model_config = ConfigDict(
frozen=True,
@@ -62,6 +70,11 @@ class MediaApiState(BaseModel):
"""
search_results: Optional[MediaSearchResult] = None
search_results_type: Optional[Literal["MEDIA_LIST", "USER_MEDIA_LIST"]] = None
sort: Optional[str] = None
query: Optional[str] = None
user_media_status: Optional[UserListStatusType] = None
media_status: Optional[MediaStatus] = None
anime: Optional[MediaItem] = None
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)