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

@@ -30,6 +30,7 @@ commands = {
"config": ".config",
"search": ".search",
"download": ".download",
"anilist": ".anilist",
}

View File

@@ -1,5 +1,6 @@
from .anilist import anilist
from .config import config
from .download import download
from .search import search
__all__ = ["config", "search", "download"]
__all__ = ["config", "search", "download", "anilist"]

View File

@@ -1,36 +1,15 @@
import click
from ...interactive.anilist.controller import InteractiveController
from ...interactive.session import session
# Import the new interactive components
from ...interactive.session import Session
from ...utils.lazyloader import LazyGroup
# Define your subcommands (this part remains the same)
commands = {
"trending": "trending.trending",
"recent": "recent.recent",
"search": "search.search",
# ... add all your other subcommands
}
@click.group(
lazy_subcommands=commands,
cls=LazyGroup(root="fastanime.cli.commands.anilist.subcommands"),
invoke_without_command=True,
help="A beautiful interface that gives you access to a complete streaming experience",
short_help="Access all streaming options",
epilog="""
\b
\b\bExamples:
# Launch the interactive TUI
fastanime anilist
\b
# Run a specific subcommand
fastanime anilist trending --dump-json
""",
)
@click.command(name="anilist")
@click.option(
"--resume", is_flag=True, help="Resume from the last session (Not yet implemented)."
)
@@ -40,35 +19,9 @@ def anilist(ctx: click.Context, resume: bool):
The entry point for the 'anilist' command. If no subcommand is invoked,
it launches the interactive TUI mode.
"""
from ....libs.anilist.api import AniListApi
config = ctx.obj
# Initialize the AniList API client.
anilist_client = AniListApi()
if user := getattr(config, "user", None): # Safely access user attribute
anilist_client.update_login_info(user, user["token"])
if ctx.invoked_subcommand is None:
# ---- LAUNCH INTERACTIVE MODE ----
# 1. Create the session object.
session = Session(config, anilist_client)
# 2. Handle resume logic (placeholder for now).
if resume:
click.echo(
"Resume functionality is not yet implemented in the new architecture.",
err=True,
)
# You would load session.state from a file here.
# 3. Initialize and run the controller.
controller = InteractiveController(session)
# Clear the screen for a clean TUI experience.
click.clear()
controller.run()
# Print a goodbye message on exit.
click.echo("Exiting FastAnime. Have a great day!")
session.load_menus_from_folder()
session.run(config)

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)

View File

@@ -0,0 +1,29 @@
# Define ANSI escape codes as constants
RESET = "\033[0m"
BOLD = "\033[1m"
INVISIBLE_CURSOR = "\033[?25l"
VISIBLE_CURSOR = "\033[?25h"
UNDERLINE = "\033[4m"
def get_true_fg(color: list[str], bold: bool = True) -> str:
"""Custom helper function that enables colored text in the terminal
Args:
bold: whether to bolden the text
string: string to color
r: red
g: green
b: blue
Returns:
colored string
"""
# NOTE: Currently only supports terminals that support true color
r = color[0]
g = color[1]
b = color[2]
if bold:
return f"{BOLD}\033[38;2;{r};{g};{b};m"
else:
return f"\033[38;2;{r};{g};{b};m"

View File

@@ -0,0 +1,63 @@
import re
from typing import TYPE_CHECKING, List, Optional
from yt_dlp.utils import clean_html as ytdlp_clean_html
from ...libs.api.types import AiringSchedule, MediaItem
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
def clean_html(raw_html: str) -> str:
"""A wrapper around yt-dlp's clean_html to handle None inputs."""
return ytdlp_clean_html(raw_html) if raw_html else ""
def format_number_with_commas(number: Optional[int]) -> str:
"""Formats an integer with commas for thousands separation."""
if number is None:
return "N/A"
return COMMA_REGEX.sub(r"\1,", str(number)[::-1])[::-1]
def format_airing_schedule(airing: Optional[AiringSchedule]) -> str:
"""Formats the next airing episode information into a readable string."""
if not airing or not airing.airing_at:
return "N/A"
# Get a human-readable date and time
air_date = airing.airing_at.strftime("%a, %b %d at %I:%M %p")
return f"Ep {airing.episode} on {air_date}"
def format_genres(genres: List[str]) -> str:
"""Joins a list of genres into a single, comma-separated string."""
return ", ".join(genres) if genres else "N/A"
def format_score_stars_full(score: Optional[float]) -> str:
"""Formats an AniList score (0-100) to a 0-10 scale using full stars."""
if score is None:
return "N/A"
# Convert 0-100 to 0-10, then to a whole number of stars
num_stars = min(round(score * 6 / 100), 6)
return "" * num_stars
def format_score(score: Optional[float]) -> str:
"""Formats an AniList score (0-100) to a 0-10 scale."""
if score is None:
return "N/A"
return f"{score / 10.0:.1f} / 10"
def shell_safe(text: Optional[str]) -> str:
"""
Escapes a string for safe inclusion in a shell script,
specifically for use within double quotes. It escapes backticks,
double quotes, and dollar signs.
"""
if not text:
return ""
return text.replace("`", "\\`").replace('"', '\\"').replace("$", "\\$")

