diff --git a/fastanime/cli/commands/anilist/cmd.py b/fastanime/cli/commands/anilist/cmd.py index 05cf0b3..86f2dd0 100644 --- a/fastanime/cli/commands/anilist/cmd.py +++ b/fastanime/cli/commands/anilist/cmd.py @@ -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", diff --git a/fastanime/cli/commands/anilist/commands/search.py b/fastanime/cli/commands/anilist/commands/search.py index 6b8e57b..17bf74d 100644 --- a/fastanime/cli/commands/anilist/commands/search.py +++ b/fastanime/cli/commands/anilist/commands/search.py @@ -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)) diff --git a/fastanime/cli/commands/anilist/examples.py b/fastanime/cli/commands/anilist/examples.py index b378a01..c23bebc 100644 --- a/fastanime/cli/commands/anilist/examples.py +++ b/fastanime/cli/commands/anilist/examples.py @@ -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 diff --git a/fastanime/cli/commands/download.py b/fastanime/cli/commands/download.py index e512ca3..b782c4e 100644 --- a/fastanime/cli/commands/download.py +++ b/fastanime/cli/commands/download.py @@ -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", diff --git a/fastanime/cli/commands/search.py b/fastanime/cli/commands/search.py index 06e93c7..522a0b0 100644 --- a/fastanime/cli/commands/search.py +++ b/fastanime/cli/commands/search.py @@ -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", diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index b219e54..a04ad0d 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -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: diff --git a/fastanime/cli/utils/__init__.py b/fastanime/cli/utils/__init__.py index e69de29..a4bb9b7 100644 --- a/fastanime/cli/utils/__init__.py +++ b/fastanime/cli/utils/__init__.py @@ -0,0 +1,5 @@ +"""CLI utilities for FastAnime.""" + +from .parser import parse_episode_range + +__all__ = ["parse_episode_range"] \ No newline at end of file diff --git a/fastanime/cli/utils/parser.py b/fastanime/cli/utils/parser.py new file mode 100644 index 0000000..5f0d628 --- /dev/null +++ b/fastanime/cli/utils/parser.py @@ -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 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..348236f --- /dev/null +++ b/tests/test_parser.py @@ -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)