refactor: reorganize assets

This commit is contained in:
Benexl
2025-07-24 13:38:31 +03:00
parent 9cafcde9e1
commit 48f46cdf3d
40 changed files with 457 additions and 523 deletions

View File

@@ -0,0 +1,6 @@
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝

View File

@@ -0,0 +1,33 @@
#!/bin/sh
#
# Episode Preview Info Script Template
# This script formats and displays episode information in the FZF preview pane.
# Some values are injected by python those with '{name}' syntax using .replace()
draw_rule
echo "{TITLE}"| fold -s -w "$WIDTH"
draw_rule
print_kv "Duration" "{DURATION}"
print_kv "Status" "{STATUS}"
draw_rule
print_kv "Total Episodes" "{EPISODES}"
print_kv "Next Episode" "{NEXT_EPISODE}"
draw_rule
print_kv "Progress" "{USER_PROGRESS}"
print_kv "List Status" "{USER_STATUS}"
draw_rule
print_kv "Start Date" "{START_DATE}"
print_kv "End Date" "{END_DATE}"
draw_rule

View File

@@ -0,0 +1,55 @@
#!/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()
draw_rule
print_kv "Title" "{TITLE}"
draw_rule
# Emojis take up double the space
score_multiplier=1
if ! [ "{SCORE}" = "N/A" ];then
score_multiplier=2
fi
print_kv "Score" "{SCORE}" $score_multiplier
print_kv "Favourites" "{FAVOURITES}"
print_kv "Popularity" "{POPULARITY}"
print_kv "Status" "{STATUS}"
draw_rule
print_kv "Episodes" "{EPISODES}"
print_kv "Next Episode" "{NEXT_EPISODE}"
print_kv "Duration" "{DURATION}"
draw_rule
print_kv "Genres" "{GENRES}"
print_kv "Format" "{FORMAT}"
draw_rule
print_kv "List Status" "{USER_STATUS}"
print_kv "Progress" "{USER_PROGRESS}"
draw_rule
print_kv "Start Date" "{START_DATE}"
print_kv "End Date" "{END_DATE}"
draw_rule
print_kv "Studios" "{STUDIOS}"
print_kv "Synonymns" "{SYNONYMNS}"
print_kv "Tags" "{TAGS}"
draw_rule
# Synopsis
echo "{SYNOPSIS}" | fold -s -w "$WIDTH"

View File

