Add AniList download command and download service integration

- Implemented a new command for downloading anime episodes using the AniList API.
- Created a DownloadService to manage episode downloads and track their status in the media registry.
- Added comprehensive command-line options for filtering and selecting anime based on various criteria.
- Integrated feedback mechanisms to inform users about download progress and issues.
- Established validation for command options to ensure correct input.
- Enhanced error handling and logging for better debugging and user experience.
- Included functionality for managing multiple downloads concurrently.
This commit is contained in:
Benexl
2025-07-25 00:38:07 +03:00
parent 5246a2fc4b
commit f4e73c3335
10 changed files with 1560 additions and 424 deletions

View File

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

View File

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

View File

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