mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-13 00:00:01 -08:00
feat: episode preview
This commit is contained in:
@@ -79,11 +79,16 @@ def episodes(ctx: Context, state: State) -> State | ControlFlow:
|
||||
if not chosen_episode:
|
||||
choices = [*sorted(available_episodes, key=float), "Back"]
|
||||
|
||||
# TODO: Implement FZF/Rofi preview for episode thumbnails if available
|
||||
# preview_command = get_episode_preview(...)
|
||||
# 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)
|
||||
|
||||
chosen_episode_str = ctx.selector.choose(
|
||||
prompt="Select Episode", choices=choices
|
||||
prompt="Select Episode",
|
||||
choices=choices,
|
||||
preview=preview_command
|
||||
)
|
||||
|
||||
if not chosen_episode_str or chosen_episode_str == "Back":
|
||||
|
||||
@@ -14,7 +14,7 @@ from rich.text import Text
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.constants import APP_CACHE_DIR, APP_DIR, PLATFORM
|
||||
from ...libs.api.types import MediaItem
|
||||
from ...libs.api.types import MediaItem, StreamingEpisode
|
||||
from . import ansi, formatters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +26,7 @@ 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"
|
||||
INFO_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "info.sh"
|
||||
EPISODE_INFO_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "episode_info.sh"
|
||||
|
||||
|
||||
def _get_cache_hash(text: str) -> str:
|
||||
@@ -153,3 +154,117 @@ def get_anime_preview(
|
||||
# to the script, even if it contains spaces or special characters.
|
||||
os.environ["SHELL"] = "bash"
|
||||
return final_script
|
||||
|
||||
# --- Episode Preview Functionality ---
|
||||
|
||||
def _populate_episode_info_template(episode_data: dict, config: AppConfig) -> str:
|
||||
"""
|
||||
Takes the episode_info.sh template and injects episode-specific formatted data.
|
||||
"""
|
||||
template = EPISODE_INFO_SCRIPT_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
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(episode_data.get("title", "Episode")),
|
||||
"SCORE": formatters.shell_safe("N/A"), # Episodes don't have scores
|
||||
"STATUS": formatters.shell_safe(episode_data.get("status", "Available")),
|
||||
"FAVOURITES": formatters.shell_safe("N/A"), # Episodes don't have favorites
|
||||
"GENRES": formatters.shell_safe(episode_data.get("duration", "Unknown duration")),
|
||||
"SYNOPSIS": formatters.shell_safe(episode_data.get("description", "No episode description available.")),
|
||||
# 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 _episode_cache_worker(episodes: List[str], anime: MediaItem, config: AppConfig):
|
||||
"""Background task that fetches and saves episode preview data."""
|
||||
streaming_episodes = {ep.title: ep for ep in anime.streaming_episodes}
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
for episode_str in episodes:
|
||||
hash_id = _get_cache_hash(episode_str)
|
||||
|
||||
# Find matching streaming episode
|
||||
episode_data = None
|
||||
for title, ep in streaming_episodes.items():
|
||||
if f"Episode {episode_str}" in title or title.endswith(f" {episode_str}"):
|
||||
episode_data = {
|
||||
"title": title,
|
||||
"thumbnail": ep.thumbnail,
|
||||
"description": f"Episode {episode_str} of {anime.title.english or anime.title.romaji}",
|
||||
"duration": f"{anime.duration} min" if anime.duration else "Unknown duration",
|
||||
"status": "Available"
|
||||
}
|
||||
break
|
||||
|
||||
# Fallback if no streaming episode found
|
||||
if not episode_data:
|
||||
episode_data = {
|
||||
"title": f"Episode {episode_str}",
|
||||
"thumbnail": None,
|
||||
"description": f"Episode {episode_str} of {anime.title.english or anime.title.romaji}",
|
||||
"duration": f"{anime.duration} min" if anime.duration else "Unknown duration",
|
||||
"status": "Available"
|
||||
}
|
||||
|
||||
# Download thumbnail if available
|
||||
if episode_data["thumbnail"]:
|
||||
executor.submit(_save_image_from_url, episode_data["thumbnail"], hash_id)
|
||||
|
||||
# Generate and save episode info
|
||||
episode_info = _populate_episode_info_template(episode_data, config)
|
||||
executor.submit(_save_info_text, episode_info, hash_id)
|
||||
|
||||
|
||||
def get_episode_preview(episodes: List[str], anime: MediaItem, config: AppConfig) -> str:
|
||||
"""
|
||||
Starts a background task to cache episode preview data and returns the fzf preview command.
|
||||
|
||||
Args:
|
||||
episodes: List of episode numbers as strings
|
||||
anime: MediaItem containing the anime data with streaming episodes
|
||||
config: Application configuration
|
||||
|
||||
Returns:
|
||||
FZF preview command string
|
||||
"""
|
||||
# Ensure cache directories exist
|
||||
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Start background caching for episodes
|
||||
Thread(target=_episode_cache_worker, args=(episodes, anime, config), daemon=True).start()
|
||||
|
||||
# Read the shell script template
|
||||
try:
|
||||
template = PREVIEW_SCRIPT_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Preview script template not found at {PREVIEW_SCRIPT_TEMPLATE_PATH}")
|
||||
return "echo 'Error: Preview script template not found.'"
|
||||
|
||||
# Prepare values to inject into the template
|
||||
path_sep = "\\" if PLATFORM == "win32" else "/"
|
||||
|
||||
# Format the template with the dynamic values
|
||||
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)
|
||||
)
|
||||
|
||||
os.environ["SHELL"] = "bash"
|
||||
return final_script
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..types import (
|
||||
MediaTitle,
|
||||
MediaTrailer,
|
||||
PageInfo,
|
||||
StreamingEpisode,
|
||||
Studio,
|
||||
UserListStatus,
|
||||
UserProfile,
|
||||
@@ -29,6 +30,7 @@ from .types import (
|
||||
AnilistPageInfo,
|
||||
AnilistStudioNodes,
|
||||
AnilistViewerData,
|
||||
StreamingEpisode as AnilistStreamingEpisode,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -101,6 +103,18 @@ def _to_generic_tags(anilist_tags: list[AnilistMediaTag]) -> List[MediaTag]:
|
||||
]
|
||||
|
||||
|
||||
def _to_generic_streaming_episodes(anilist_episodes: list[AnilistStreamingEpisode]) -> List[StreamingEpisode]:
|
||||
"""Maps a list of AniList streaming episodes to generic StreamingEpisode objects."""
|
||||
return [
|
||||
StreamingEpisode(
|
||||
title=episode["title"],
|
||||
thumbnail=episode.get("thumbnail")
|
||||
)
|
||||
for episode in anilist_episodes
|
||||
if episode.get("title")
|
||||
]
|
||||
|
||||
|
||||
def _to_generic_user_status(
|
||||
anilist_media: AnilistBaseMediaDataSchema,
|
||||
anilist_list_entry: Optional[AnilistMediaList],
|
||||
@@ -160,6 +174,7 @@ def _to_generic_media_item(
|
||||
popularity=data.get("popularity"),
|
||||
favourites=data.get("favourites"),
|
||||
next_airing=_to_generic_airing_schedule(data.get("nextAiringEpisode")),
|
||||
streaming_episodes=_to_generic_streaming_episodes(data.get("streamingEpisodes", [])),
|
||||
user_status=_to_generic_user_status(data, media_list),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..types import (
|
||||
MediaTag,
|
||||
MediaTitle,
|
||||
PageInfo,
|
||||
StreamingEpisode,
|
||||
Studio,
|
||||
UserListStatus,
|
||||
UserProfile,
|
||||
@@ -81,8 +82,10 @@ def _to_generic_media_item(data: dict) -> MediaItem:
|
||||
studios=[
|
||||
Studio(id=s["mal_id"], name=s["name"]) for s in data.get("studios", [])
|
||||
],
|
||||
# Jikan doesn't provide streaming episodes
|
||||
streaming_episodes=[],
|
||||
# Jikan doesn't provide user list status in its search results.
|
||||
user_list_status=None,
|
||||
user_status=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ class MediaTag:
|
||||
rank: Optional[int] = None # Percentage relevance from 0-100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamingEpisode:
|
||||
"""A generic representation of a streaming episode."""
|
||||
|
||||
title: str
|
||||
thumbnail: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserListStatus:
|
||||
"""Generic representation of a user's list status for a media item."""
|
||||
@@ -118,6 +126,9 @@ class MediaItem:
|
||||
|
||||
next_airing: Optional[AiringSchedule] = None
|
||||
|
||||
# streaming episodes
|
||||
streaming_episodes: List[StreamingEpisode] = field(default_factory=list)
|
||||
|
||||
# user related
|
||||
user_status: Optional[UserListStatus] = None
|
||||
|
||||
|
||||
51
fastanime/libs/selectors/fzf/scripts/episode_info.sh
Normal file
51
fastanime/libs/selectors/fzf/scripts/episode_info.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FastAnime Episode Preview Info Script Template
|
||||
# This script formats and displays episode information in the FZF preview pane.
|
||||
# Values are injected by python 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"
|
||||
}
|
||||
|
||||
# --- Display Episode Content ---
|
||||
draw_rule
|
||||
print_kv "Episode" "{TITLE}"
|
||||
draw_rule
|
||||
|
||||
# Episode-specific information
|
||||
print_kv "Duration" "{GENRES}"
|
||||
print_kv "Status" "{STATUS}"
|
||||
draw_rule
|
||||
|
||||
# Episode description/summary
|
||||
echo "{SYNOPSIS}" | fold -s -w "$WIDTH"
|
||||
Reference in New Issue
Block a user