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

View File

@@ -33,6 +33,7 @@ class Services:
watch_history: WatchHistoryService
session: SessionsService
auth: AuthService
download: "DownloadService"
@dataclass(frozen=True)

View File

@@ -0,0 +1,3 @@
from .service import DownloadService
__all__ = ["DownloadService"]

View 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

View File

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

View File

@@ -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 {}

View File

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

View File

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