feat: episode preview

This commit is contained in:
Benexl
2025-07-14 22:34:26 +03:00
parent f4c4c874df
commit c882691412
6 changed files with 205 additions and 5 deletions

View File

@@ -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":

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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,
)

View File

@@ -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

View 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"