mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
feat(menus): intergrate download service and downloads in menus
This commit is contained in:
@@ -261,11 +261,12 @@ def download_anime(
|
||||
episode_title=f"{anime.title}; Episode {episode}",
|
||||
subtitles=[sub.url for sub in server.subtitles],
|
||||
headers=server.headers,
|
||||
vid_format=config.stream.ytdlp_format,
|
||||
vid_format=config.downloads.ytdlp_format,
|
||||
force_unknown_ext=download_options["force_unknown_ext"],
|
||||
verbose=download_options["verbose"],
|
||||
hls_use_mpegts=download_options["hls_use_mpegts"],
|
||||
hls_use_h264=download_options["hls_use_h264"],
|
||||
silent=download_options["silent"],
|
||||
no_check_certificate=config.downloads.no_check_certificate,
|
||||
)
|
||||
)
|
||||
|
||||
91
fastanime/cli/interactive/menu/media/download_episodes.py
Normal file
91
fastanime/cli/interactive/menu/media/download_episodes.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from .....core.utils.fuzzy import fuzz
|
||||
from .....core.utils.normalizer import normalize_title
|
||||
from .....libs.provider.anime.params import AnimeParams, SearchParams
|
||||
from ....service.download.service import DownloadService
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def download_episodes(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""Menu to select and download episodes synchronously."""
|
||||
feedback = ctx.feedback
|
||||
selector = ctx.selector
|
||||
media_item = state.media_api.media_item
|
||||
config = ctx.config
|
||||
provider = ctx.provider
|
||||
|
||||
if not media_item:
|
||||
feedback.error("No media item selected for download.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
media_title = media_item.title.english or media_item.title.romaji
|
||||
if not media_title:
|
||||
feedback.error("Cannot download: Media item has no title.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Step 1: Find the anime on the provider to get a full episode list
|
||||
with feedback.progress(
|
||||
f"Searching for '{media_title}' on {provider.__class__.__name__}..."
|
||||
):
|
||||
provider_search_results = provider.search(
|
||||
SearchParams(
|
||||
query=normalize_title(media_title, config.general.provider.value, True)
|
||||
)
|
||||
)
|
||||
|
||||
if not provider_search_results or not provider_search_results.results:
|
||||
feedback.warning(f"Could not find '{media_title}' on provider.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
provider_results_map = {res.title: res for res in provider_search_results.results}
|
||||
best_match_title = max(
|
||||
provider_results_map.keys(),
|
||||
key=lambda p_title: fuzz.ratio(
|
||||
normalize_title(p_title, config.general.provider.value).lower(),
|
||||
media_title.lower(),
|
||||
),
|
||||
)
|
||||
selected_provider_anime_ref = provider_results_map[best_match_title]
|
||||
|
||||
with feedback.progress(f"Fetching episode list for '{best_match_title}'..."):
|
||||
full_provider_anime = provider.get(
|
||||
AnimeParams(id=selected_provider_anime_ref.id, query=media_title)
|
||||
)
|
||||
|
||||
if not full_provider_anime:
|
||||
feedback.warning(f"Failed to fetch details for '{best_match_title}'.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
available_episodes = getattr(
|
||||
full_provider_anime.episodes, config.stream.translation_type, []
|
||||
)
|
||||
if not available_episodes:
|
||||
feedback.warning("No episodes found for download.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Step 2: Let user select episodes
|
||||
selected_episodes = selector.choose_multiple(
|
||||
"Select episodes to download (TAB to select, ENTER to confirm)",
|
||||
choices=available_episodes,
|
||||
)
|
||||
|
||||
if not selected_episodes:
|
||||
feedback.info("No episodes selected for download.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Step 3: Download episodes synchronously
|
||||
# TODO: move to main ctx
|
||||
download_service = DownloadService(
|
||||
config, ctx.media_registry, ctx.media_api, ctx.provider
|
||||
)
|
||||
|
||||
feedback.info(
|
||||
f"Starting download of {len(selected_episodes)} episodes. This may take a while..."
|
||||
)
|
||||
download_service.download_episodes_sync(media_item, selected_episodes)
|
||||
|
||||
feedback.success(f"Finished downloading {len(selected_episodes)} episodes.")
|
||||
|
||||
# After downloading, return to the media actions menu
|
||||
return InternalDirective.BACK
|
||||
@@ -11,6 +11,7 @@ from .....libs.media_api.types import (
|
||||
UserMediaListStatus,
|
||||
)
|
||||
from .....libs.player.params import PlayerParams
|
||||
from ....service.registry.service import DownloadStatus
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MediaApiState, MenuName, State
|
||||
|
||||
@@ -30,41 +31,70 @@ def media_actions(ctx: Context, state: State) -> State | InternalDirective:
|
||||
return InternalDirective.BACK
|
||||
progress = _get_progress_string(ctx, state.media_api.media_item)
|
||||
|
||||
# TODO: Add media list management
|
||||
# TODO: cross reference for none implemented features
|
||||
# Check for downloaded episodes to conditionally show options
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
has_downloads = False
|
||||
if record:
|
||||
has_downloads = any(
|
||||
ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
for ep in record.media_episodes
|
||||
)
|
||||
|
||||
options: Dict[str, MenuAction] = {
|
||||
f"{'▶️ ' if icons else ''}Stream {progress}": _stream(ctx, state),
|
||||
f"{'📽️ ' if icons else ''}Episodes": _stream(
|
||||
ctx, state, force_episodes_menu=True
|
||||
),
|
||||
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state),
|
||||
f"{'🔗 ' if icons else ''}Recommendations": _view_recommendations(ctx, state),
|
||||
f"{'🔄 ' if icons else ''}Related Anime": _view_relations(ctx, state),
|
||||
f"{'👥 ' if icons else ''}Characters": _view_characters(ctx, state),
|
||||
f"{'📅 ' if icons else ''}Airing Schedule": _view_airing_schedule(ctx, state),
|
||||
f"{'📝 ' if icons else ''}View Reviews": _view_reviews(ctx, state),
|
||||
f"{'➕ ' if icons else ''}Add/Update List": _manage_user_media_list(ctx, state),
|
||||
f"{'⭐ ' if icons else ''}Score Anime": _score_anime(ctx, state),
|
||||
f"{'ℹ️ ' if icons else ''}View Info": _view_info(ctx, state),
|
||||
f"{'📀 ' if icons else ''}Change Provider (Current: {ctx.config.general.provider.value.upper()})": _change_provider(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Select Anime (Current: {ctx.config.general.auto_select_anime_result})": _toggle_config_state(
|
||||
ctx, state, "AUTO_ANIME"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Next Episode (Current: {ctx.config.stream.auto_next})": _toggle_config_state(
|
||||
ctx, state, "AUTO_EPISODE"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Continue From History (Current: {ctx.config.stream.continue_from_watch_history})": _toggle_config_state(
|
||||
ctx, state, "CONTINUE_FROM_HISTORY"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Translation Type (Current: {ctx.config.stream.translation_type.upper()})": _toggle_config_state(
|
||||
ctx, state, "TRANSLATION_TYPE"
|
||||
),
|
||||
f"{'🔙 ' if icons else ''}Back to Results": lambda: InternalDirective.BACK,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
|
||||
if has_downloads:
|
||||
options[f"{'💾 ' if icons else ''}Stream (Downloads)"] = _stream_downloads(
|
||||
ctx, state
|
||||
)
|
||||
options[f"{'💿 ' if icons else ''}Episodes (Downloads)"] = _stream_downloads(
|
||||
ctx, state
|
||||
)
|
||||
|
||||
options.update(
|
||||
{
|
||||
f"{'📥 ' if icons else ''}Download": _queue_downloads(ctx, state),
|
||||
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state),
|
||||
f"{'🔗 ' if icons else ''}Recommendations": _view_recommendations(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🔄 ' if icons else ''}Related Anime": _view_relations(ctx, state),
|
||||
f"{'👥 ' if icons else ''}Characters": _view_characters(ctx, state),
|
||||
f"{'📅 ' if icons else ''}Airing Schedule": _view_airing_schedule(
|
||||
ctx, state
|
||||
),
|
||||
f"{'📝 ' if icons else ''}View Reviews": _view_reviews(ctx, state),
|
||||
f"{'➕ ' if icons else ''}Add/Update List": _manage_user_media_list(
|
||||
ctx, state
|
||||
),
|
||||
f"{'⭐ ' if icons else ''}Score Anime": _score_anime(ctx, state),
|
||||
f"{'ℹ️ ' if icons else ''}View Info": _view_info(ctx, state),
|
||||
f"{'📀 ' if icons else ''}Change Provider (Current: {ctx.config.general.provider.value.upper()})": _change_provider(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Select Anime (Current: {ctx.config.general.auto_select_anime_result})": _toggle_config_state(
|
||||
ctx, state, "AUTO_ANIME"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Next Episode (Current: {ctx.config.stream.auto_next})": _toggle_config_state(
|
||||
ctx, state, "AUTO_EPISODE"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Continue From History (Current: {ctx.config.stream.continue_from_watch_history})": _toggle_config_state(
|
||||
ctx, state, "CONTINUE_FROM_HISTORY"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Translation Type (Current: {ctx.config.stream.translation_type.upper()})": _toggle_config_state(
|
||||
ctx, state, "TRANSLATION_TYPE"
|
||||
),
|
||||
f"{'🔙 ' if icons else ''}Back to Results": lambda: InternalDirective.BACK,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
)
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Action",
|
||||
choices=list(options.keys()),
|
||||
@@ -114,6 +144,20 @@ def _stream(ctx: Context, state: State, force_episodes_menu=False) -> MenuAction
|
||||
return action
|
||||
|
||||
|
||||
def _stream_downloads(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
return State(menu_name=MenuName.PLAY_DOWNLOADS, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _queue_downloads(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
return State(menu_name=MenuName.DOWNLOAD_EPISODES, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _watch_trailer(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
56
fastanime/cli/interactive/menu/media/play_downloads.py
Normal file
56
fastanime/cli/interactive/menu/media/play_downloads.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from .....libs.player.params import PlayerParams
|
||||
from ....service.registry.models import DownloadStatus
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def play_downloads(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""Menu to select and play locally downloaded episodes."""
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
if not media_item:
|
||||
feedback.error("No media item selected.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
if not record or not record.media_episodes:
|
||||
feedback.warning("No downloaded episodes found for this anime.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
downloaded_episodes = {
|
||||
ep.episode_number: ep.file_path
|
||||
for ep in record.media_episodes
|
||||
if ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
}
|
||||
|
||||
if not downloaded_episodes:
|
||||
feedback.warning("No complete downloaded episodes found.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
choices = list(downloaded_episodes.keys()) + ["Back"]
|
||||
chosen_episode = ctx.selector.choose("Select a downloaded episode to play", choices)
|
||||
|
||||
if not chosen_episode or chosen_episode == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
file_path = downloaded_episodes[chosen_episode]
|
||||
|
||||
# Use the player service to play the local file
|
||||
title = f"{media_item.title.english or media_item.title.romaji} - Episode {chosen_episode}"
|
||||
player_result = ctx.player.play(
|
||||
PlayerParams(
|
||||
url=str(file_path),
|
||||
title=title,
|
||||
query=media_item.title.english or media_item.title.romaji or "",
|
||||
episode=chosen_episode,
|
||||
)
|
||||
)
|
||||
|
||||
# Track watch history after playing
|
||||
ctx.watch_history.track(media_item, player_result)
|
||||
|
||||
# Stay on this menu to allow playing another downloaded episode
|
||||
return InternalDirective.RELOAD
|
||||
@@ -44,6 +44,8 @@ class MenuName(Enum):
|
||||
MEDIA_REVIEW = "MEDIA_REVIEW"
|
||||
MEDIA_CHARACTERS = "MEDIA_CHARACTERS"
|
||||
MEDIA_AIRING_SCHEDULE = "MEDIA_AIRING_SCHEDULE"
|
||||
PLAY_DOWNLOADS = "PLAY_DOWNLOADS"
|
||||
DOWNLOAD_EPISODES = "DOWNLOAD_EPISODES"
|
||||
|
||||
|
||||
class StateModel(BaseModel):
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ....core.config.model import AppConfig
|
||||
from ....core.downloader import DownloadParams, DownloadResult, create_downloader
|
||||
from ....core.downloader import DownloadParams, create_downloader
|
||||
from ....core.utils.concurrency import ManagedBackgroundWorker, thread_manager
|
||||
from ....core.utils.fuzzy import fuzz
|
||||
from ....core.utils.normalizer import normalize_title
|
||||
@@ -132,7 +130,6 @@ class DownloadService:
|
||||
media_item.title.english or media_item.title.romaji or "Unknown"
|
||||
)
|
||||
|
||||
# --- START OF FIX: REPLICATE WORKING LOGIC ---
|
||||
# 1. Search the provider to get the provider-specific ID
|
||||
provider_search_title = normalize_title(
|
||||
media_title,
|
||||
@@ -172,8 +169,6 @@ class DownloadService:
|
||||
f"Failed to get full details for '{best_match_title}' from provider."
|
||||
)
|
||||
|
||||
# --- END OF FIX ---
|
||||
|
||||
# 4. Get stream links using the now-validated provider_anime ID
|
||||
streams_iterator = self.provider.episode_streams(
|
||||
EpisodeStreamsParams(
|
||||
@@ -190,8 +185,17 @@ class DownloadService:
|
||||
if not server or not server.links:
|
||||
raise ValueError(f"No stream links found for Episode {episode_number}")
|
||||
|
||||
stream_link = server.links[0]
|
||||
if server.name != self.config.downloads.server.value:
|
||||
while True:
|
||||
try:
|
||||
_server = next(streams_iterator)
|
||||
if _server.name == self.config.downloads.server.value:
|
||||
server = _server
|
||||
break
|
||||
except StopIteration:
|
||||
break
|
||||
|
||||
stream_link = server.links[0]
|
||||
# 5. Perform the download
|
||||
download_params = DownloadParams(
|
||||
url=stream_link.link,
|
||||
@@ -202,6 +206,7 @@ class DownloadService:
|
||||
subtitles=[sub.url for sub in server.subtitles],
|
||||
merge=self.config.downloads.merge_subtitles,
|
||||
clean=self.config.downloads.cleanup_after_merge,
|
||||
no_check_certificate=self.config.downloads.no_check_certificate,
|
||||
)
|
||||
|
||||
result = self.downloader.download(download_params)
|
||||
@@ -223,7 +228,6 @@ class DownloadService:
|
||||
provider_name=self.config.general.provider.value,
|
||||
server_name=server.name,
|
||||
subtitle_paths=result.subtitle_paths,
|
||||
download_date=datetime.now(),
|
||||
)
|
||||
logger.info(
|
||||
f"Successfully downloaded Episode {episode_number} of '{media_title}'"
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Literal, Optional
|
||||
from typing import Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from ....libs.media_api.types import (
|
||||
from .models import (
|
||||
REGISTRY_VERSION,
|
||||
DownloadStatus,
|
||||
MediaEpisode,
|
||||
MediaRecord,
|
||||
MediaRegistryIndex,
|
||||
MediaRegistryIndexEntry,
|
||||
|
||||
Reference in New Issue
Block a user