View File

@@ -0,0 +1,87 @@
# fastanime/cli/utils/image.py
from __future__ import annotations
import logging
import shutil
import subprocess
from typing import Optional
import click
import httpx
logger = logging.getLogger(__name__)
def render_image(url: str, capture: bool = False, size: str = "30x30") -> Optional[str]:
"""
Renders an image from a URL in the terminal using icat or chafa.
This function automatically detects the best available tool.
Args:
url: The URL of the image to render.
capture: If True, returns the terminal-formatted image as a string
instead of printing it. Defaults to False.
size: The size parameter to pass to the rendering tool (e.g., "WxH").
Returns:
If capture is True, returns the image data as a string.
If capture is False, prints directly to the terminal and returns None.
Returns None on any failure.
"""
# --- Common subprocess arguments ---
subprocess_kwargs = {
"check": False, # We will handle errors manually
"capture_output": capture,
"text": capture, # Decode stdout/stderr as text if capturing
}
# --- Try icat (Kitty terminal) first ---
if icat_executable := shutil.which("icat"):
process = subprocess.run(
[icat_executable, "--align", "left", url], **subprocess_kwargs
)
if process.returncode == 0:
return process.stdout if capture else None
logger.warning(f"icat failed for URL {url} with code {process.returncode}")
# --- Fallback to chafa ---
if chafa_executable := shutil.which("chafa"):
try:
# Chafa requires downloading the image data first
with httpx.Client() as client:
response = client.get(url, follow_redirects=True, timeout=20)
response.raise_for_status()
img_bytes = response.content
# Add stdin input to the subprocess arguments
subprocess_kwargs["input"] = img_bytes
process = subprocess.run(
[chafa_executable, f"--size={size}", "-"], **subprocess_kwargs
)
if process.returncode == 0:
return process.stdout if capture else None
logger.warning(f"chafa failed for URL {url} with code {process.returncode}")
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error fetching image for chafa: {e.response.status_code}"
)
click.echo(
f"[dim]Error fetching image: {e.response.status_code}[/dim]", err=True
)
except Exception as e:
logger.error(f"An exception occurred while running chafa: {e}")
return None
# --- Final fallback if no tool is found ---
if not capture:
# Only show this message if the user expected to see something.
click.echo(
"[dim](Image preview skipped: icat or chafa not found)[/dim]", err=True
)
return None

View File

