diff --git a/fastanime/cli/commands/anilist/commands/download.py b/fastanime/cli/commands/anilist/commands/download.py new file mode 100644 index 0000000..391b763 --- /dev/null +++ b/fastanime/cli/commands/anilist/commands/download.py @@ -0,0 +1,836 @@ +"""AniList download command using the modern download service.""" + +from typing import TYPE_CHECKING + +import click + +from .....core.config import AppConfig +from .....core.exceptions import FastAnimeError +from .....libs.media_api.api import create_api_client +from .....libs.media_api.params import MediaSearchParams +from .....libs.media_api.types import ( + MediaFormat, + MediaGenre, + MediaSeason, + MediaSort, + MediaStatus, + MediaTag, + MediaType, + MediaYear, +) +from .....libs.provider.anime.provider import create_provider +from .....libs.provider.anime.params import SearchParams, AnimeParams +from .....libs.selectors import create_selector +from ....service.download import DownloadService +from ....service.feedback import FeedbackService +from ....service.registry import MediaRegistryService +from ....utils.completion import anime_titles_shell_complete +from ....utils import parse_episode_range +from .. import examples + +if TYPE_CHECKING: + from typing import TypedDict + from typing_extensions import Unpack + + class DownloadOptions(TypedDict, total=False): + title: str | None + episode_range: str | None + quality: str | None + force_redownload: 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 + max_concurrent: int | None + + +@click.command( + help="Download anime episodes using AniList API for search and provider integration", + short_help="Download anime episodes", + epilog=examples.download, +) +@click.option( + "--title", + "-t", + shell_complete=anime_titles_shell_complete, + help="Title of the anime to search for" +) +@click.option( + "--episode-range", + "-r", + help="Range of episodes to download (e.g., '1:5', '3:', ':5', '1:10:2')", +) +@click.option( + "--quality", + "-q", + type=click.Choice(["360", "480", "720", "1080", "best"]), + help="Preferred download quality", +) +@click.option( + "--force-redownload", + "-f", + is_flag=True, + help="Force redownload even if episode already exists", +) +@click.option( + "--page", + "-p", + type=click.IntRange(min=1), + default=1, + help="Page number for search 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([season.value for season in MediaSeason]), +) +@click.option( + "--status", + "-S", + help="The media status of the anime", + multiple=True, + 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([sort.value for sort in MediaSort]), +) +@click.option( + "--genres", + "-g", + multiple=True, + help="the genres to filter by", + 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([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([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([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", + help="Whether the anime should be in your list or not", + type=bool, +) +@click.option( + "--max-concurrent", + "-c", + type=click.IntRange(min=1, max=10), + help="Maximum number of concurrent downloads", +) +@click.pass_obj +def download(config: AppConfig, **options: "Unpack[DownloadOptions]"): + """Download anime episodes using AniList search and provider integration.""" + feedback = FeedbackService(config.general.icons) + + try: + # Extract and validate options + title = options.get("title") + episode_range = options.get("episode_range") + quality = options.get("quality") + force_redownload = options.get("force_redownload", False) + max_concurrent = options.get("max_concurrent", config.downloads.max_concurrent) + + _validate_options(options) + + # Initialize services + feedback.info("Initializing services...") + api_client, provider, selector, media_registry, download_service = _initialize_services(config) + feedback.info(f"Using provider: {provider.__class__.__name__}") + feedback.info(f"Using media API: {config.general.media_api}") + feedback.info(f"Translation type: {config.stream.translation_type}") + + # Search for anime + search_params = _build_search_params(options, config) + search_result = _search_anime(api_client, search_params, feedback) + + # Let user select anime (single or multiple) + selected_anime_list = _select_anime(search_result, selector, feedback) + if not selected_anime_list: + feedback.info("No anime selected. Exiting.") + return + + # Process each selected anime + for selected_anime in selected_anime_list: + feedback.info(f"Processing: {selected_anime.title.english or selected_anime.title.romaji}") + feedback.info(f"AniList ID: {selected_anime.id}") + + # Get available episodes from provider + episodes_result = _get_available_episodes(provider, selected_anime, config, feedback) + if not episodes_result: + feedback.warning(f"No episodes found for {selected_anime.title.english or selected_anime.title.romaji}") + _suggest_alternatives(selected_anime, provider, config, feedback) + continue + + # Unpack the result + if len(episodes_result) == 2: + available_episodes, provider_anime_data = episodes_result + else: + # Fallback for backwards compatibility + available_episodes = episodes_result + provider_anime_data = None + + # Determine episodes to download + episodes_to_download = _determine_episodes_to_download( + episode_range, available_episodes, selector, feedback + ) + if not episodes_to_download: + feedback.warning("No episodes selected for download") + continue + + feedback.info(f"About to download {len(episodes_to_download)} episodes: {', '.join(episodes_to_download)}") + + # Test stream availability before attempting download (using provider anime data) + if episodes_to_download and provider_anime_data: + test_episode = episodes_to_download[0] + feedback.info(f"Testing stream availability for episode {test_episode}...") + success = _test_episode_stream_availability(provider, provider_anime_data, test_episode, config, feedback) + if not success: + feedback.warning(f"Stream test failed for episode {test_episode}.") + feedback.info("Possible solutions:") + feedback.info("1. Try a different provider (check your config)") + feedback.info("2. Check if the episode number is correct") + feedback.info("3. Try a different translation type (sub/dub)") + feedback.info("4. The anime might not be available on this provider") + + # Ask user if they want to continue anyway + continue_anyway = input("\nContinue with download anyway? (y/N): ").strip().lower() + if continue_anyway not in ['y', 'yes']: + feedback.info("Download cancelled by user") + continue + + # Download episodes (using provider anime data if available, otherwise AniList data) + anime_for_download = provider_anime_data if provider_anime_data else selected_anime + _download_episodes( + download_service, anime_for_download, episodes_to_download, + quality, force_redownload, max_concurrent, feedback + ) + + # Show final statistics + _show_final_statistics(download_service, feedback) + + except FastAnimeError as e: + feedback.error("Download failed", str(e)) + raise click.Abort() + except Exception as e: + feedback.error("Unexpected error occurred", str(e)) + raise click.Abort() + + +def _validate_options(options: "DownloadOptions") -> None: + """Validate command line options.""" + score_greater = options.get("score_greater") + score_lesser = options.get("score_lesser") + popularity_greater = options.get("popularity_greater") + popularity_lesser = options.get("popularity_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") + + # Score validation + 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") + + # Popularity validation + 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") + + # Date validation + if start_date_greater is not None and start_date_lesser is not None and start_date_greater > start_date_lesser: + raise FastAnimeError("Minimum start date cannot be after maximum start date") + + if end_date_greater is not None and end_date_lesser is not None and end_date_greater > end_date_lesser: + raise FastAnimeError("Minimum end date cannot be after maximum end date") + + +def _initialize_services(config: AppConfig) -> tuple: + """Initialize all required services.""" + api_client = create_api_client(config.general.media_api, config) + provider = create_provider(config.general.provider) + selector = create_selector(config) + media_registry = MediaRegistryService(config.general.media_api, config.media_registry) + download_service = DownloadService(config, media_registry, provider) + + return api_client, provider, selector, media_registry, download_service + + +def _build_search_params(options: "DownloadOptions", config: AppConfig) -> MediaSearchParams: + """Build MediaSearchParams from command options.""" + return MediaSearchParams( + query=options.get("title"), + page=options.get("page", 1), + per_page=options.get("per_page") or config.anilist.per_page or 50, + sort=MediaSort(options.get("sort")) if options.get("sort") else None, + status_in=[MediaStatus(s) for s in options.get("status", ())] if options.get("status") else None, + status_not_in=[MediaStatus(s) for s in options.get("status_not", ())] if options.get("status_not") else None, + genre_in=[MediaGenre(g) for g in options.get("genres", ())] if options.get("genres") else None, + genre_not_in=[MediaGenre(g) for g in options.get("genres_not", ())] if options.get("genres_not") else None, + tag_in=[MediaTag(t) for t in options.get("tags", ())] if options.get("tags") else None, + tag_not_in=[MediaTag(t) for t in options.get("tags_not", ())] if options.get("tags_not") else None, + format_in=[MediaFormat(f) for f in options.get("media_format", ())] if options.get("media_format") else None, + type=MediaType(options.get("media_type")) if options.get("media_type") else None, + season=MediaSeason(options.get("season")) if options.get("season") else None, + seasonYear=int(year) if (year := options.get("year")) else None, + popularity_greater=options.get("popularity_greater"), + popularity_lesser=options.get("popularity_lesser"), + averageScore_greater=options.get("score_greater"), + averageScore_lesser=options.get("score_lesser"), + startDate_greater=options.get("start_date_greater"), + startDate_lesser=options.get("start_date_lesser"), + endDate_greater=options.get("end_date_greater"), + endDate_lesser=options.get("end_date_lesser"), + on_list=options.get("on_list"), + ) + + +def _search_anime(api_client, search_params, feedback): + """Search for anime using the API client.""" + from rich.progress import Progress, SpinnerColumn, TextColumn + + # Check if we have any search criteria at all + has_criteria = any([ + search_params.query, + search_params.genre_in, + search_params.tag_in, + search_params.status_in, + search_params.season, + search_params.seasonYear, + search_params.format_in, + search_params.popularity_greater, + search_params.averageScore_greater, + ]) + + if not has_criteria: + raise FastAnimeError("Please provide at least one search criterion (title, genre, tag, status, etc.)") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + ) as progress: + progress.add_task("Searching for anime...", total=None) + search_result = api_client.search_media(search_params) + + if not search_result or not search_result.media: + raise FastAnimeError("No anime found matching your search criteria") + + return search_result + + +def _select_anime(search_result, selector, feedback): + """Let user select anime from search results.""" + if len(search_result.media) == 1: + selected_anime = search_result.media[0] + feedback.info(f"Auto-selected: {selected_anime.title.english or selected_anime.title.romaji}") + return [selected_anime] + + # Create choice strings with additional info + choices = [] + for i, anime in enumerate(search_result.media, 1): + title = anime.title.english or anime.title.romaji or "Unknown" + year = str(anime.start_date.year) if anime.start_date else "N/A" + score = f"{anime.average_score}%" if anime.average_score else "N/A" + status = anime.status.value if anime.status else "N/A" + choices.append(f"{i:2d}. {title} ({year}) [Score: {score}, Status: {status}]") + + # Use multi-selection + selected_choices = selector.choose_multiple( + prompt="Select anime to download", + choices=choices, + header="Use TAB to select multiple anime, ENTER to confirm", + ) + + if not selected_choices: + return [] + + # Extract anime objects from selections + selected_anime_list = [] + for choice in selected_choices: + # Extract index from choice string (format: "XX. Title...") + try: + index = int(choice.split(".")[0].strip()) - 1 + selected_anime_list.append(search_result.media[index]) + except (ValueError, IndexError): + feedback.error(f"Invalid selection: {choice}") + continue + + return selected_anime_list + + +def _get_available_episodes(provider, anime, config, feedback): + """Get available episodes from provider.""" + try: + # Search for anime in provider first + media_title = anime.title.english or anime.title.romaji + feedback.info(f"Searching provider '{provider.__class__.__name__}' for: '{media_title}'") + feedback.info(f"Using translation type: '{config.stream.translation_type}'") + + provider_search_results = provider.search( + SearchParams(query=media_title, translation_type=config.stream.translation_type) + ) + + if not provider_search_results or not provider_search_results.results: + feedback.warning(f"Could not find '{media_title}' on provider '{provider.__class__.__name__}'") + return [] + + feedback.info(f"Found {len(provider_search_results.results)} results on provider") + + # Show the first few results for debugging + for i, result in enumerate(provider_search_results.results[:3]): + feedback.info(f"Result {i+1}: ID={result.id}, Title='{getattr(result, 'title', 'Unknown')}'") + + # Get the first result (could be enhanced with fuzzy matching) + first_result = provider_search_results.results[0] + feedback.info(f"Using first result: ID={first_result.id}") + + # Now get the full anime data using the PROVIDER'S ID, not AniList ID + provider_anime_data = provider.get( + AnimeParams(id=first_result.id, query=media_title) + ) + + if not provider_anime_data: + feedback.warning(f"Failed to get anime details from provider") + return [] + + # Check all available translation types + translation_types = ['sub', 'dub'] + for trans_type in translation_types: + episodes = getattr(provider_anime_data.episodes, trans_type, []) + feedback.info(f"Translation '{trans_type}': {len(episodes)} episodes available") + + available_episodes = getattr( + provider_anime_data.episodes, config.stream.translation_type, [] + ) + + if not available_episodes: + feedback.warning(f"No '{config.stream.translation_type}' episodes found") + # Suggest alternative translation type if available + for trans_type in translation_types: + if trans_type != config.stream.translation_type: + other_episodes = getattr(provider_anime_data.episodes, trans_type, []) + if other_episodes: + feedback.info(f"Suggestion: Try using translation type '{trans_type}' (has {len(other_episodes)} episodes)") + return [] + + feedback.info(f"Found {len(available_episodes)} episodes available for download") + + # Return both episodes and the provider anime data for later use + return available_episodes, provider_anime_data + + except Exception as e: + feedback.error(f"Error getting episodes from provider: {e}") + import traceback + feedback.error("Full traceback", traceback.format_exc()) + return [] + + +def _determine_episodes_to_download(episode_range, available_episodes, selector, feedback): + """Determine which episodes to download based on range or user selection.""" + if not available_episodes: + feedback.warning("No episodes available to download") + return [] + + if episode_range: + try: + episodes_to_download = list(parse_episode_range(episode_range, available_episodes)) + feedback.info(f"Episodes from range '{episode_range}': {', '.join(episodes_to_download)}") + return episodes_to_download + except (ValueError, IndexError) as e: + feedback.error(f"Invalid episode range '{episode_range}': {e}") + return [] + else: + # Let user select episodes + selected_episodes = selector.choose_multiple( + prompt="Select episodes to download", + choices=available_episodes, + header="Use TAB to select multiple episodes, ENTER to confirm", + ) + + if selected_episodes: + feedback.info(f"Selected episodes: {', '.join(selected_episodes)}") + + return selected_episodes + + +def _suggest_alternatives(anime, provider, config, feedback): + """Suggest alternatives when episodes are not found.""" + feedback.info("Troubleshooting suggestions:") + feedback.info(f"1. Current provider: {provider.__class__.__name__}") + feedback.info(f"2. AniList ID being used: {anime.id}") + feedback.info(f"3. Translation type: {config.stream.translation_type}") + + # Special message for AllAnime provider + if provider.__class__.__name__ == "AllAnimeProvider": + feedback.info("4. AllAnime ID mismatch: AllAnime uses different IDs than AniList") + feedback.info(" The provider searches by title, but episodes use AniList ID") + feedback.info(" This can cause episodes to not be found even if the anime exists") + + # Check if provider has different ID mapping + anime_titles = [] + if anime.title.english: + anime_titles.append(anime.title.english) + if anime.title.romaji: + anime_titles.append(anime.title.romaji) + if anime.title.native: + anime_titles.append(anime.title.native) + + feedback.info(f"5. Available titles: {', '.join(anime_titles)}") + feedback.info("6. Possible solutions:") + feedback.info(" - Try a different provider (GogoAnime, 9anime, etc.)") + feedback.info(" - Check provider configuration") + feedback.info(" - Try different translation type (sub/dub)") + feedback.info(" - Manual search on the provider website") + feedback.info(" - Check if anime is available in your region") + + +def _download_episodes(download_service, anime, episodes, quality, force_redownload, max_concurrent, feedback): + """Download the specified episodes.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + from rich.console import Console + from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, + ) + import logging + + console = Console() + anime_title = anime.title.english or anime.title.romaji + + console.print(f"\n[bold green]Starting downloads for: {anime_title}[/bold green]") + + # Set up logging capture to get download errors + log_messages = [] + class ListHandler(logging.Handler): + def emit(self, record): + log_messages.append(self.format(record)) + + handler = ListHandler() + handler.setLevel(logging.ERROR) + logger = logging.getLogger('fastanime') + logger.addHandler(handler) + + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TaskProgressColumn(), + TimeElapsedColumn(), + ) as progress: + + task = progress.add_task("Downloading episodes...", total=len(episodes)) + + if max_concurrent == 1: + # Sequential downloads + results = {} + for episode in episodes: + progress.update(task, description=f"Downloading episode {episode}...") + + # Clear previous log messages for this episode + log_messages.clear() + + try: + success = download_service.download_episode( + media_item=anime, + episode_number=episode, + quality=quality, + force_redownload=force_redownload, + ) + results[episode] = success + + if not success: + # Try to get more detailed error from registry + error_msg = _get_episode_error_details(download_service, anime, episode) + if error_msg: + feedback.error(f"Episode {episode}", error_msg) + elif log_messages: + # Show any log messages that were captured + for msg in log_messages[-3:]: # Show last 3 error messages + feedback.error(f"Episode {episode}", msg) + else: + feedback.error(f"Episode {episode}", "Download failed - check logs for details") + + except Exception as e: + results[episode] = False + feedback.error(f"Episode {episode} failed", str(e)) + progress.advance(task) + else: + # Concurrent downloads + results = {} + with ThreadPoolExecutor(max_workers=max_concurrent) as executor: + # Submit all download tasks + future_to_episode = { + executor.submit( + download_service.download_episode, + media_item=anime, + episode_number=episode, + server=None, + quality=quality, + force_redownload=force_redownload, + ): episode + for episode in episodes + } + + # Process completed downloads + for future in as_completed(future_to_episode): + episode = future_to_episode[future] + try: + success = future.result() + results[episode] = success + if not success: + # Try to get more detailed error from registry + error_msg = _get_episode_error_details(download_service, anime, episode) + if error_msg: + feedback.error(f"Episode {episode}", error_msg) + else: + feedback.error(f"Episode {episode}", "Download failed - check logs for details") + except Exception as e: + results[episode] = False + feedback.error(f"Download failed for episode {episode}", str(e)) + + progress.advance(task) + finally: + # Remove the log handler + logger.removeHandler(handler) + + # Display results + _display_download_results(console, results, anime) + + +def _get_episode_error_details(download_service, anime, episode_number): + """Get detailed error information from the registry for a failed episode.""" + try: + # Get the media record from registry + media_record = download_service.media_registry.get_record(anime.id) + if not media_record: + return None + + # Find the episode in the record + for episode_record in media_record.episodes: + if episode_record.episode_number == episode_number: + if episode_record.error_message: + error_msg = episode_record.error_message + + # Provide more helpful error messages for common issues + if "Failed to get server for episode" in error_msg: + return f"Episode {episode_number} not available on current provider. Try a different provider or check episode number." + elif "NoneType" in error_msg or "not subscriptable" in error_msg: + return f"Episode {episode_number} data not found on provider (API returned null). Episode may not exist or be accessible." + else: + return error_msg + elif episode_record.download_status: + return f"Download status: {episode_record.download_status.value}" + break + + return None + except Exception: + return None + + +def _test_episode_stream_availability(provider, anime, episode_number, config, feedback): + """Test if streams are available for a specific episode.""" + try: + from .....libs.provider.anime.params import EpisodeStreamsParams + + media_title = anime.title.english or anime.title.romaji + feedback.info(f"Testing stream availability for '{media_title}' episode {episode_number}") + + # Test episode streams + streams = provider.episode_streams( + EpisodeStreamsParams( + anime_id=str(anime.id), + query=media_title, + episode=episode_number, + translation_type=config.stream.translation_type, + ) + ) + + if not streams: + feedback.warning(f"No streams found for episode {episode_number}") + return False + + # Convert to list to check actual availability + stream_list = list(streams) + if not stream_list: + feedback.warning(f"No stream servers available for episode {episode_number}") + return False + + feedback.info(f"Found {len(stream_list)} stream server(s) for episode {episode_number}") + + # Show details about the first server for debugging + first_server = stream_list[0] + feedback.info(f"First server: name='{first_server.name}', type='{type(first_server).__name__}'") + + return True + + except TypeError as e: + if "'NoneType' object is not subscriptable" in str(e): + feedback.warning(f"Episode {episode_number} not available on provider (API returned null)") + feedback.info("This usually means the episode doesn't exist on this provider or isn't accessible") + return False + else: + feedback.error(f"Type error testing stream availability: {e}") + return False + except Exception as e: + feedback.error(f"Error testing stream availability: {e}") + import traceback + feedback.error("Stream test traceback", traceback.format_exc()) + return False + + +def _display_download_results(console, results: dict[str, bool], anime): + """Display download results in a formatted table.""" + from rich.table import Table + + table = Table(title=f"Download Results for {anime.title.english or anime.title.romaji}") + table.add_column("Episode", justify="center", style="cyan") + table.add_column("Status", justify="center") + + for episode, success in sorted(results.items(), key=lambda x: float(x[0])): + status = "[green]✓ Success[/green]" if success else "[red]✗ Failed[/red]" + table.add_row(episode, status) + + console.print(table) + + # Summary + total = len(results) + successful = sum(results.values()) + failed = total - successful + + if failed == 0: + console.print(f"\n[bold green]All {total} episodes downloaded successfully![/bold green]") + else: + console.print(f"\n[yellow]Download complete: {successful}/{total} successful, {failed} failed[/yellow]") + + +def _show_final_statistics(download_service, feedback): + """Show final download statistics.""" + from rich.console import Console + + console = Console() + stats = download_service.get_download_statistics() + + if stats: + console.print(f"\n[bold blue]Overall Download Statistics:[/bold blue]") + console.print(f"Total episodes tracked: {stats.get('total_episodes', 0)}") + console.print(f"Successfully downloaded: {stats.get('downloaded', 0)}") + console.print(f"Failed downloads: {stats.get('failed', 0)}") + console.print(f"Queued downloads: {stats.get('queued', 0)}") + + if stats.get('total_size_bytes', 0) > 0: + size_mb = stats['total_size_bytes'] / (1024 * 1024) + if size_mb > 1024: + console.print(f"Total size: {size_mb/1024:.2f} GB") + else: + console.print(f"Total size: {size_mb:.2f} MB") diff --git a/fastanime/cli/commands/anilist/download.py b/fastanime/cli/commands/anilist/download.py deleted file mode 100644 index d5db19e..0000000 --- a/fastanime/cli/commands/anilist/download.py +++ /dev/null @@ -1,408 +0,0 @@ -import click - -from ...completion_functions 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, -) - - -@click.command( - help="download anime using anilists api to get the titles", - short_help="download anime with anilist intergration", -) -@click.option("--title", "-t", shell_complete=anime_titles_shell_complete) -@click.option( - "--season", - help="The season the media was released", - type=click.Choice(seasons_available), -) -@click.option( - "--status", - "-S", - help="The media status of the anime", - multiple=True, - type=click.Choice(media_statuses_available), -) -@click.option( - "--sort", - "-s", - help="What to sort the search results on", - type=click.Choice(sorts_available), -) -@click.option( - "--genres", - "-g", - multiple=True, - help="the genres to filter by", - type=click.Choice(genres_available), -) -@click.option( - "--tags", - "-T", - multiple=True, - help="the tags to filter by", - type=click.Choice(tags_available_list), -) -@click.option( - "--media-format", - "-f", - multiple=True, - help="Media format", - type=click.Choice(media_formats_available), -) -@click.option( - "--year", - "-y", - type=click.Choice(years_available), - help="the year the media was released", -) -@click.option( - "--on-list/--not-on-list", - "-L/-no-L", - help="Whether the anime should be in your list or not", - type=bool, -) -@click.option( - "--episode-range", - "-r", - help="A range of episodes to download (start-end)", -) -@click.option( - "--force-unknown-ext", - "-F", - help="This option forces yt-dlp to download extensions its not aware of", - is_flag=True, -) -@click.option( - "--silent/--no-silent", - "-q/-V", - type=bool, - help="Download silently (during download)", - default=True, -) -@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)") -@click.option( - "--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg" -) -@click.option( - "--clean", - "-c", - is_flag=True, - help="After merging delete the original files", -) -@click.option( - "--wait-time", - "-w", - type=int, - help="The amount of time to wait after downloading is complete before the screen is completely cleared", - default=60, -) -@click.option( - "--prompt/--no-prompt", - help="Whether to prompt for anything instead just do the best thing", - default=True, -) -@click.option( - "--force-ffmpeg", - is_flag=True, - help="Force the use of FFmpeg for downloading (supports large variety of streams but slower)", -) -@click.option( - "--hls-use-mpegts", - is_flag=True, - help="Use mpegts for hls streams, resulted in .ts file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)", -) -@click.option( - "--hls-use-h264", - is_flag=True, - help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)", -) -@click.option( - "--no-check-certificates", - is_flag=True, - help="Suppress HTTPS certificate validation", -) -@click.option( - "--max-results", "-M", type=int, help="The maximum number of results to show" -) -@click.pass_obj -def download( - config, - title, - season, - status, - sort, - genres, - tags, - media_format, - year, - on_list, - episode_range, - force_unknown_ext, - silent, - verbose, - merge, - clean, - wait_time, - prompt, - force_ffmpeg, - hls_use_mpegts, - hls_use_h264, - no_check_certificates, - max_results, -): - from rich import print - - from ....anilist import AniList - - force_ffmpeg |= hls_use_mpegts or hls_use_h264 - - success, anilist_search_results = AniList.search( - query=title, - sort=sort, - status_in=list(status), - genre_in=list(genres), - season=season, - tag_in=list(tags), - seasonYear=year, - format_in=list(media_format), - on_list=on_list, - max_results=max_results, - ) - if success: - import time - - from rich.progress import Progress - from thefuzz import fuzz - - from ....AnimeProvider import AnimeProvider - from ....libs.anime_provider.types import Anime - from ....libs.fzf import fzf - from ....Utility.data import anime_normalizer - from ....Utility.downloader.downloader import downloader - from ...utils.tools import exit_app - from ...utils.utils import ( - filter_by_quality, - fuzzy_inquirer, - move_preferred_subtitle_lang_to_top, - ) - - anime_provider = AnimeProvider(config.provider) - anilist_anime_info = None - - translation_type = config.translation_type - download_dir = config.downloads_dir - anime_titles = [ - (anime["title"]["romaji"] or anime["title"]["english"]) - for anime in anilist_search_results["data"]["Page"]["media"] - ] - print(f"[green bold]Queued:[/] {anime_titles}") - for i, anime_title in enumerate(anime_titles): - print(f"[green bold]Now Downloading: [/] {anime_title}") - # ---- search for anime ---- - with Progress() as progress: - progress.add_task("Fetching Search Results...", total=None) - search_results = anime_provider.search_for_anime( - anime_title, translation_type=translation_type - ) - if not search_results: - print( - "No search results found from provider for {}".format(anime_title) - ) - continue - search_results = search_results["results"] - if not search_results: - print("Nothing muches your search term") - continue - search_results_ = { - search_result["title"]: search_result - for search_result in search_results - } - - if config.auto_select: - selected_anime_title = max( - search_results_.keys(), - key=lambda title: fuzz.ratio( - anime_normalizer.get(title, title), anime_title - ), - ) - print("[cyan]Auto selecting:[/] ", selected_anime_title) - else: - choices = list(search_results_.keys()) - if config.use_fzf: - selected_anime_title = fzf.run( - choices, "Please Select title", "FastAnime" - ) - else: - selected_anime_title = fuzzy_inquirer( - choices, - "Please Select title", - ) - - # ---- fetch anime ---- - with Progress() as progress: - progress.add_task("Fetching Anime...", total=None) - anime: Anime | None = anime_provider.get_anime( - search_results_[selected_anime_title]["id"] - ) - if not anime: - print("Failed to fetch anime {}".format(selected_anime_title)) - continue - - episodes = sorted( - anime["availableEpisodesDetail"][config.translation_type], key=float - ) - # where the magic happens - if episode_range: - if ":" in episode_range: - ep_range_tuple = episode_range.split(":") - if 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) - ] - elif 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) - ] - 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(episode_range) :] - print(f"[green bold]Downloading: [/] {episodes_range}") - - else: - episodes_range = sorted(episodes, key=float) - - if config.normalize_titles: - anilist_anime_info = anilist_search_results["data"]["Page"]["media"][i] - - # lets download em - for episode in episodes_range: - try: - episode = str(episode) - if episode not in episodes: - print( - f"[cyan]Warning[/]: Episode {episode} not found, skipping" - ) - continue - with Progress() as progress: - progress.add_task("Fetching Episode Streams...", total=None) - streams = anime_provider.get_episode_streams( - anime["id"], episode, config.translation_type - ) - if not streams: - print("No streams skipping") - continue - # ---- fetch servers ---- - if config.server == "top": - with Progress() as progress: - progress.add_task("Fetching top server...", total=None) - server_name = next(streams, None) - if not server_name: - print("Sth went wrong when fetching the server") - continue - stream_link = filter_by_quality( - config.quality, server_name["links"] - ) - if not stream_link: - print("[yellow bold]WARNING:[/] No streams found") - time.sleep(1) - print("Continuing...") - continue - link = stream_link["link"] - provider_headers = server_name["headers"] - episode_title = server_name["episode_title"] - subtitles = server_name["subtitles"] - else: - with Progress() as progress: - progress.add_task("Fetching servers", total=None) - # prompt for server selection - servers = {server["server"]: server for server in streams} - servers_names = list(servers.keys()) - if config.server in servers_names: - server_name = config.server - else: - if config.use_fzf: - server_name = fzf.run(servers_names, "Select an link") - else: - server_name = fuzzy_inquirer( - servers_names, - "Select link", - ) - stream_link = filter_by_quality( - config.quality, servers[server_name]["links"] - ) - if not stream_link: - print("[yellow bold]WARNING:[/] No streams found") - time.sleep(1) - print("Continuing...") - continue - link = stream_link["link"] - provider_headers = servers[server_name]["headers"] - - subtitles = servers[server_name]["subtitles"] - episode_title = servers[server_name]["episode_title"] - - if anilist_anime_info: - selected_anime_title = ( - anilist_anime_info["title"][config.preferred_language] - or anilist_anime_info["title"]["romaji"] - or anilist_anime_info["title"]["english"] - ) - import re - - for episode_detail in anilist_anime_info["streamingEpisodes"]: - if re.match( - f".*Episode {episode} .*", episode_detail["title"] - ): - episode_title = episode_detail["title"] - break - print(f"[purple]Now Downloading:[/] {episode_title}") - subtitles = move_preferred_subtitle_lang_to_top( - subtitles, config.sub_lang - ) - downloader._download_file( - link, - selected_anime_title, - episode_title, - download_dir, - silent, - vid_format=config.format, - force_unknown_ext=force_unknown_ext, - verbose=verbose, - headers=provider_headers, - sub=subtitles[0]["url"] if subtitles else "", - merge=merge, - clean=clean, - prompt=prompt, - force_ffmpeg=force_ffmpeg, - hls_use_mpegts=hls_use_mpegts, - hls_use_h264=hls_use_h264, - nocheckcertificate=no_check_certificates, - ) - except Exception as e: - print(e) - time.sleep(1) - print("Continuing...") - print("Done Downloading") - time.sleep(wait_time) - exit_app() - else: - from sys import exit - - print("Failed to search for anime", anilist_search_results) - exit(1) diff --git a/fastanime/cli/commands/anilist/examples.py b/fastanime/cli/commands/anilist/examples.py index 51e1b5e..3411a6f 100644 --- a/fastanime/cli/commands/anilist/examples.py +++ b/fastanime/cli/commands/anilist/examples.py @@ -7,35 +7,35 @@ download = """ # Download specific episodes fastanime anilist download -t "One Piece" --episode-range "1-10" \b - # Download with auto-selection (no prompts) - fastanime anilist download -t "Naruto" --auto-select --silent + # Download single episode + fastanime anilist download -t "Death Note" --episode-range "1" +\b + # Download multiple specific episodes + fastanime anilist download -t "Naruto" --episode-range "1,5,10" \b # Download with quality preference - fastanime anilist download -t "Death Note" --quality 1080p --episode-range "1-5" + fastanime anilist download -t "Death Note" --quality 1080 --episode-range "1-5" \b # Download with multiple filters fastanime anilist download -g Action -T Isekai --score-greater 80 --status RELEASING \b - # Download recent episodes only - fastanime anilist download -t "Demon Slayer" --episode-range "20:" + # Download with concurrent downloads + fastanime anilist download -t "Demon Slayer" --episode-range "1-5" --max-concurrent 3 \b - # Download with subtitle merging - fastanime anilist download -t "Your Name" --merge --clean -\b - # Download using FFmpeg with HLS options - fastanime anilist download -t "Spirited Away" --force-ffmpeg --hls-use-h264 + # Force redownload existing episodes + fastanime anilist download -t "Your Name" --episode-range "1" --force-redownload \b # Download from a specific season and year - fastanime anilist download --season WINTER --year 2024 -s POPULARITY_DESC --auto-select + fastanime anilist download --season WINTER --year 2024 -s POPULARITY_DESC +\b + # Download with genre filtering + fastanime anilist download -g Action -g Adventure --score-greater 75 \b # Download only completed series - fastanime anilist download -g Fantasy --status FINISHED --score-greater 75 --auto-select -\b - # Download with verbose output and no certificate checking - fastanime anilist download -t "Akira" --verbose --no-check-certificates + fastanime anilist download -g Fantasy --status FINISHED --score-greater 75 \b # Download movies only - fastanime anilist download -f MOVIE -s SCORE_DESC --auto-select --quality best + fastanime anilist download -F MOVIE -s SCORE_DESC --quality best """ diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index a04ad0d..ab3045b 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -33,6 +33,7 @@ class Services: watch_history: WatchHistoryService session: SessionsService auth: AuthService + download: "DownloadService" @dataclass(frozen=True) diff --git a/fastanime/cli/service/download/__init__.py b/fastanime/cli/service/download/__init__.py new file mode 100644 index 0000000..6b98bde --- /dev/null +++ b/fastanime/cli/service/download/__init__.py @@ -0,0 +1,3 @@ +from .service import DownloadService + +__all__ = ["DownloadService"] diff --git a/fastanime/cli/service/download/service.py b/fastanime/cli/service/download/service.py new file mode 100644 index 0000000..13c5109 --- /dev/null +++ b/fastanime/cli/service/download/service.py @@ -0,0 +1,480 @@ +"""Download service that integrates with the media registry.""" + +import logging +from pathlib import Path +from typing import Optional + +from ....core.config.model import AppConfig, DownloadsConfig +from ....core.downloader.base import BaseDownloader +from ....core.downloader.downloader import create_downloader +from ....core.downloader.params import DownloadParams +from ....core.exceptions import FastAnimeError +from ....libs.media_api.types import MediaItem +from ....libs.provider.anime.base import BaseAnimeProvider +from ....libs.provider.anime.params import EpisodeStreamsParams +from ....libs.provider.anime.types import Server +from ..registry import MediaRegistryService +from ..registry.models import DownloadStatus, MediaEpisode + +logger = logging.getLogger(__name__) + + +class DownloadService: + """Service for downloading episodes and tracking them in the registry.""" + + def __init__( + self, + config: AppConfig, + media_registry: MediaRegistryService, + provider: BaseAnimeProvider, + ): + self.config = config + self.downloads_config = config.downloads + self.media_registry = media_registry + self.provider = provider + self._downloader: Optional[BaseDownloader] = None + + @property + def downloader(self) -> BaseDownloader: + """Lazy initialization of downloader.""" + if self._downloader is None: + self._downloader = create_downloader(self.downloads_config) + return self._downloader + + def download_episode( + self, + media_item: MediaItem, + episode_number: str, + server: Optional[Server] = None, + quality: Optional[str] = None, + force_redownload: bool = False, + ) -> bool: + """ + Download a specific episode and record it in the registry. + + Args: + media_item: The media item to download + episode_number: The episode number to download + server: Optional specific server to use for download + quality: Optional quality preference + force_redownload: Whether to redownload if already exists + + Returns: + bool: True if download was successful, False otherwise + """ + try: + # Get or create media record + media_record = self.media_registry.get_or_create_record(media_item) + + # Check if episode already exists and is completed + existing_episode = self._find_episode_in_record(media_record, episode_number) + if ( + existing_episode + and existing_episode.download_status == DownloadStatus.COMPLETED + and not force_redownload + and existing_episode.file_path.exists() + ): + logger.info(f"Episode {episode_number} already downloaded at {existing_episode.file_path}") + return True + + # Generate file path + file_path = self._generate_episode_file_path(media_item, episode_number) + + # Update status to QUEUED + self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.QUEUED, + file_path=file_path, + ) + + # Get episode stream server if not provided + if server is None: + server = self._get_episode_server(media_item, episode_number, quality) + if not server: + self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.FAILED, + error_message="Failed to get server for episode", + ) + return False + + # Update status to DOWNLOADING + self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.DOWNLOADING, + provider_name=self.provider.__class__.__name__, + server_name=server.name, + quality=quality or self.downloads_config.preferred_quality, + ) + + # Perform the download + download_result = self._download_from_server( + media_item, episode_number, server, file_path + ) + + if download_result.success and download_result.video_path: + # Get file size if available + file_size = None + if download_result.video_path.exists(): + file_size = download_result.video_path.stat().st_size + + # Update episode record with success + self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.COMPLETED, + file_path=download_result.video_path, + file_size=file_size, + subtitle_paths=download_result.subtitle_paths, + ) + + logger.info(f"Successfully downloaded episode {episode_number} to {download_result.video_path}") + else: + # Update episode record with failure + self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.FAILED, + error_message=download_result.error_message, + ) + + logger.error(f"Failed to download episode {episode_number}: {download_result.error_message}") + + return download_result.success + + except Exception as e: + logger.error(f"Error downloading episode {episode_number}: {e}") + # Update status to FAILED + try: + self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.FAILED, + error_message=str(e), + ) + except Exception as cleanup_error: + logger.error(f"Failed to update failed status: {cleanup_error}") + + return False + + def download_multiple_episodes( + self, + media_item: MediaItem, + episode_numbers: list[str], + quality: Optional[str] = None, + force_redownload: bool = False, + ) -> dict[str, bool]: + """ + Download multiple episodes and return success status for each. + + Args: + media_item: The media item to download + episode_numbers: List of episode numbers to download + quality: Optional quality preference + force_redownload: Whether to redownload if already exists + + Returns: + dict: Mapping of episode_number -> success status + """ + results = {} + + for episode_number in episode_numbers: + success = self.download_episode( + media_item=media_item, + episode_number=episode_number, + quality=quality, + force_redownload=force_redownload, + ) + results[episode_number] = success + + # Log progress + logger.info(f"Download progress: {episode_number} - {'✓' if success else '✗'}") + + return results + + def get_download_status(self, media_item: MediaItem, episode_number: str) -> Optional[DownloadStatus]: + """Get the download status for a specific episode.""" + media_record = self.media_registry.get_media_record(media_item.id) + if not media_record: + return None + + episode_record = self._find_episode_in_record(media_record, episode_number) + return episode_record.download_status if episode_record else None + + def get_downloaded_episodes(self, media_item: MediaItem) -> list[str]: + """Get list of successfully downloaded episode numbers for a media item.""" + media_record = self.media_registry.get_media_record(media_item.id) + if not media_record: + return [] + + return [ + episode.episode_number + for episode in media_record.media_episodes + if episode.download_status == DownloadStatus.COMPLETED + and episode.file_path.exists() + ] + + def remove_downloaded_episode(self, media_item: MediaItem, episode_number: str) -> bool: + """Remove a downloaded episode file and update registry.""" + try: + media_record = self.media_registry.get_media_record(media_item.id) + if not media_record: + return False + + episode_record = self._find_episode_in_record(media_record, episode_number) + if not episode_record: + return False + + # Remove file if it exists + if episode_record.file_path.exists(): + episode_record.file_path.unlink() + + # Remove episode from record + media_record.media_episodes = [ + ep for ep in media_record.media_episodes + if ep.episode_number != episode_number + ] + + # Save updated record + self.media_registry.save_media_record(media_record) + + logger.info(f"Removed downloaded episode {episode_number}") + return True + + except Exception as e: + logger.error(f"Error removing episode {episode_number}: {e}") + return False + + def _find_episode_in_record(self, media_record, episode_number: str) -> Optional[MediaEpisode]: + """Find an episode record by episode number.""" + for episode in media_record.media_episodes: + if episode.episode_number == episode_number: + return episode + return None + + def _get_episode_server( + self, media_item: MediaItem, episode_number: str, quality: Optional[str] = None + ) -> Optional[Server]: + """Get a server for downloading the episode.""" + try: + # Use media title for provider search + media_title = media_item.title.english or media_item.title.romaji + if not media_title: + logger.error("Media item has no searchable title") + return None + + # Get episode streams from provider + streams = self.provider.episode_streams( + EpisodeStreamsParams( + anime_id=str(media_item.id), + query=media_title, + episode=episode_number, + translation_type=self.config.stream.translation_type, + ) + ) + + if not streams: + logger.error(f"No streams found for episode {episode_number}") + return None + + # Convert iterator to list and get first available server + stream_list = list(streams) + if not stream_list: + logger.error(f"No servers available for episode {episode_number}") + return None + + # Return the first server (could be enhanced with quality/preference logic) + return stream_list[0] + + except Exception as e: + logger.error(f"Error getting episode server: {e}") + return None + + def _download_from_server( + self, + media_item: MediaItem, + episode_number: str, + server: Server, + output_path: Path, + ): + """Download episode from a specific server.""" + anime_title = media_item.title.english or media_item.title.romaji or "Unknown" + episode_title = server.episode_title or f"Episode {episode_number}" + + try: + # Get the best quality link from server + if not server.links: + raise FastAnimeError("Server has no available links") + + # Use the first link (could be enhanced with quality filtering) + stream_link = server.links[0] + + # Prepare download parameters + download_params = DownloadParams( + url=stream_link.link, + anime_title=anime_title, + episode_title=episode_title, + silent=True, # Use True by default since there's no verbose in config + headers=server.headers, + subtitles=[sub.url for sub in server.subtitles] if server.subtitles else [], + vid_format=self.downloads_config.preferred_quality, + force_unknown_ext=True, + ) + + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Perform download + return self.downloader.download(download_params) + + except Exception as e: + logger.error(f"Error during download: {e}") + from ....core.downloader.model import DownloadResult + return DownloadResult( + success=False, + error_message=str(e), + anime_title=anime_title, + episode_title=episode_title, + ) + + def get_download_statistics(self) -> dict: + """Get comprehensive download statistics from the registry.""" + return self.media_registry.get_download_statistics() + + def get_failed_downloads(self) -> list[tuple[int, str]]: + """Get all episodes that failed to download.""" + return self.media_registry.get_episodes_by_download_status(DownloadStatus.FAILED) + + def get_queued_downloads(self) -> list[tuple[int, str]]: + """Get all episodes queued for download.""" + return self.media_registry.get_episodes_by_download_status(DownloadStatus.QUEUED) + + def retry_failed_downloads(self, max_retries: int = 3) -> dict[str, bool]: + """Retry all failed downloads up to max_retries.""" + failed_episodes = self.get_failed_downloads() + results = {} + + for media_id, episode_number in failed_episodes: + # Get the media record to check retry attempts + media_record = self.media_registry.get_media_record(media_id) + if not media_record: + continue + + episode_record = self._find_episode_in_record(media_record, episode_number) + if not episode_record or episode_record.download_attempts >= max_retries: + logger.info(f"Skipping {media_id}:{episode_number} - max retries exceeded") + continue + + logger.info(f"Retrying download for {media_id}:{episode_number}") + success = self.download_episode( + media_item=media_record.media_item, + episode_number=episode_number, + force_redownload=True, + ) + results[f"{media_id}:{episode_number}"] = success + + return results + + def cleanup_failed_downloads(self, older_than_days: int = 7) -> int: + """Clean up failed download records older than specified days.""" + from datetime import datetime, timedelta + + cleanup_count = 0 + cutoff_date = datetime.now() - timedelta(days=older_than_days) + + try: + for record in self.media_registry.get_all_media_records(): + episodes_to_remove = [] + + for episode in record.media_episodes: + if ( + episode.download_status == DownloadStatus.FAILED + and episode.download_date < cutoff_date + ): + episodes_to_remove.append(episode.episode_number) + + for episode_number in episodes_to_remove: + record.media_episodes = [ + ep for ep in record.media_episodes + if ep.episode_number != episode_number + ] + cleanup_count += 1 + + if episodes_to_remove: + self.media_registry.save_media_record(record) + + logger.info(f"Cleaned up {cleanup_count} failed download records") + return cleanup_count + + except Exception as e: + logger.error(f"Error during cleanup: {e}") + return 0 + + def pause_download(self, media_item: MediaItem, episode_number: str) -> bool: + """Pause a download (change status from DOWNLOADING to PAUSED).""" + try: + return self.media_registry.update_episode_download_status( + media_id=media_item.id, + episode_number=episode_number, + status=DownloadStatus.PAUSED, + ) + except Exception as e: + logger.error(f"Error pausing download: {e}") + return False + + def resume_download(self, media_item: MediaItem, episode_number: str) -> bool: + """Resume a paused download.""" + return self.download_episode( + media_item=media_item, + episode_number=episode_number, + force_redownload=True, + ) + + def get_media_download_progress(self, media_item: MediaItem) -> dict: + """Get download progress for a specific media item.""" + try: + media_record = self.media_registry.get_media_record(media_item.id) + if not media_record: + return {"total": 0, "downloaded": 0, "failed": 0, "queued": 0, "downloading": 0} + + stats = {"total": 0, "downloaded": 0, "failed": 0, "queued": 0, "downloading": 0, "paused": 0} + + for episode in media_record.media_episodes: + stats["total"] += 1 + status = episode.download_status.value.lower() + if status == "completed": + stats["downloaded"] += 1 + elif status == "failed": + stats["failed"] += 1 + elif status == "queued": + stats["queued"] += 1 + elif status == "downloading": + stats["downloading"] += 1 + elif status == "paused": + stats["paused"] += 1 + + return stats + + except Exception as e: + logger.error(f"Error getting download progress: {e}") + return {"total": 0, "downloaded": 0, "failed": 0, "queued": 0, "downloading": 0} + + def _generate_episode_file_path(self, media_item: MediaItem, episode_number: str) -> Path: + """Generate the file path for a downloaded episode.""" + # Use the download directory from config + base_dir = self.downloads_config.downloads_dir + + # Create anime-specific directory + anime_title = media_item.title.english or media_item.title.romaji or "Unknown" + # Sanitize title for filesystem + safe_title = "".join(c for c in anime_title if c.isalnum() or c in (' ', '-', '_')).rstrip() + + anime_dir = base_dir / safe_title + + # Generate filename (could use template from config in the future) + filename = f"Episode_{episode_number:0>2}.mp4" + + return anime_dir / filename diff --git a/fastanime/cli/service/registry/models.py b/fastanime/cli/service/registry/models.py index e76020e..93cf476 100644 --- a/fastanime/cli/service/registry/models.py +++ b/fastanime/cli/service/registry/models.py @@ -30,6 +30,15 @@ class MediaEpisode(BaseModel): download_status: DownloadStatus = DownloadStatus.NOT_DOWNLOADED file_path: Path download_date: datetime = Field(default_factory=datetime.now) + + # Additional download metadata + file_size: Optional[int] = None # File size in bytes + quality: Optional[str] = None # Download quality (e.g., "1080p", "720p") + provider_name: Optional[str] = None # Name of the provider used + server_name: Optional[str] = None # Name of the server used + subtitle_paths: list[Path] = Field(default_factory=list) # Paths to subtitle files + download_attempts: int = 0 # Number of download attempts + last_error: Optional[str] = None # Last error message if failed class MediaRecord(BaseModel): diff --git a/fastanime/cli/service/registry/service.py b/fastanime/cli/service/registry/service.py index 20f369d..3b7eb5e 100644 --- a/fastanime/cli/service/registry/service.py +++ b/fastanime/cli/service/registry/service.py @@ -274,3 +274,145 @@ class MediaRegistryService: self._save_index(index) logger.debug(f"Removed media record {media_id}") + + def update_episode_download_status( + self, + media_id: int, + episode_number: str, + status: "DownloadStatus", + file_path: Optional[Path] = None, + file_size: Optional[int] = None, + quality: Optional[str] = None, + provider_name: Optional[str] = None, + server_name: Optional[str] = None, + subtitle_paths: Optional[list[Path]] = None, + error_message: Optional[str] = None, + ) -> bool: + """Update the download status and metadata for a specific episode.""" + try: + from .models import DownloadStatus, MediaEpisode + + record = self.get_media_record(media_id) + if not record: + logger.error(f"No media record found for ID {media_id}") + return False + + # Find existing episode or create new one + episode_record = None + for episode in record.media_episodes: + if episode.episode_number == episode_number: + episode_record = episode + break + + if not episode_record: + if not file_path: + logger.error(f"File path required for new episode {episode_number}") + return False + episode_record = MediaEpisode( + episode_number=episode_number, + file_path=file_path, + download_status=status, + ) + record.media_episodes.append(episode_record) + + # Update episode metadata + episode_record.download_status = status + if file_path: + episode_record.file_path = file_path + if file_size is not None: + episode_record.file_size = file_size + if quality: + episode_record.quality = quality + if provider_name: + episode_record.provider_name = provider_name + if server_name: + episode_record.server_name = server_name + if subtitle_paths: + episode_record.subtitle_paths = subtitle_paths + if error_message: + episode_record.last_error = error_message + + # Increment download attempts if this is a failure + if status == DownloadStatus.FAILED: + episode_record.download_attempts += 1 + + # Save the updated record + return self.save_media_record(record) + + except Exception as e: + logger.error(f"Failed to update episode download status: {e}") + return False + + def get_episodes_by_download_status( + self, status: "DownloadStatus" + ) -> list[tuple[int, str]]: + """Get all episodes with a specific download status.""" + try: + from .models import DownloadStatus + + episodes = [] + for record in self.get_all_media_records(): + for episode in record.media_episodes: + if episode.download_status == status: + episodes.append((record.media_item.id, episode.episode_number)) + return episodes + + except Exception as e: + logger.error(f"Failed to get episodes by status: {e}") + return [] + + def get_download_statistics(self) -> dict: + """Get comprehensive download statistics.""" + try: + from .models import DownloadStatus + + stats = { + "total_episodes": 0, + "downloaded": 0, + "failed": 0, + "queued": 0, + "downloading": 0, + "paused": 0, + "total_size_bytes": 0, + "by_quality": {}, + "by_provider": {}, + } + + for record in self.get_all_media_records(): + for episode in record.media_episodes: + stats["total_episodes"] += 1 + + # Count by status + status_key = episode.download_status.value.lower() + if status_key == "completed": + stats["downloaded"] += 1 + elif status_key == "failed": + stats["failed"] += 1 + elif status_key == "queued": + stats["queued"] += 1 + elif status_key == "downloading": + stats["downloading"] += 1 + elif status_key == "paused": + stats["paused"] += 1 + + # Aggregate file sizes + if episode.file_size: + stats["total_size_bytes"] += episode.file_size + + # Count by quality + if episode.quality: + stats["by_quality"][episode.quality] = ( + stats["by_quality"].get(episode.quality, 0) + 1 + ) + + # Count by provider + if episode.provider_name: + stats["by_provider"][episode.provider_name] = ( + stats["by_provider"].get(episode.provider_name, 0) + 1 + ) + + return stats + + except Exception as e: + logger.error(f"Failed to get download statistics: {e}") + return {} diff --git a/fastanime/libs/selectors/base.py b/fastanime/libs/selectors/base.py index 9fed015..bc4b099 100644 --- a/fastanime/libs/selectors/base.py +++ b/fastanime/libs/selectors/base.py @@ -31,6 +31,50 @@ class BaseSelector(ABC): """ pass + def choose_multiple( + self, + prompt: str, + choices: List[str], + *, + preview: Optional[str] = None, + header: Optional[str] = None, + ) -> List[str]: + """ + Prompts the user to choose multiple items from a list. + Default implementation falls back to single selection. + + Args: + prompt: The message to display to the user. + choices: A list of strings for the user to choose from. + preview: An optional command or string for a preview window. + header: An optional header to display above the choices. + + Returns: + A list of the chosen items. + """ + # Default implementation: single selection in a loop + selected = [] + remaining_choices = choices.copy() + + while remaining_choices: + choice = self.choose( + f"{prompt} (Select multiple, empty to finish)", + remaining_choices + ["[DONE] Finish selection"], + preview=preview, + header=header, + ) + + if not choice or choice == "[DONE] Finish selection": + break + + selected.append(choice) + remaining_choices.remove(choice) + + if not self.confirm(f"Selected: {', '.join(selected)}. Continue selecting?", default=True): + break + + return selected + @abstractmethod def confirm(self, prompt: str, *, default: bool = False) -> bool: """ diff --git a/fastanime/libs/selectors/fzf/selector.py b/fastanime/libs/selectors/fzf/selector.py index 6339765..35555ff 100644 --- a/fastanime/libs/selectors/fzf/selector.py +++ b/fastanime/libs/selectors/fzf/selector.py @@ -53,6 +53,35 @@ class FzfSelector(BaseSelector): return None return result.stdout.strip() + def choose_multiple(self, prompt, choices, *, preview=None, header=None): + """Enhanced multi-selection using fzf's --multi flag.""" + fzf_input = "\n".join(choices) + + commands = [ + self.executable, + "--multi", + "--prompt", + f"{prompt.title()}: ", + "--header", + f"{self.header}\nPress TAB to select multiple items, ENTER to confirm", + "--header-first", + ] + if preview: + commands.extend(["--preview", preview]) + + result = subprocess.run( + commands, + input=fzf_input, + stdout=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + return [] + + # Split the output by newlines and filter out empty strings + selections = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()] + return selections + def confirm(self, prompt, *, default=False): choices = ["Yes", "No"] default_choice = "Yes" if default else "No"