mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-02-04 11:07:48 -08:00
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:
836
fastanime/cli/commands/anilist/commands/download.py
Normal file
836
fastanime/cli/commands/anilist/commands/download.py
Normal 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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class Services:
|
||||
watch_history: WatchHistoryService
|
||||
session: SessionsService
|
||||
auth: AuthService
|
||||
download: "DownloadService"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
3
fastanime/cli/service/download/__init__.py
Normal file
3
fastanime/cli/service/download/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .service import DownloadService
|
||||
|
||||
__all__ = ["DownloadService"]
|
||||
480
fastanime/cli/service/download/service.py
Normal file
480
fastanime/cli/service/download/service.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user