mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
feat: implement episode range parsing and enhance search functionality with improved filtering options
This commit is contained in:
@@ -6,7 +6,7 @@ from . import examples
|
||||
commands = {
|
||||
# "trending": "trending.trending",
|
||||
# "recent": "recent.recent",
|
||||
# "search": "search.search",
|
||||
"search": "search.search",
|
||||
# "download": "download.download",
|
||||
# "downloads": "downloads.downloads",
|
||||
"auth": "auth.auth",
|
||||
|
||||
@@ -2,25 +2,56 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from fastanime.cli.utils.completion import anime_titles_shell_complete
|
||||
|
||||
from .data import (
|
||||
genres_available,
|
||||
media_formats_available,
|
||||
media_statuses_available,
|
||||
seasons_available,
|
||||
sorts_available,
|
||||
tags_available_list,
|
||||
years_available,
|
||||
from .....core.config import AppConfig
|
||||
from .....core.exceptions import FastAnimeError
|
||||
from .....libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaSeason,
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
MediaTag,
|
||||
MediaType,
|
||||
MediaYear,
|
||||
)
|
||||
from ....utils.completion import anime_titles_shell_complete
|
||||
from .. import examples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
from typing import TypedDict
|
||||
from typing_extensions import Unpack
|
||||
|
||||
class SearchOptions(TypedDict, total=False):
|
||||
title: str | None
|
||||
dump_json: bool
|
||||
page: int
|
||||
per_page: int | None
|
||||
season: str | None
|
||||
status: tuple[str, ...]
|
||||
status_not: tuple[str, ...]
|
||||
sort: str | None
|
||||
genres: tuple[str, ...]
|
||||
genres_not: tuple[str, ...]
|
||||
tags: tuple[str, ...]
|
||||
tags_not: tuple[str, ...]
|
||||
media_format: tuple[str, ...]
|
||||
media_type: str | None
|
||||
year: str | None
|
||||
popularity_greater: int | None
|
||||
popularity_lesser: int | None
|
||||
score_greater: int | None
|
||||
score_lesser: int | None
|
||||
start_date_greater: int | None
|
||||
start_date_lesser: int | None
|
||||
end_date_greater: int | None
|
||||
end_date_lesser: int | None
|
||||
on_list: bool | None
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime using anilists api and get top ~50 results",
|
||||
short_help="Search for anime",
|
||||
epilog=examples.search,
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
@@ -29,51 +60,126 @@ if TYPE_CHECKING:
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.option(
|
||||
"--page",
|
||||
"-p",
|
||||
type=click.IntRange(min=1),
|
||||
default=1,
|
||||
help="Page number for pagination",
|
||||
)
|
||||
@click.option(
|
||||
"--per-page",
|
||||
type=click.IntRange(min=1, max=50),
|
||||
help="Number of results per page (max 50)",
|
||||
)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(seasons_available),
|
||||
type=click.Choice([season.value for season in MediaSeason]),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(media_statuses_available),
|
||||
type=click.Choice([status.value for status in MediaStatus]),
|
||||
)
|
||||
@click.option(
|
||||
"--status-not",
|
||||
help="Exclude media with these statuses",
|
||||
multiple=True,
|
||||
type=click.Choice([status.value for status in MediaStatus]),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(sorts_available),
|
||||
type=click.Choice([sort.value for sort in MediaSort]),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(genres_available),
|
||||
type=click.Choice([genre.value for genre in MediaGenre]),
|
||||
)
|
||||
@click.option(
|
||||
"--genres-not",
|
||||
multiple=True,
|
||||
help="Exclude these genres",
|
||||
type=click.Choice([genre.value for genre in MediaGenre]),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
type=click.Choice([tag.value for tag in MediaTag]),
|
||||
)
|
||||
@click.option(
|
||||
"--tags-not",
|
||||
multiple=True,
|
||||
help="Exclude these tags",
|
||||
type=click.Choice([tag.value for tag in MediaTag]),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(media_formats_available),
|
||||
type=click.Choice([format.value for format in MediaFormat]),
|
||||
)
|
||||
@click.option(
|
||||
"--media-type",
|
||||
help="Media type (ANIME or MANGA)",
|
||||
type=click.Choice([media_type.value for media_type in MediaType]),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(years_available),
|
||||
type=click.Choice([year.value for year in MediaYear]),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--popularity-greater",
|
||||
type=click.IntRange(min=0),
|
||||
help="Minimum popularity score",
|
||||
)
|
||||
@click.option(
|
||||
"--popularity-lesser",
|
||||
type=click.IntRange(min=0),
|
||||
help="Maximum popularity score",
|
||||
)
|
||||
@click.option(
|
||||
"--score-greater",
|
||||
type=click.IntRange(min=0, max=100),
|
||||
help="Minimum average score (0-100)",
|
||||
)
|
||||
@click.option(
|
||||
"--score-lesser",
|
||||
type=click.IntRange(min=0, max=100),
|
||||
help="Maximum average score (0-100)",
|
||||
)
|
||||
@click.option(
|
||||
"--start-date-greater",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Minimum start date (YYYYMMDD format, e.g., 20240101)",
|
||||
)
|
||||
@click.option(
|
||||
"--start-date-lesser",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Maximum start date (YYYYMMDD format, e.g., 20241231)",
|
||||
)
|
||||
@click.option(
|
||||
"--end-date-greater",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Minimum end date (YYYYMMDD format, e.g., 20240101)",
|
||||
)
|
||||
@click.option(
|
||||
"--end-date-lesser",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Maximum end date (YYYYMMDD format, e.g., 20241231)",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
@@ -81,45 +187,84 @@ if TYPE_CHECKING:
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(
|
||||
config: "AppConfig",
|
||||
title: str,
|
||||
dump_json: bool,
|
||||
season: str,
|
||||
status: tuple,
|
||||
sort: str,
|
||||
genres: tuple,
|
||||
tags: tuple,
|
||||
media_format: tuple,
|
||||
year: str,
|
||||
on_list: bool,
|
||||
):
|
||||
def search(config: AppConfig, **options: "Unpack[SearchOptions]"):
|
||||
import json
|
||||
|
||||
from rich.progress import Progress
|
||||
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.core.exceptions import FastAnimeError
|
||||
from fastanime.libs.media_api.api import create_api_client
|
||||
from fastanime.libs.media_api.params import MediaSearchParams
|
||||
from .....libs.media_api.api import create_api_client
|
||||
from .....libs.media_api.params import MediaSearchParams
|
||||
from ....service.feedback import FeedbackService
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
feedback = FeedbackService(config.general.icons)
|
||||
|
||||
try:
|
||||
# Create API client
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
# Extract options
|
||||
title = options.get("title")
|
||||
dump_json = options.get("dump_json", False)
|
||||
page = options.get("page", 1)
|
||||
per_page = options.get("per_page") or config.anilist.per_page or 50
|
||||
season = options.get("season")
|
||||
status = options.get("status", ())
|
||||
status_not = options.get("status_not", ())
|
||||
sort = options.get("sort")
|
||||
genres = options.get("genres", ())
|
||||
genres_not = options.get("genres_not", ())
|
||||
tags = options.get("tags", ())
|
||||
tags_not = options.get("tags_not", ())
|
||||
media_format = options.get("media_format", ())
|
||||
media_type = options.get("media_type")
|
||||
year = options.get("year")
|
||||
popularity_greater = options.get("popularity_greater")
|
||||
popularity_lesser = options.get("popularity_lesser")
|
||||
score_greater = options.get("score_greater")
|
||||
score_lesser = options.get("score_lesser")
|
||||
start_date_greater = options.get("start_date_greater")
|
||||
start_date_lesser = options.get("start_date_lesser")
|
||||
end_date_greater = options.get("end_date_greater")
|
||||
end_date_lesser = options.get("end_date_lesser")
|
||||
on_list = options.get("on_list")
|
||||
|
||||
# Validate logical relationships
|
||||
if score_greater is not None and score_lesser is not None and score_greater > score_lesser:
|
||||
raise FastAnimeError("Minimum score cannot be higher than maximum score")
|
||||
|
||||
if popularity_greater is not None and popularity_lesser is not None and popularity_greater > popularity_lesser:
|
||||
raise FastAnimeError("Minimum popularity cannot be higher than maximum popularity")
|
||||
|
||||
if start_date_greater is not None and start_date_lesser is not None and start_date_greater > start_date_lesser:
|
||||
raise FastAnimeError("Start date greater cannot be later than start date lesser")
|
||||
|
||||
if end_date_greater is not None and end_date_lesser is not None and end_date_greater > end_date_lesser:
|
||||
raise FastAnimeError("End date greater cannot be later than end date lesser")
|
||||
|
||||
# Build search parameters
|
||||
search_params = MediaSearchParams(
|
||||
query=title,
|
||||
per_page=config.anilist.per_page or 50,
|
||||
sort=[sort] if sort else None,
|
||||
status_in=list(status) if status else None,
|
||||
genre_in=list(genres) if genres else None,
|
||||
tag_in=list(tags) if tags else None,
|
||||
format_in=list(media_format) if media_format else None,
|
||||
season=season,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort=MediaSort(sort) if sort else None,
|
||||
status_in=[MediaStatus(s) for s in status] if status else None,
|
||||
status_not_in=[MediaStatus(s) for s in status_not] if status_not else None,
|
||||
genre_in=[MediaGenre(g) for g in genres] if genres else None,
|
||||
genre_not_in=[MediaGenre(g) for g in genres_not] if genres_not else None,
|
||||
tag_in=[MediaTag(t) for t in tags] if tags else None,
|
||||
tag_not_in=[MediaTag(t) for t in tags_not] if tags_not else None,
|
||||
format_in=[MediaFormat(f) for f in media_format] if media_format else None,
|
||||
type=MediaType(media_type) if media_type else None,
|
||||
season=MediaSeason(season) if season else None,
|
||||
seasonYear=int(year) if year else None,
|
||||
popularity_greater=popularity_greater,
|
||||
popularity_lesser=popularity_lesser,
|
||||
averageScore_greater=score_greater,
|
||||
averageScore_lesser=score_lesser,
|
||||
startDate_greater=start_date_greater,
|
||||
startDate_lesser=start_date_lesser,
|
||||
endDate_greater=end_date_greater,
|
||||
endDate_lesser=end_date_lesser,
|
||||
on_list=on_list,
|
||||
)
|
||||
|
||||
@@ -133,16 +278,30 @@ def search(
|
||||
|
||||
if dump_json:
|
||||
# Use Pydantic's built-in serialization
|
||||
print(json.dumps(search_result.model_dump(), indent=2))
|
||||
print(json.dumps(search_result.model_dump(mode="json")))
|
||||
else:
|
||||
# Launch interactive session for browsing results
|
||||
from fastanime.cli.interactive.session import session
|
||||
from ....interactive.session import session
|
||||
from ....interactive.state import MediaApiState, MenuName, State
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(search_result.media)} anime matching your search. Launching interactive mode..."
|
||||
)
|
||||
session.load_menus_from_folder()
|
||||
session.run(config)
|
||||
|
||||
# Create initial state with search results
|
||||
initial_state = State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in search_result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=search_result.page_info,
|
||||
),
|
||||
)
|
||||
|
||||
session.load_menus_from_folder("media")
|
||||
session.run(config, history=[initial_state])
|
||||
|
||||
except FastAnimeError as e:
|
||||
feedback.error("Search failed", str(e))
|
||||
|
||||
@@ -1,27 +1,108 @@
|
||||
search = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Basic search by title
|
||||
fastanime anilist search -t "Attack on Titan"
|
||||
\b
|
||||
# Search with multiple filters
|
||||
fastanime anilist search -g Action -T Isekai --score-greater 75 --status RELEASING
|
||||
\b
|
||||
# Get anime with the tag of isekai
|
||||
fastanime anilist search -T isekai
|
||||
\b
|
||||
# Get anime of 2024 and sort by popularity, finished or releasing, not in your list
|
||||
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# Get anime of 2024 season WINTER
|
||||
fastanime anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# Get anime genre action and tag isekai,magic
|
||||
fastanime anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# Get anime of 2024 thats finished airing
|
||||
fastanime anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# Get the most favourite anime movies
|
||||
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# Search with score and popularity filters
|
||||
fastanime anilist search --score-greater 80 --popularity-greater 50000
|
||||
\b
|
||||
# Search excluding certain genres and tags
|
||||
fastanime anilist search --genres-not Ecchi --tags-not "Hentai"
|
||||
\b
|
||||
# Search with date ranges (YYYYMMDD format)
|
||||
fastanime anilist search --start-date-greater 20200101 --start-date-lesser 20241231
|
||||
\b
|
||||
# Get only TV series, exclude certain statuses
|
||||
fastanime anilist search -f TV --status-not CANCELLED --status-not HIATUS
|
||||
\b
|
||||
# Paginated search with custom page size
|
||||
fastanime anilist search -g Action --page 2 --per-page 25
|
||||
\b
|
||||
# Search for manga specifically
|
||||
fastanime anilist search --media-type MANGA -g Fantasy
|
||||
\b
|
||||
# Complex search with multiple criteria
|
||||
fastanime anilist search -t "demon" -g Action -g Supernatural --score-greater 70 --year 2020 -s SCORE_DESC
|
||||
\b
|
||||
# Dump search results as JSON instead of interactive mode
|
||||
fastanime anilist search -g Action --dump-json
|
||||
"""
|
||||
|
||||
|
||||
main = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# ---- search ----
|
||||
\b
|
||||
# get anime with the tag of isekai
|
||||
# Basic search by title
|
||||
fastanime anilist search -t "Attack on Titan"
|
||||
\b
|
||||
# Search with multiple filters
|
||||
fastanime anilist search -g Action -T Isekai --score-greater 75 --status RELEASING
|
||||
\b
|
||||
# Get anime with the tag of isekai
|
||||
fastanime anilist search -T isekai
|
||||
\b
|
||||
# get anime of 2024 and sort by popularity
|
||||
# that has already finished airing or is releasing
|
||||
# and is not in your anime lists
|
||||
# Get anime of 2024 and sort by popularity, finished or releasing, not in your list
|
||||
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# get anime of 2024 season WINTER
|
||||
# Get anime of 2024 season WINTER
|
||||
fastanime anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# get anime genre action and tag isekai,magic
|
||||
# Get anime genre action and tag isekai,magic
|
||||
fastanime anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# get anime of 2024 thats finished airing
|
||||
# Get anime of 2024 thats finished airing
|
||||
fastanime anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# get the most favourite anime movies
|
||||
# Get the most favourite anime movies
|
||||
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# Search with score and popularity filters
|
||||
fastanime anilist search --score-greater 80 --popularity-greater 50000
|
||||
\b
|
||||
# Search excluding certain genres and tags
|
||||
fastanime anilist search --genres-not Ecchi --tags-not "Hentai"
|
||||
\b
|
||||
# Search with date ranges (YYYYMMDD format)
|
||||
fastanime anilist search --start-date-greater 20200101 --start-date-lesser 20241231
|
||||
\b
|
||||
# Get only TV series, exclude certain statuses
|
||||
fastanime anilist search -f TV --status-not CANCELLED --status-not HIATUS
|
||||
\b
|
||||
# Paginated search with custom page size
|
||||
fastanime anilist search -g Action --page 2 --per-page 25
|
||||
\b
|
||||
# Search for manga specifically
|
||||
fastanime anilist search --media-type MANGA -g Fantasy
|
||||
\b
|
||||
# Complex search with multiple criteria
|
||||
fastanime anilist search -t "demon" -g Action -g Supernatural --score-greater 70 --year 2020 -s SCORE_DESC
|
||||
\b
|
||||
# Dump search results as JSON instead of interactive mode
|
||||
fastanime anilist search -g Action --dump-json
|
||||
\b
|
||||
# ---- login ----
|
||||
\b
|
||||
|
||||
@@ -150,39 +150,26 @@ def download(config: AppConfig, **options: "Unpack[Options]"):
|
||||
|
||||
if not anime:
|
||||
raise FastAnimeError(f"Failed to fetch anime {anime_result.title}")
|
||||
episodes_range = []
|
||||
episodes: list[str] = sorted(
|
||||
|
||||
available_episodes: list[str] = sorted(
|
||||
getattr(anime.episodes, config.stream.translation_type), key=float
|
||||
)
|
||||
|
||||
if options["episode_range"]:
|
||||
if ":" in options["episode_range"]:
|
||||
ep_range_tuple = options["episode_range"].split(":")
|
||||
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
|
||||
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(options["episode_range"]) :]
|
||||
|
||||
episodes_range = iter(episodes_range)
|
||||
|
||||
for episode in episodes_range:
|
||||
download_anime(
|
||||
config, options, provider, selector, anime, anime_title, episode
|
||||
from ..utils.parser import parse_episode_range
|
||||
|
||||
try:
|
||||
episodes_range = parse_episode_range(
|
||||
options["episode_range"],
|
||||
available_episodes
|
||||
)
|
||||
|
||||
for episode in episodes_range:
|
||||
download_anime(
|
||||
config, options, provider, selector, anime, anime_title, episode
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
raise FastAnimeError(f"Invalid episode range: {e}") from e
|
||||
else:
|
||||
episode = selector.choose(
|
||||
"Select Episode",
|
||||
|
||||
@@ -89,37 +89,24 @@ def search(config: AppConfig, **options: "Unpack[Options]"):
|
||||
|
||||
if not anime:
|
||||
raise FastAnimeError(f"Failed to fetch anime {anime_result.title}")
|
||||
episodes_range = []
|
||||
episodes: list[str] = sorted(
|
||||
|
||||
available_episodes: list[str] = sorted(
|
||||
getattr(anime.episodes, config.stream.translation_type), key=float
|
||||
)
|
||||
|
||||
if options["episode_range"]:
|
||||
if ":" in options["episode_range"]:
|
||||
ep_range_tuple = options["episode_range"].split(":")
|
||||
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
|
||||
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(options["episode_range"]) :]
|
||||
|
||||
episodes_range = iter(episodes_range)
|
||||
|
||||
for episode in episodes_range:
|
||||
stream_anime(config, provider, selector, anime, episode, anime_title)
|
||||
from ..utils.parser import parse_episode_range
|
||||
|
||||
try:
|
||||
episodes_range = parse_episode_range(
|
||||
options["episode_range"],
|
||||
available_episodes
|
||||
)
|
||||
|
||||
for episode in episodes_range:
|
||||
stream_anime(config, provider, selector, anime, episode, anime_title)
|
||||
except (ValueError, IndexError) as e:
|
||||
raise FastAnimeError(f"Invalid episode range: {e}") from e
|
||||
else:
|
||||
episode = selector.choose(
|
||||
"Select Episode",
|
||||
|
||||
@@ -124,7 +124,9 @@ class Session:
|
||||
else:
|
||||
logger.warning("Failed to continue from history. No sessions found")
|
||||
|
||||
if not self._history:
|
||||
if history:
|
||||
self._history = history
|
||||
else:
|
||||
self._history.append(State(menu_name=MenuName.MAIN))
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""CLI utilities for FastAnime."""
|
||||
|
||||
from .parser import parse_episode_range
|
||||
|
||||
__all__ = ["parse_episode_range"]
|
||||
135
fastanime/cli/utils/parser.py
Normal file
135
fastanime/cli/utils/parser.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Episode range parsing utilities for FastAnime CLI commands."""
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
def parse_episode_range(
|
||||
episode_range_str: str | None,
|
||||
available_episodes: list[str]
|
||||
) -> Iterator[str]:
|
||||
"""
|
||||
Parse an episode range string and return an iterator of episode numbers.
|
||||
|
||||
This function handles various episode range formats:
|
||||
- Single episode: "5" -> episodes from index 5 onwards
|
||||
- Range with start and end: "5:10" -> episodes from index 5 to 10 (exclusive)
|
||||
- Range with step: "5:10:2" -> episodes from index 5 to 10 with step 2
|
||||
- Start only: "5:" -> episodes from index 5 onwards
|
||||
- End only: ":10" -> episodes from beginning to index 10
|
||||
- All episodes: ":" -> all episodes
|
||||
|
||||
Args:
|
||||
episode_range_str: The episode range string to parse (e.g., "5:10", "5:", ":10", "5")
|
||||
available_episodes: List of available episode numbers/identifiers
|
||||
|
||||
Returns:
|
||||
Iterator over the selected episode numbers
|
||||
|
||||
Raises:
|
||||
ValueError: If the episode range format is invalid
|
||||
IndexError: If the specified indices are out of range
|
||||
|
||||
Examples:
|
||||
>>> episodes = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
|
||||
>>> list(parse_episode_range("2:5", episodes))
|
||||
['3', '4', '5']
|
||||
>>> list(parse_episode_range("5:", episodes))
|
||||
['6', '7', '8', '9', '10']
|
||||
>>> list(parse_episode_range(":3", episodes))
|
||||
['1', '2', '3']
|
||||
>>> list(parse_episode_range("2:8:2", episodes))
|
||||
['3', '5', '7']
|
||||
"""
|
||||
if not episode_range_str:
|
||||
# No range specified, return all episodes
|
||||
return iter(available_episodes)
|
||||
|
||||
# Sort episodes numerically for consistent ordering
|
||||
episodes = sorted(available_episodes, key=float)
|
||||
|
||||
if ":" in episode_range_str:
|
||||
# Handle colon-separated ranges
|
||||
parts = episode_range_str.split(":")
|
||||
|
||||
if len(parts) == 3:
|
||||
# Format: start:end:step
|
||||
start_str, end_str, step_str = parts
|
||||
if not all([start_str, end_str, step_str]):
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"When using 3 parts (start:end:step), all parts must be non-empty."
|
||||
)
|
||||
|
||||
try:
|
||||
start_idx = int(start_str)
|
||||
end_idx = int(end_str)
|
||||
step = int(step_str)
|
||||
|
||||
if step <= 0:
|
||||
raise ValueError("Step value must be positive")
|
||||
|
||||
return iter(episodes[start_idx:end_idx:step])
|
||||
except ValueError as e:
|
||||
if "invalid literal" in str(e):
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"All parts must be valid integers."
|
||||
) from e
|
||||
raise
|
||||
|
||||
elif len(parts) == 2:
|
||||
# Format: start:end or start: or :end
|
||||
start_str, end_str = parts
|
||||
|
||||
if start_str and end_str:
|
||||
# Both start and end specified: start:end
|
||||
try:
|
||||
start_idx = int(start_str)
|
||||
end_idx = int(end_str)
|
||||
return iter(episodes[start_idx:end_idx])
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"Start and end must be valid integers."
|
||||
) from e
|
||||
|
||||
elif start_str and not end_str:
|
||||
# Only start specified: start:
|
||||
try:
|
||||
start_idx = int(start_str)
|
||||
return iter(episodes[start_idx:])
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"Start must be a valid integer."
|
||||
) from e
|
||||
|
||||
elif not start_str and end_str:
|
||||
# Only end specified: :end
|
||||
try:
|
||||
end_idx = int(end_str)
|
||||
return iter(episodes[:end_idx])
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"End must be a valid integer."
|
||||
) from e
|
||||
|
||||
else:
|
||||
# Both empty: ":"
|
||||
return iter(episodes)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"Too many colon separators."
|
||||
)
|
||||
else:
|
||||
# Single number: start from that index onwards
|
||||
try:
|
||||
start_idx = int(episode_range_str)
|
||||
return iter(episodes[start_idx:])
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid episode range format: '{episode_range_str}'. "
|
||||
"Must be a valid integer."
|
||||
) from e
|
||||
115
tests/test_parser.py
Normal file
115
tests/test_parser.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Tests for episode range parser."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastanime.cli.utils.parser import parse_episode_range
|
||||
|
||||
|
||||
class TestParseEpisodeRange:
|
||||
"""Test cases for the parse_episode_range function."""
|
||||
|
||||
@pytest.fixture
|
||||
def episodes(self):
|
||||
"""Sample episode list for testing."""
|
||||
return ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
|
||||
|
||||
def test_no_range_returns_all_episodes(self, episodes):
|
||||
"""Test that None or empty range returns all episodes."""
|
||||
result = list(parse_episode_range(None, episodes))
|
||||
assert result == episodes
|
||||
|
||||
def test_colon_only_returns_all_episodes(self, episodes):
|
||||
"""Test that ':' returns all episodes."""
|
||||
result = list(parse_episode_range(":", episodes))
|
||||
assert result == episodes
|
||||
|
||||
def test_start_end_range(self, episodes):
|
||||
"""Test start:end range format."""
|
||||
result = list(parse_episode_range("2:5", episodes))
|
||||
assert result == ["3", "4", "5"]
|
||||
|
||||
def test_start_only_range(self, episodes):
|
||||
"""Test start: range format."""
|
||||
result = list(parse_episode_range("5:", episodes))
|
||||
assert result == ["6", "7", "8", "9", "10"]
|
||||
|
||||
def test_end_only_range(self, episodes):
|
||||
"""Test :end range format."""
|
||||
result = list(parse_episode_range(":3", episodes))
|
||||
assert result == ["1", "2", "3"]
|
||||
|
||||
def test_start_end_step_range(self, episodes):
|
||||
"""Test start:end:step range format."""
|
||||
result = list(parse_episode_range("2:8:2", episodes))
|
||||
assert result == ["3", "5", "7"]
|
||||
|
||||
def test_single_number_range(self, episodes):
|
||||
"""Test single number format (start from index)."""
|
||||
result = list(parse_episode_range("5", episodes))
|
||||
assert result == ["6", "7", "8", "9", "10"]
|
||||
|
||||
def test_empty_start_end_in_three_part_range_raises_error(self, episodes):
|
||||
"""Test that empty parts in start:end:step format raise error."""
|
||||
with pytest.raises(ValueError, match="When using 3 parts"):
|
||||
list(parse_episode_range(":5:2", episodes))
|
||||
|
||||
with pytest.raises(ValueError, match="When using 3 parts"):
|
||||
list(parse_episode_range("2::2", episodes))
|
||||
|
||||
with pytest.raises(ValueError, match="When using 3 parts"):
|
||||
list(parse_episode_range("2:5:", episodes))
|
||||
|
||||
def test_invalid_integer_raises_error(self, episodes):
|
||||
"""Test that invalid integers raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Must be a valid integer"):
|
||||
list(parse_episode_range("abc", episodes))
|
||||
|
||||
with pytest.raises(ValueError, match="Start and end must be valid integers"):
|
||||
list(parse_episode_range("2:abc", episodes))
|
||||
|
||||
with pytest.raises(ValueError, match="All parts must be valid integers"):
|
||||
list(parse_episode_range("2:5:abc", episodes))
|
||||
|
||||
def test_zero_step_raises_error(self, episodes):
|
||||
"""Test that zero step raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Step value must be positive"):
|
||||
list(parse_episode_range("2:5:0", episodes))
|
||||
|
||||
def test_negative_step_raises_error(self, episodes):
|
||||
"""Test that negative step raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Step value must be positive"):
|
||||
list(parse_episode_range("2:5:-1", episodes))
|
||||
|
||||
def test_too_many_colons_raises_error(self, episodes):
|
||||
"""Test that too many colons raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Too many colon separators"):
|
||||
list(parse_episode_range("2:5:7:9", episodes))
|
||||
|
||||
def test_edge_case_empty_list(self):
|
||||
"""Test behavior with empty episode list."""
|
||||
result = list(parse_episode_range(":", []))
|
||||
assert result == []
|
||||
|
||||
def test_edge_case_single_episode(self):
|
||||
"""Test behavior with single episode."""
|
||||
episodes = ["1"]
|
||||
result = list(parse_episode_range(":", episodes))
|
||||
assert result == ["1"]
|
||||
|
||||
result = list(parse_episode_range("0:1", episodes))
|
||||
assert result == ["1"]
|
||||
|
||||
def test_numerical_sorting(self):
|
||||
"""Test that episodes are sorted numerically, not lexicographically."""
|
||||
episodes = ["10", "2", "1", "11", "3"]
|
||||
result = list(parse_episode_range(":", episodes))
|
||||
assert result == ["1", "2", "3", "10", "11"]
|
||||
|
||||
def test_index_out_of_bounds_behavior(self, episodes):
|
||||
"""Test behavior when indices exceed available episodes."""
|
||||
# Python slicing handles out-of-bounds gracefully
|
||||
result = list(parse_episode_range("15:", episodes))
|
||||
assert result == [] # No episodes beyond index 15
|
||||
|
||||
result = list(parse_episode_range(":20", episodes))
|
||||
assert result == episodes # All episodes (slice stops at end)
|
||||
Reference in New Issue
Block a user