@@ -1,11 +1,11 @@
import concurrent.futures
import logging
import textwrap
import os
import shutil
from hashlib import sha256
from io import StringIO
from pathlib import Path
from threading import Thread
from typing import TYPE_CHECKING, List
from typing import List
import httpx
from rich.console import Console
@@ -13,11 +13,9 @@ from rich.panel import Panel
from rich.text import Text
from ...core.config import AppConfig
from ...core.constants import APP_DIR, PLATFORM
from .scripts import bash_functions
if TYPE_CHECKING:
from ...libs.api.types import MediaItem
from ...core.constants import APP_CACHE_DIR, APP_DIR, PLATFORM
from ...libs.api.types import MediaItem
from . import ansi, formatters
logger = logging.getLogger(__name__)
@@ -27,15 +25,7 @@ IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images"
INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info"
FZF_SCRIPTS_DIR = APP_DIR / "libs" / "selectors" / "fzf" / "scripts"
PREVIEW_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "preview.sh"
# Ensure cache directories exist on startup
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# The helper functions (_get_cache_hash, _save_image_from_url, _save_info_text,
# _format_info_text, and _cache_worker) remain exactly the same as before.
# I am including them here for completeness.
INFO_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "info.sh"
def _get_cache_hash(text: str) -> str:
@@ -45,9 +35,9 @@ def _get_cache_hash(text: str) -> str:
def _save_image_from_url(url: str, hash_id: str):
"""Downloads an image using httpx and saves it to the cache."""
temp_image_path = IMAGES_CACHE_DIR / f"{hash_id}.png.tmp"
image_path = IMAGES_CACHE_DIR / f"{hash_id}.png"
try:
temp_image_path = IMAGES_CACHE_DIR / f"{hash_id}.png.tmp"
image_path = IMAGES_CACHE_DIR / f"{hash_id}.png"
with httpx.stream("GET", url, follow_redirects=True, timeout=20) as response:
response.raise_for_status()
with temp_image_path.open("wb") as f:
@@ -69,25 +59,40 @@ def _save_info_text(info_text: str, hash_id: str):
logger.error(f"Failed to write info cache for {hash_id}: {e}")
def _format_info_text(item: MediaItem) -> str:
"""Uses Rich to format a media item's details into a string."""
from .anilist import anilist_data_helper
def _populate_info_template(item: MediaItem, config: AppConfig) -> str:
"""
Takes the info.sh template and injects formatted, shell-safe data.
"""
template = INFO_SCRIPT_TEMPLATE_PATH.read_text(encoding="utf-8")
description = formatters.clean_html(item.description or "No description available.")
io_buffer = StringIO()
console = Console(file=io_buffer, force_terminal=True, color_system="truecolor")
title = Text(
item.title.english or item.title.romaji or "Unknown Title", style="bold cyan"
)
description = anilist_data_helper.clean_html(
item.description or "No description available."
)
description = (description[:350] + "...") if len(description) > 350 else description
genres = f"[bold]Genres:[/bold] {', '.join(item.genres)}"
status = f"[bold]Status:[/bold] {item.status}"
score = f"[bold]Score:[/bold] {item.average_score / 10 if item.average_score else 'N/A'}"
panel_content = f"{genres}\n{status}\n{score}\n\n{description}"
console.print(Panel(panel_content, title=title, border_style="dim"))
return io_buffer.getvalue()
HEADER_COLOR = config.fzf.preview_header_color.split(",")
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
# Escape all variables before injecting them into the script
replacements = {
"TITLE": formatters.shell_safe(item.title.english or item.title.romaji),
"SCORE": formatters.shell_safe(
formatters.format_score_stars_full(item.average_score)
),
"STATUS": formatters.shell_safe(item.status),
"FAVOURITES": formatters.shell_safe(
formatters.format_number_with_commas(item.favourites)
),
"GENRES": formatters.shell_safe(formatters.format_genres(item.genres)),
"SYNOPSIS": formatters.shell_safe(description),
# Color codes
"C_TITLE": ansi.get_true_fg(HEADER_COLOR, bold=True),
"C_KEY": ansi.get_true_fg(HEADER_COLOR, bold=True),
"C_VALUE": ansi.get_true_fg(HEADER_COLOR, bold=True),
"C_RULE": ansi.get_true_fg(SEPARATOR_COLOR, bold=True),
"RESET": ansi.RESET,
}
for key, value in replacements.items():
template = template.replace(f"{{{key}}}", value)
return template
def _cache_worker(items: List[MediaItem], titles: List[str], config: AppConfig):
@@ -102,7 +107,7 @@ def _cache_worker(items: List[MediaItem], titles: List[str], config: AppConfig):
)
if config.general.preview in ("full", "text"):
if not (INFO_CACHE_DIR / hash_id).exists():
info_text = _format_info_text(item)
info_text = _populate_info_template(item, config)
executor.submit(_save_info_text, info_text, hash_id)
@@ -114,6 +119,10 @@ def get_anime_preview(
Starts a background task to cache preview data and returns the fzf preview command
by formatting a shell script template.
"""
# Ensure cache directories exist on startup
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# Start the non-blocking background Caching
Thread(target=_cache_worker, args=(items, titles, config), daemon=True).start()
@@ -130,15 +139,17 @@ def get_anime_preview(
path_sep = "\\" if PLATFORM == "win32" else "/"
# Format the template with the dynamic values
final_script = template.format(
bash_functions=bash_functions,
preview_mode=config.general.preview,
image_cache_path=str(IMAGES_CACHE_DIR),
info_cache_path=str(INFO_CACHE_DIR),
path_sep=path_sep,
final_script = (
template.replace("{preview_mode}", config.general.preview)
.replace("{image_cache_path}", str(IMAGES_CACHE_DIR))
.replace("{info_cache_path}", str(INFO_CACHE_DIR))
.replace("{path_sep}", path_sep)
.replace("{image_renderer}", config.general.image_renderer)
)
# )
# Return the command for fzf to execute. `sh -c` is used to run the script string.
# The -- "{}" ensures that the selected item is passed as the first argument ($1)
# to the script, even if it contains spaces or special characters.
return f'sh -c {final_script!r} -- "{{}}"'
os.environ["SHELL"] = "bash"
return final_script

View File

@@ -1,33 +0,0 @@
import shutil
import subprocess
import requests
def print_img(url: str):
"""helper function to print an image given its url
Args:
url: [TODO:description]
"""
if EXECUTABLE := shutil.which("icat"):
subprocess.run([EXECUTABLE, url], check=False)
else:
EXECUTABLE = shutil.which("chafa")
if EXECUTABLE is None:
print("chafanot found")
return
res = requests.get(url)
if res.status_code != 200:
print("Error fetching image")
return
img_bytes = res.content
"""
Change made in call to chafa. Chafa dev dropped ability
to pull from urls. Keeping old line here just in case.
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)
"""
subprocess.run([EXECUTABLE, "--size=15x15"], input=img_bytes, check=False)

View File

@@ -43,7 +43,7 @@ class AniListApi(BaseApiClient):
def search_media(self, params: ApiSearchParams) -> Optional[MediaSearchResult]:
variables = {k: v for k, v in params.__dict__.items() if v is not None}
variables["perPage"] = params.per_page
variables["perPage"] = self.config.per_page or params.per_page
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.SEARCH_MEDIA, variables
)
@@ -57,7 +57,7 @@ class AniListApi(BaseApiClient):
"userId": self.user_profile.id,
"status": params.status,
"page": params.page,
"perPage": params.per_page,
"perPage": self.config.per_page or params.per_page,
}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_USER_MEDIA_LIST, variables

View File

@@ -18,7 +18,7 @@ from .yt_mp4 import YtExtractor
AVAILABLE_SOURCES = {
"Sak": SakExtractor,
"S-mp4": Smp4Extractor,
"Luf-mp4": Lufmp4Extractor,
"Luf-Mp4": Lufmp4Extractor,
"Default": DefaultExtractor,
"Yt-mp4": YtExtractor,
"Kir": KirExtractor,

View File

@@ -1,6 +1,6 @@
from ...types import EpisodeStream, Server
from ..constants import API_BASE_URL
from ..types import AllAnimeEpisode, AllAnimeSource
from ..types import AllAnimeEpisode, AllAnimeEpisodeStreams, AllAnimeSource
from .base import BaseExtractor
@@ -19,12 +19,15 @@ class Lufmp4Extractor(BaseExtractor):
timeout=10,
)
response.raise_for_status()
streams = response.json()
streams: AllAnimeEpisodeStreams = response.json()
return Server(
name="gogoanime",
links=[
EpisodeStream(link=link, quality="1080") for link in streams["links"]
EpisodeStream(
link=stream["link"], quality="1080", format=stream["resolutionStr"]
)
for stream in streams["links"]
],
episode_title=episode["notes"],
headers={"Referer": f"https://{API_BASE_URL}/"},

View File

@@ -55,7 +55,7 @@ class Anime(BaseAnimeProviderModel):
class EpisodeStream(BaseAnimeProviderModel):
episode: str
# episode: str
link: str
title: str | None = None
quality: Literal["360", "480", "720", "1080"] = "720"

View File

@@ -0,0 +1,71 @@
#!/bin/sh
#
# FastAnime Preview Info Script Template
# This script formats and displays the textual information in the FZF preview pane.
# Some values are injected by python those with '{name}' syntax using .replace()
# --- Terminal Dimensions ---
WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
# --- Helper function for printing a key-value pair, aligning the value to the right ---
print_kv() {
local key="$1"
local value="$2"
local key_len=${#key}
local value_len=${#value}
local multiplier="${3:-1}"
# Correctly calculate padding by accounting for the key, the ": ", and the value.
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
# If the text is too long to fit, just add a single space for separation.
if [ "$padding_len" -lt 1 ]; then
padding_len=1
value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))")
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
else
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
fi
}
# --- Draw a rule across the screen ---
draw_rule() {
local rule
# Generate the line of '─' characters, removing the trailing newline `tr` adds.
rule=$(printf '%*s' "$WIDTH" | tr ' ' '─' | tr -d '\n')
# Print the rule with colors and a single, clean newline.
printf "{C_RULE}%s{RESET}\\n" "$rule"
}
draw_rule(){
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{C_RULE}─{RESET}"
((ll++))
done
echo
}
# --- Display Content ---
draw_rule
print_kv "Title" "{TITLE}"
draw_rule
# Key-Value Stats Section
score_multiplier=1
if ! [ "{SCORE}" = "N/A" ];then
score_multiplier=2
fi
print_kv "Score" "{SCORE}" $score_multiplier
print_kv "Status" "{STATUS}"
print_kv "Favourites" "{FAVOURITES}"
draw_rule
print_kv "Genres" "{GENRES}"
draw_rule
# Synopsis
echo "{SYNOPSIS}" | fold -s -w "$WIDTH"

View File

@@ -3,11 +3,11 @@
# FastAnime FZF Preview Script Template
#
# This script is a template. The placeholders in curly braces, like
# {placeholder}, are filled in by the Python application at runtime.
# placeholder, are filled in by the Python application at runtime.
# It is executed by `sh -c "..."` for each item fzf previews.
# The first argument ($1) is the item string from fzf (the sanitized title).
IMAGE_RENDERER="{image_renderer}"
generate_sha256() {
local input
@@ -37,11 +37,11 @@ fzf_preview() {
if [ "$dim" = x ]; then
dim=$(stty size </dev/tty | awk "{print \$2 \"x\" \$1}")
fi
if ! [ "$FASTANIME_IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
if ! [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
fi
if [ "$FASTANIME_IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
if [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
if command -v kitten >/dev/null 2>&1; then
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
elif command -v icat >/dev/null 2>&1; then
@@ -75,7 +75,7 @@ fzf_preview() {
fi
}
# Generate the same cache key that the Python worker uses
hash=$(_get_cache_hash "$1")
hash=$(generate_sha256 {})
# Display image if configured and the cached file exists
if [ "{preview_mode}" = "full" ] || [ "{preview_mode}" = "image" ]; then
@@ -87,12 +87,11 @@ if [ "{preview_mode}" = "full" ] || [ "{preview_mode}" = "image" ]; then
fi
echo # Add a newline for spacing
fi
# Display text info if configured and the cached file exists
if [ "{preview_mode}" = "full" ] || [ "{preview_mode}" = "text" ]; then
info_file="{info_cache_path}{path_sep}$hash"
if [ -f "$info_file" ]; then
cat "$info_file"
source "$info_file"
else
echo "📝 Loading details..."
fi

View File

@@ -3,6 +3,8 @@ import os
import shutil
import subprocess
from rich.prompt import Prompt
from ....core.config import FzfConfig
from ....core.exceptions import FastAnimeError
from ..base import BaseSelector
@@ -58,7 +60,9 @@ class FzfSelector(BaseSelector):
return result == "Yes"
def ask(self, prompt, *, default=None):
# Use FZF's --print-query to capture user input
# cleaner to use rich
return Prompt.ask(prompt, default=default)
# -- not going to be used --
commands = [
self.executable,
"--prompt",