@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# #
# FastAnime FZF Preview Script Template # FZF Preview Script Template
# #
# This script is a template. The placeholders in curly braces, like # This script is a template. The placeholders in curly braces, like {NAME}
# placeholder, are filled in by the Python application at runtime. # are dynamically filled by python using .replace()
# It is executed by `sh -c "..."` for each item fzf previews.
# The first argument ($1) is the item string from fzf (the sanitized title). WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
IMAGE_RENDERER="{IMAGE_RENDERER}"
IMAGE_RENDERER="{image_renderer}"
generate_sha256() { generate_sha256() {
local input local input
@@ -30,6 +30,7 @@ generate_sha256() {
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n' echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
fi fi
} }
fzf_preview() { fzf_preview() {
file=$1 file=$1
@@ -74,13 +75,59 @@ fzf_preview() {
echo either icat for kitty terminal and wezterm or imgcat or chafa echo either icat for kitty terminal and wezterm or imgcat or chafa
fi fi
} }
# --- 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 ---
# TODO: figure out why this method does not work in fzf
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
}
# Generate the same cache key that the Python worker uses # Generate the same cache key that the Python worker uses
# {PREFIX} is used only on episode previews to make sure they are unique
title={} title={}
hash=$(generate_sha256 "{PREFIX}$title") hash=$(generate_sha256 "{PREFIX}$title")
# Display image if configured and the cached file exists #
if [ "{preview_mode}" = "full" ] || [ "{preview_mode}" = "image" ]; then # --- Display image if configured and the cached file exists ---
image_file="{image_cache_path}{path_sep}$hash.png" #
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then
image_file="{IMAGE_CACHE_PATH}{PATH_SEP}$hash.png"
if [ -f "$image_file" ]; then if [ -f "$image_file" ]; then
fzf_preview "$image_file" fzf_preview "$image_file"
else else
@@ -89,8 +136,8 @@ if [ "{preview_mode}" = "full" ] || [ "{preview_mode}" = "image" ]; then
echo # Add a newline for spacing echo # Add a newline for spacing
fi fi
# Display text info if configured and the cached file exists # Display text info if configured and the cached file exists
if [ "{preview_mode}" = "full" ] || [ "{preview_mode}" = "text" ]; then if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
info_file="{info_cache_path}{path_sep}$hash" info_file="{INFO_CACHE_PATH}{PATH_SEP}$hash"
if [ -f "$info_file" ]; then if [ -f "$info_file" ]; then
source "$info_file" source "$info_file"
else else

View File

@@ -5,7 +5,9 @@ from ...core.config import AppConfig
from ...core.constants import APP_ASCII_ART, DISCORD_INVITE, PROJECT_NAME, REPO_HOME from ...core.constants import APP_ASCII_ART, DISCORD_INVITE, PROJECT_NAME, REPO_HOME
# The header for the config file. # The header for the config file.
config_asci = "\n".join([f"# {line}" for line in APP_ASCII_ART.split()]) config_asci = "\n".join(
[f"# {line}" for line in APP_ASCII_ART.read_text(encoding="utf-8").split()]
)
CONFIG_HEADER = f""" CONFIG_HEADER = f"""
# ============================================================================== # ==============================================================================
# #

View File

@@ -24,7 +24,7 @@ class FeedbackService:
console.print(f"{main_msg}\n[dim]{details}[/dim]") console.print(f"{main_msg}\n[dim]{details}[/dim]")
else: else:
console.print(main_msg) console.print(main_msg)
time.sleep(5) # time.sleep(5)
def error(self, message: str, details: Optional[str] = None) -> None: def error(self, message: str, details: Optional[str] = None) -> None:
"""Show an error message with optional details.""" """Show an error message with optional details."""
@@ -57,7 +57,7 @@ class FeedbackService:
console.print(f"{main_msg}\n[dim]{details}[/dim]") console.print(f"{main_msg}\n[dim]{details}[/dim]")
else: else:
console.print(main_msg) console.print(main_msg)
time.sleep(5) # time.sleep(5)
@contextmanager @contextmanager
def progress( def progress(

View File

@@ -2,27 +2,280 @@ import concurrent.futures
import logging import logging
import os import os
from hashlib import sha256 from hashlib import sha256
from pathlib import Path
from threading import Thread from threading import Thread
from typing import List from typing import List
import httpx import httpx
from ...core.config import AppConfig from ...core.config import AppConfig
from ...core.constants import APP_CACHE_DIR, APP_DIR, PLATFORM from ...core.constants import APP_CACHE_DIR, PLATFORM, SCRIPTS_DIR
from ...core.utils.file import AtomicWriter from ...core.utils.file import AtomicWriter
from ...libs.api.types import MediaItem from ...libs.api.types import MediaItem
from . import ansi, formatters from . import ansi, formatters
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- Constants for Paths --- os.environ["SHELL"] = "bash"
PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews" PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews"
IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images" IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images"
INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info" 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" FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
INFO_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "info.sh" TEMPLATE_PREVIEW_SCRIPT = Path(str(FZF_SCRIPTS_DIR / "preview.template.sh")).read_text(
EPISODE_INFO_SCRIPT_TEMPLATE_PATH = FZF_SCRIPTS_DIR / "episode_info.sh" encoding="utf-8"
)
TEMPLATE_INFO_SCRIPT = Path(str(FZF_SCRIPTS_DIR / "info.template.sh")).read_text(
encoding="utf-8"
)
TEMPLATE_EPISODE_INFO_SCRIPT = Path(
str(FZF_SCRIPTS_DIR / "episode-info.template.sh")
).read_text(encoding="utf-8")
def get_anime_preview(
items: List[MediaItem], titles: List[str], config: AppConfig
) -> str:
# Ensure cache directories exist on startup
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
HEADER_COLOR = config.fzf.preview_header_color.split(",")
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
preview_script = TEMPLATE_PREVIEW_SCRIPT
# Start the non-blocking background Caching
Thread(target=_cache_worker, args=(items, titles, config), daemon=True).start()
# Prepare values to inject into the template
path_sep = "\\" if PLATFORM == "win32" else "/"
# Format the template with the dynamic values
replacements = {
"PREVIEW_MODE": config.general.preview,
"IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR),
"INFO_CACHE_PATH": str(INFO_CACHE_DIR),
"PATH_SEP": path_sep,
"IMAGE_RENDERER": config.general.image_renderer,
# 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,
"PREFIX": "",
}
for key, value in replacements.items():
preview_script = preview_script.replace(f"{{{key}}}", value)
return preview_script
def _cache_worker(media_items: List[MediaItem], titles: List[str], config: AppConfig):
"""The background task that fetches and saves all necessary preview data."""
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
for media_item, title_str in zip(media_items, titles):
hash_id = _get_cache_hash(title_str)
if config.general.preview in ("full", "image") and media_item.cover_image:
if not (IMAGES_CACHE_DIR / f"{hash_id}.png").exists():
executor.submit(
_save_image_from_url, media_item.cover_image.large, hash_id
)
if config.general.preview in ("full", "text"):
# TODO: Come up with a better caching pattern for now just let it be remade
if not (INFO_CACHE_DIR / hash_id).exists() or True:
info_text = _populate_info_template(media_item, config)
executor.submit(_save_info_text, info_text, hash_id)
def _populate_info_template(media_item: MediaItem, config: AppConfig) -> str:
"""
Takes the info.sh template and injects formatted, shell-safe data.
"""
info_script = TEMPLATE_INFO_SCRIPT
description = formatters.clean_html(
media_item.description or "No description available."
)
# Escape all variables before injecting them into the script
replacements = {
"TITLE": formatters.shell_safe(
media_item.title.english or media_item.title.romaji
),
"STATUS": formatters.shell_safe(media_item.status.value),
"FORMAT": formatters.shell_safe(media_item.format.value),
"NEXT_EPISODE": formatters.shell_safe(
f"Episode {media_item.next_airing.episode} on {formatters.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}"
if media_item.next_airing
else "N/A"
),
"EPISODES": formatters.shell_safe(str(media_item.episodes)),
"DURATION": formatters.shell_safe(
formatters.format_media_duration(media_item.duration)
),
"SCORE": formatters.shell_safe(
formatters.format_score_stars_full(media_item.average_score)
),
"FAVOURITES": formatters.shell_safe(
formatters.format_number_with_commas(media_item.favourites)
),
"POPULARITY": formatters.shell_safe(
formatters.format_number_with_commas(media_item.popularity)
),
"GENRES": formatters.shell_safe(
formatters.format_list_with_commas([v.value for v in media_item.genres])
),
"TAGS": formatters.shell_safe(
formatters.format_list_with_commas([t.name.value for t in media_item.tags])
),
"STUDIOS": formatters.shell_safe(
formatters.format_list_with_commas(
[t.name for t in media_item.studios if t.name]
)
),
"SYNONYMNS": formatters.shell_safe(
formatters.format_list_with_commas(media_item.synonymns)
),
"USER_STATUS": formatters.shell_safe(
media_item.user_status.status.value
if media_item.user_status and media_item.user_status.status
else "NOT_ON_LIST"
),
"USER_PROGRESS": formatters.shell_safe(
f"Episode {media_item.user_status.progress}"
if media_item.user_status
else "0"
),
"START_DATE": formatters.shell_safe(
formatters.format_date(media_item.start_date)
),
"END_DATE": formatters.shell_safe(formatters.format_date(media_item.end_date)),
"SYNOPSIS": formatters.shell_safe(description),
}
for key, value in replacements.items():
info_script = info_script.replace(f"{{{key}}}", value)
return info_script
def get_episode_preview(
episodes: List[str], media_item: MediaItem, config: AppConfig
) -> str:
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
HEADER_COLOR = config.fzf.preview_header_color.split(",")
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
preview_script = TEMPLATE_PREVIEW_SCRIPT
# Start background caching for episodes
Thread(
target=_episode_cache_worker, args=(episodes, media_item, config), daemon=True
).start()
# Prepare values to inject into the template
path_sep = "\\" if PLATFORM == "win32" else "/"
# Format the template with the dynamic values
replacements = {
"PREVIEW_MODE": config.general.preview,
"IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR),
"INFO_CACHE_PATH": str(INFO_CACHE_DIR),
"PATH_SEP": path_sep,
"IMAGE_RENDERER": config.general.image_renderer,
# 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,
"PREFIX": f"{media_item.title.english}_Episode_",
}
for key, value in replacements.items():
preview_script = preview_script.replace(f"{{{key}}}", value)
return preview_script
def _episode_cache_worker(
episodes: List[str], media_item: MediaItem, config: AppConfig
):
"""Background task that fetches and saves episode preview data."""
streaming_episodes = {ep.title: ep for ep in media_item.streaming_episodes}
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
for episode_str in episodes:
hash_id = _get_cache_hash(
f"{media_item.title.english}_Episode_{episode_str}"
)
# Find matching streaming episode
title = None
thumbnail = None
for title, ep in streaming_episodes.items():
if f"Episode {episode_str} -" in title or title.endswith(
f" {episode_str}"
):
title = title
thumbnail = ep.thumbnail
break
# Fallback if no streaming episode found
if not title:
title = f"Episode {episode_str}"
# Download thumbnail if available
if thumbnail:
executor.submit(_save_image_from_url, thumbnail, hash_id)
# Generate and save episode info
episode_info = _populate_episode_info_template(config, title, media_item)
executor.submit(_save_info_text, episode_info, hash_id)
def _populate_episode_info_template(
config: AppConfig, title: str, media_item: MediaItem
) -> str:
"""
Takes the episode_info.sh template and injects episode-specific formatted data.
"""
episode_info_script = TEMPLATE_EPISODE_INFO_SCRIPT
replacements = {
"TITLE": formatters.shell_safe(title),
"NEXT_EPISODE": formatters.shell_safe(
f"Episode {media_item.next_airing.episode} on {formatters.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}"
if media_item.next_airing
else "N/A"
),
"DURATION": formatters.format_media_duration(media_item.duration),
"STATUS": formatters.shell_safe(media_item.status.value),
"EPISODES": formatters.shell_safe(str(media_item.episodes)),
"USER_STATUS": formatters.shell_safe(
media_item.user_status.status.value
if media_item.user_status and media_item.user_status.status
else "NOT_ON_LIST"
),
"USER_PROGRESS": formatters.shell_safe(
f"Episode {media_item.user_status.progress}"
if media_item.user_status
else "0"
),
"START_DATE": formatters.shell_safe(
formatters.format_date(media_item.start_date)
),
"END_DATE": formatters.shell_safe(formatters.format_date(media_item.end_date)),
}
for key, value in replacements.items():
episode_info_script = episode_info_script.replace(f"{{{key}}}", value)
return episode_info_script
def _get_cache_hash(text: str) -> str: def _get_cache_hash(text: str) -> str:
@@ -53,290 +306,3 @@ def _save_info_text(info_text: str, hash_id: str):
f.write(info_text) f.write(info_text)
except IOError as e: except IOError as e:
logger.error(f"Failed to write info cache for {hash_id}: {e}") logger.error(f"Failed to write info cache for {hash_id}: {e}")
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.")
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 = {
#
# plain text
#
"TITLE": formatters.shell_safe(item.title.english or item.title.romaji),
"STATUS": formatters.shell_safe(item.status.value),
"FORMAT": formatters.shell_safe(item.format.value),
#
# numerical
#
"NEXT_EPISODE": formatters.shell_safe(
f"Episode {item.next_airing.episode} on {formatters.format_date(item.next_airing.airing_at, '%A, %d %B %Y at %X)')}"
if item.next_airing
else "N/A"
),
"EPISODES": formatters.shell_safe(str(item.episodes)),
"DURATION": formatters.shell_safe(
formatters.format_media_duration(item.duration)
),
"SCORE": formatters.shell_safe(
formatters.format_score_stars_full(item.average_score)
),
"FAVOURITES": formatters.shell_safe(
formatters.format_number_with_commas(item.favourites)
),
"POPULARITY": formatters.shell_safe(
formatters.format_number_with_commas(item.popularity)
),
#
# list
#
"GENRES": formatters.shell_safe(
formatters.format_list_with_commas([v.value for v in item.genres])
),
"TAGS": formatters.shell_safe(
formatters.format_list_with_commas([t.name.value for t in item.tags])
),
"STUDIOS": formatters.shell_safe(
formatters.format_list_with_commas([t.name for t in item.studios if t.name])
),
"SYNONYMNS": formatters.shell_safe(
formatters.format_list_with_commas(item.synonymns)
),
#
# user
#
"USER_STATUS": formatters.shell_safe(
item.user_status.status.value
if item.user_status and item.user_status.status
else "NOT_ON_LIST"
),
"USER_PROGRESS": formatters.shell_safe(
f"Episode {item.user_status.progress}" if item.user_status else "0"
),
#
# dates
#
"START_DATE": formatters.shell_safe(formatters.format_date(item.start_date)),
"END_DATE": formatters.shell_safe(formatters.format_date(item.end_date)),
#
# big guy
#
"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):
"""The background task that fetches and saves all necessary preview data."""
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
for item, title_str in zip(items, titles):
hash_id = _get_cache_hash(title_str)
if config.general.preview in ("full", "image") and item.cover_image:
if not (IMAGES_CACHE_DIR / f"{hash_id}.png").exists():
executor.submit(
_save_image_from_url, item.cover_image.large, hash_id
)
if config.general.preview in ("full", "text"):
# TODO: Come up with a better caching pattern for now just let it be remade
if not (INFO_CACHE_DIR / hash_id).exists() or True:
info_text = _populate_info_template(item, config)
executor.submit(_save_info_text, info_text, hash_id)
def get_anime_preview(
items: List[MediaItem], titles: List[str], config: AppConfig
) -> str:
"""
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()
# Read the shell script template from the file system.
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)
.replace("{PREFIX}", "")
)
# )
# 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.
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(f"{anime.title.english}_Episode_{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
"""
# TODO: finish implementation of episode preview
# 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)
.replace("{PREFIX}", f"{anime.title.english}_Episode_")
)
os.environ["SHELL"] = "bash"
return final_script

View File

@@ -1,4 +1,4 @@
from ..constants import APP_DATA_DIR, APP_NAME, USER_VIDEOS_DIR from ..constants import APP_DATA_DIR, DEFAULTS_DIR, USER_VIDEOS_DIR
# GeneralConfig # GeneralConfig
GENERAL_PYGMENT_STYLE = "github-dark" GENERAL_PYGMENT_STYLE = "github-dark"
@@ -42,10 +42,18 @@ SERVICE_CLEANUP_COMPLETED_DAYS = 7
SERVICE_NOTIFICATION_ENABLED = True SERVICE_NOTIFICATION_ENABLED = True
# FzfConfig # FzfConfig
FZF_OPTS = DEFAULTS_DIR / "fzf-opts"
FZF_HEADER_COLOR = "95,135,175" FZF_HEADER_COLOR = "95,135,175"
FZF_PREVIEW_HEADER_COLOR = "215,0,95" FZF_PREVIEW_HEADER_COLOR = "215,0,95"
FZF_PREVIEW_SEPARATOR_COLOR = "208,208,208" FZF_PREVIEW_SEPARATOR_COLOR = "208,208,208"
# RofiConfig
_ROFI_THEMES_DIR = DEFAULTS_DIR / "rofi-themes"
ROFI_THEME_MAIN = _ROFI_THEMES_DIR / "main.rasi"
ROFI_THEME_INPUT = _ROFI_THEMES_DIR / "input.rasi"
ROFI_THEME_CONFIRM = _ROFI_THEMES_DIR / "confirm.rasi"
ROFI_THEME_PREVIEW = _ROFI_THEMES_DIR / "preview.rasi"
# MpvConfig # MpvConfig
MPV_ARGS = "" MPV_ARGS = ""
MPV_PRE_ARGS = "" MPV_PRE_ARGS = ""

View File

@@ -4,13 +4,6 @@ from typing import Literal
from pydantic import BaseModel, Field, PrivateAttr, computed_field from pydantic import BaseModel, Field, PrivateAttr, computed_field
from ...core.constants import (
FZF_DEFAULT_OPTS,
ROFI_THEME_CONFIRM,
ROFI_THEME_INPUT,
ROFI_THEME_MAIN,
ROFI_THEME_PREVIEW,
)
from ...libs.api.types import MediaSort, UserMediaListSort from ...libs.api.types import MediaSort, UserMediaListSort
from ...libs.providers.anime.types import ProviderName, ProviderServer from ...libs.providers.anime.types import ProviderName, ProviderServer
from ..constants import APP_ASCII_ART from ..constants import APP_ASCII_ART
@@ -198,11 +191,15 @@ class SessionsConfig(OtherConfig):
class FzfConfig(OtherConfig): class FzfConfig(OtherConfig):
"""Configuration specific to the FZF selector.""" """Configuration specific to the FZF selector."""
_opts: str = PrivateAttr(default=FZF_DEFAULT_OPTS.read_text(encoding="utf-8")) _opts: str = PrivateAttr(
default_factory=lambda: defaults.FZF_OPTS.read_text(encoding="utf-8")
)
header_color: str = Field( header_color: str = Field(
default=defaults.FZF_HEADER_COLOR, description=desc.FZF_HEADER_COLOR default=defaults.FZF_HEADER_COLOR, description=desc.FZF_HEADER_COLOR
) )
_header_ascii_art: str = PrivateAttr(default=APP_ASCII_ART) _header_ascii_art: str = PrivateAttr(
default_factory=lambda: APP_ASCII_ART.read_text(encoding="utf-8")
)
preview_header_color: str = Field( preview_header_color: str = Field(
default=defaults.FZF_PREVIEW_HEADER_COLOR, default=defaults.FZF_PREVIEW_HEADER_COLOR,
description=desc.FZF_PREVIEW_HEADER_COLOR, description=desc.FZF_PREVIEW_HEADER_COLOR,
@@ -239,19 +236,19 @@ class RofiConfig(OtherConfig):
"""Configuration specific to the Rofi selector.""" """Configuration specific to the Rofi selector."""
theme_main: Path = Field( theme_main: Path = Field(
default=Path(str(ROFI_THEME_MAIN)), default_factory=lambda: Path(str(defaults.ROFI_THEME_MAIN)),
description=desc.ROFI_THEME_MAIN, description=desc.ROFI_THEME_MAIN,
) )
theme_preview: Path = Field( theme_preview: Path = Field(
default=Path(str(ROFI_THEME_PREVIEW)), default_factory=lambda: Path(str(defaults.ROFI_THEME_PREVIEW)),
description=desc.ROFI_THEME_PREVIEW, description=desc.ROFI_THEME_PREVIEW,
) )
theme_confirm: Path = Field( theme_confirm: Path = Field(
default=Path(str(ROFI_THEME_CONFIRM)), default_factory=lambda: Path(str(defaults.ROFI_THEME_CONFIRM)),
description=desc.ROFI_THEME_CONFIRM, description=desc.ROFI_THEME_CONFIRM,
) )
theme_input: Path = Field( theme_input: Path = Field(
default=Path(str(ROFI_THEME_INPUT)), default_factory=lambda: Path(str(defaults.ROFI_THEME_INPUT)),
description=desc.ROFI_THEME_INPUT, description=desc.ROFI_THEME_INPUT,
) )

View File

@@ -4,8 +4,11 @@ from importlib import metadata, resources
from pathlib import Path from pathlib import Path
PLATFORM = sys.platform PLATFORM = sys.platform
APP_NAME = os.environ.get("FASTANIME_APP_NAME", "fastanime")
PROJECT_NAME = "FASTANIME" PROJECT_NAME = "FASTANIME"
APP_NAME = os.environ.get(f"{PROJECT_NAME}_APP_NAME", PROJECT_NAME.lower())
USER_NAME = os.environ.get("USERNAME", "User")
__version__ = metadata.version(PROJECT_NAME) __version__ = metadata.version(PROJECT_NAME)
@@ -13,7 +16,9 @@ AUTHOR = "Benexl"
GIT_REPO = "github.com" GIT_REPO = "github.com"
GIT_PROTOCOL = "https://" GIT_PROTOCOL = "https://"
REPO_HOME = f"https://{GIT_REPO}/{AUTHOR}/FastAnime" REPO_HOME = f"https://{GIT_REPO}/{AUTHOR}/FastAnime"
DISCORD_INVITE = "https://discord.gg/C4rhMA4mmK" DISCORD_INVITE = "https://discord.gg/C4rhMA4mmK"
ANILIST_AUTH = ( ANILIST_AUTH = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token" "https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
) )
@@ -27,20 +32,13 @@ except ModuleNotFoundError:
APP_DIR = Path(__file__).resolve().parent.parent APP_DIR = Path(__file__).resolve().parent.parent
ASSETS_DIR = APP_DIR / "assets" ASSETS_DIR = APP_DIR / "assets"
DEFAULTS = ASSETS_DIR / "defaults" DEFAULTS_DIR = ASSETS_DIR / "defaults"
SCRIPTS_DIR = ASSETS_DIR / "scripts"
GRAPHQL_DIR = ASSETS_DIR / "graphql"
ICONS_DIR = ASSETS_DIR / "icons" ICONS_DIR = ASSETS_DIR / "icons"
# rofi files ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Win32" else "logo.png")
_ROFI_THEMES_DIR = DEFAULTS / "rofi-themes" APP_ASCII_ART = DEFAULTS_DIR / "ascii-art"
ROFI_THEME_MAIN = _ROFI_THEMES_DIR / "main.rasi"
ROFI_THEME_INPUT = _ROFI_THEMES_DIR / "input.rasi"
ROFI_THEME_CONFIRM = _ROFI_THEMES_DIR / "confirm.rasi"
ROFI_THEME_PREVIEW = _ROFI_THEMES_DIR / "preview.rasi"
# fzf
FZF_DEFAULT_OPTS = DEFAULTS / "fzf-opts"
USER_NAME = os.environ.get("USERNAME", "Anime Fan")
try: try:
import click import click
@@ -83,15 +81,3 @@ USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
USER_CONFIG_PATH = APP_DATA_DIR / "config.ini" USER_CONFIG_PATH = APP_DATA_DIR / "config.ini"
LOG_FILE_PATH = APP_CACHE_DIR / "fastanime.log" LOG_FILE_PATH = APP_CACHE_DIR / "fastanime.log"
ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Win32" else "logo.png")
APP_ASCII_ART = """\
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
"""

View File

@@ -155,7 +155,7 @@ class AniListApi(BaseApiClient):
"type": params.type.value if params.type else "ANIME", "type": params.type.value if params.type else "ANIME",
} }
response = execute_graphql( response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_USER_MEDIA_LIST, variables ANILIST_ENDPOINT, self.http_client, gql.SEARCH_USER_MEDIA_LIST, variables
) )
return mapper.to_generic_user_list_result(response.json()) if response else None return mapper.to_generic_user_list_result(response.json()) if response else None

View File

@@ -1,27 +1,21 @@
from ....core.constants import APP_DIR from ....core.constants import GRAPHQL_DIR
_ANILIST_PATH = APP_DIR / "libs" / "api" / "anilist" _ANILIST_PATH = GRAPHQL_DIR / "anilist"
_QUERIES_PATH = _ANILIST_PATH / "queries" _QUERIES_PATH = _ANILIST_PATH / "queries"
_MUTATIONS_PATH = _ANILIST_PATH / "mutations" _MUTATIONS_PATH = _ANILIST_PATH / "mutations"
SEARCH_MEDIA = _QUERIES_PATH / "search.gql"
SEARCH_USER_MEDIA_LIST = _QUERIES_PATH / "media-list.gql"
GET_AIRING_SCHEDULE = _QUERIES_PATH / "airing.gql" GET_AIRING_SCHEDULE = _QUERIES_PATH / "airing.gql"
GET_ANIME_DETAILS = _QUERIES_PATH / "anime.gql"
GET_CHARACTERS = _QUERIES_PATH / "character.gql" GET_CHARACTERS = _QUERIES_PATH / "character.gql"
GET_FAVOURITES = _QUERIES_PATH / "favourite.gql"
GET_MEDIA_LIST_ITEM = _QUERIES_PATH / "get-medialist-item.gql" GET_MEDIA_LIST_ITEM = _QUERIES_PATH / "get-medialist-item.gql"
GET_LOGGED_IN_USER = _QUERIES_PATH / "logged-in-user.gql" GET_LOGGED_IN_USER = _QUERIES_PATH / "logged-in-user.gql"
GET_USER_MEDIA_LIST = _QUERIES_PATH / "media-list.gql"
GET_MEDIA_RELATIONS = _QUERIES_PATH / "media-relations.gql" GET_MEDIA_RELATIONS = _QUERIES_PATH / "media-relations.gql"
GET_NOTIFICATIONS = _QUERIES_PATH / "notifications.gql" GET_NOTIFICATIONS = _QUERIES_PATH / "notifications.gql"
GET_POPULAR = _QUERIES_PATH / "popular.gql"
GET_RECENTLY_UPDATED = _QUERIES_PATH / "recently-updated.gql"
GET_RECOMMENDATIONS = _QUERIES_PATH / "recommended.gql" GET_RECOMMENDATIONS = _QUERIES_PATH / "recommended.gql"
GET_REVIEWS = _QUERIES_PATH / "reviews.gql" GET_REVIEWS = _QUERIES_PATH / "reviews.gql"
GET_SCORES = _QUERIES_PATH / "score.gql"
SEARCH_MEDIA = _QUERIES_PATH / "search.gql"
GET_TRENDING = _QUERIES_PATH / "trending.gql"
GET_UPCOMING = _QUERIES_PATH / "upcoming.gql"
GET_USER_INFO = _QUERIES_PATH / "user-info.gql" GET_USER_INFO = _QUERIES_PATH / "user-info.gql"

View File

@@ -1,6 +1,6 @@
import re import re
from .....core.constants import APP_DIR from .....core.constants import GRAPHQL_DIR
SERVERS_AVAILABLE = [ SERVERS_AVAILABLE = [
"sharepoint", "sharepoint",
@@ -28,7 +28,7 @@ MP4_SERVER_JUICY_STREAM_REGEX = re.compile(
) )
# graphql files # graphql files
GQLS = APP_DIR / "libs" / "providers" / "anime" / "allanime" / "queries" _GQL_QUERIES = GRAPHQL_DIR / "allanime" / "queries"
SEARCH_GQL = GQLS / "search.gql" SEARCH_GQL = _GQL_QUERIES / "search.gql"
ANIME_GQL = GQLS / "anime.gql" ANIME_GQL = _GQL_QUERIES / "anime.gql"
EPISODE_GQL = GQLS / "episodes.gql" EPISODE_GQL = _GQL_QUERIES / "episodes.gql"

View File

@@ -1,62 +0,0 @@
#!/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"
}
draw_rule(){
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{C_RULE}─{RESET}"
((ll++))
done
echo
}
# --- Display Episode Content ---
draw_rule
echo "{TITLE}"| fold -s -w "$WIDTH"
draw_rule
# Episode-specific information
# print_kv "Duration" "{GENRES}"
# print_kv "Status" "{STATUS}"
# draw_rule
# Episode description/summary
# echo "{SYNOPSIS}" | fold -s -w "$WIDTH"

View File

@@ -1,98 +0,0 @@
#!/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 "Favourites" "{FAVOURITES}"
print_kv "Popularity" "{POPULARITY}"
print_kv "Status" "{STATUS}"
draw_rule
print_kv "Episodes" "{EPISODES}"
print_kv "Next Episode" "{NEXT_EPISODE}"
print_kv "Duration" "{DURATION}"
draw_rule
print_kv "Genres" "{GENRES}"
print_kv "Format" "{FORMAT}"
draw_rule
print_kv "List Status" "{USER_STATUS}"
print_kv "Progress" "{USER_PROGRESS}"
draw_rule
print_kv "Start Date" "{START_DATE}"
print_kv "End Date" "{END_DATE}"
draw_rule
print_kv "Studios" "{STUDIOS}"
print_kv "Synonymns" "{SYNONYMNS}"
print_kv "Tags" "{TAGS}"
draw_rule
# Synopsis
echo "{SYNOPSIS}" | fold -s -w "$WIDTH"