diff --git a/fastanime/cli/interactive/menu/media/episodes.py b/fastanime/cli/interactive/menu/media/episodes.py index 6c96c4f..63f67c0 100644 --- a/fastanime/cli/interactive/menu/media/episodes.py +++ b/fastanime/cli/interactive/menu/media/episodes.py @@ -29,12 +29,12 @@ def episodes(ctx: Context, state: State) -> State | InternalDirective: return InternalDirective.BACKX2 chosen_episode: str | None = None + start_time: str | None = None if config.stream.continue_from_watch_history: - # TODO: implement watch history logic - pass + chosen_episode, start_time = ctx.watch_history.get_episode(media_item) - if not chosen_episode: + if not chosen_episode or ctx.switch.show_episodes_menu: choices = [*sorted(available_episodes, key=float), "Back"] preview_command = None @@ -68,16 +68,10 @@ def episodes(ctx: Context, state: State) -> State | InternalDirective: chosen_episode = chosen_episode_str - # Track episode selection in watch history (if enabled in config) - if ( - config.stream.continue_from_watch_history - and config.stream.preferred_watch_history == "local" - ): - # TODO: implement watch history logic - pass - return State( menu_name=MenuName.SERVERS, media_api=state.media_api, - provider=state.provider.model_copy(update={"episode": chosen_episode}), + provider=state.provider.model_copy( + update={"episode": chosen_episode, "start_time": start_time} + ), ) diff --git a/fastanime/cli/interactive/menu/media/media_actions.py b/fastanime/cli/interactive/menu/media/media_actions.py index 3ea0da0..3abf1fb 100644 --- a/fastanime/cli/interactive/menu/media/media_actions.py +++ b/fastanime/cli/interactive/menu/media/media_actions.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict +from typing import Callable, Dict, Literal, Optional from rich.console import Console @@ -9,7 +9,7 @@ from .....libs.media_api.params import ( MediaRelationsParams, UpdateUserMediaListEntryParams, ) -from .....libs.media_api.types import UserMediaListStatus +from .....libs.media_api.types import MediaItem, MediaStatus, UserMediaListStatus from .....libs.player.params import PlayerParams from ...session import Context, session from ...state import InternalDirective, MediaApiState, MenuName, State @@ -28,11 +28,15 @@ def media_actions(ctx: Context, state: State) -> State | InternalDirective: if not media_item: feedback.error("Media item is not in state") 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 options: Dict[str, MenuAction] = { - f"{'â–ļī¸ ' if icons else ''}Stream": _stream(ctx, state), + 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), @@ -41,6 +45,19 @@ def media_actions(ctx: Context, state: State) -> State | InternalDirective: 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": _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, } @@ -55,8 +72,39 @@ def media_actions(ctx: Context, state: State) -> State | InternalDirective: return InternalDirective.BACK -def _stream(ctx: Context, state: State) -> MenuAction: +def _get_progress_string(ctx: Context, media_item: Optional[MediaItem]) -> str: + if not media_item: + return "" + config = ctx.config + + progress = "0" + + if media_item.user_status: + progress = str(media_item.user_status.progress or 0) + + episodes_total = str(media_item.episodes or "??") + display_title = f"({progress} of {episodes_total})" + + # Add a visual indicator for new episodes if applicable + if ( + media_item.status == MediaStatus.RELEASING + and media_item.next_airing + and media_item.user_status + and media_item.user_status.status == UserMediaListStatus.WATCHING + ): + last_aired = media_item.next_airing.episode - 1 + unwatched = last_aired - (media_item.user_status.progress or 0) + if unwatched > 0: + icon = "🔹" if config.general.icons else "!" + display_title += f" {icon}{unwatched} new{icon}" + + return display_title + + +def _stream(ctx: Context, state: State, force_episodes_menu=False) -> MenuAction: def action(): + if force_episodes_menu: + ctx.switch.force_episodes_menu() return State(menu_name=MenuName.PROVIDER_SEARCH, media_api=state.media_api) return action @@ -120,6 +168,47 @@ def _manage_user_media_list(ctx: Context, state: State) -> MenuAction: return action +def _change_provider(ctx: Context, state: State) -> MenuAction: + def action(): + from .....libs.provider.anime.types import ProviderName + + new_provider = ctx.selector.choose( + "Select Provider", [provider.value for provider in ProviderName] + ) + ctx.config.general.provider = ProviderName(new_provider) + return InternalDirective.RELOAD + + return action + + +def _toggle_config_state( + ctx: Context, + state: State, + config_state: Literal[ + "AUTO_ANIME", "AUTO_EPISODE", "CONTINUE_FROM_HISTORY", "TRANSLATION_TYPE" + ], +) -> MenuAction: + def action(): + match config_state: + case "AUTO_ANIME": + ctx.config.general.auto_select_anime_result = ( + not ctx.config.general.auto_select_anime_result + ) + case "AUTO_EPISODE": + ctx.config.stream.auto_next = not ctx.config.stream.auto_next + case "CONTINUE_FROM_HISTORY": + ctx.config.stream.continue_from_watch_history = ( + not ctx.config.stream.continue_from_watch_history + ) + case "TRANSLATION_TYPE": + ctx.config.stream.translation_type = ( + "sub" if ctx.config.stream.translation_type == "dub" else "dub" + ) + return InternalDirective.RELOAD + + return action + + def _score_anime(ctx: Context, state: State) -> MenuAction: def action(): feedback = ctx.feedback @@ -160,12 +249,13 @@ def _view_info(ctx: Context, state: State) -> MenuAction: if not media_item: return InternalDirective.RELOAD + import re + from rich import box from rich.columns import Columns from rich.panel import Panel from rich.table import Table from rich.text import Text - import re from ....utils import image @@ -532,8 +622,8 @@ def _view_characters(ctx: Context, state: State) -> MenuAction: # Display characters using rich from rich.console import Console - from rich.table import Table from rich.panel import Panel + from rich.table import Table from rich.text import Text console = Console() @@ -621,12 +711,13 @@ def _view_airing_schedule(ctx: Context, state: State) -> MenuAction: return InternalDirective.RELOAD # Display schedule using rich - from rich.console import Console - from rich.table import Table - from rich.panel import Panel - from rich.text import Text from datetime import datetime + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + console = Console() console.clear() diff --git a/fastanime/cli/interactive/menu/media/player_controls.py b/fastanime/cli/interactive/menu/media/player_controls.py index 2d87815..cdd6004 100644 --- a/fastanime/cli/interactive/menu/media/player_controls.py +++ b/fastanime/cli/interactive/menu/media/player_controls.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Union +from typing import Callable, Dict, Literal, Union from ...session import Context, session from ...state import InternalDirective, MenuName, State @@ -42,9 +42,7 @@ def player_controls(ctx: Context, state: State) -> Union[State, InternalDirectiv return State( menu_name=MenuName.SERVERS, media_api=state.media_api, - provider=state.provider.model_copy( - update={"episode_number": next_episode_num} - ), + provider=state.provider.model_copy(update={"episode": next_episode_num}), ) # --- Menu Options --- @@ -53,15 +51,22 @@ def player_controls(ctx: Context, state: State) -> Union[State, InternalDirectiv if current_index < len(available_episodes) - 1: options[f"{'â­ī¸ ' if icons else ''}Next Episode"] = _next_episode(ctx, state) + if current_index: + options[f"{'âĒ ' if icons else ''}Previous Episode"] = _previous_episode( + ctx, state + ) options.update( { - f"{'🔄 ' if icons else ''}Replay Episode": _replay(ctx, state), - f"{'đŸ’ģ ' if icons else ''}Change Server": _change_server(ctx, state), - f"{'đŸŽžī¸ ' if icons else ''}Back to Episode List": lambda: State( - menu_name=MenuName.EPISODES, - media_api=state.media_api, - provider=state.provider, + f"{'🔂 ' if icons else ''}Replay": _replay(ctx, state), + f"{'đŸ’Ŋ ' if icons else ''}Change Server": _change_server(ctx, state), + f"{'📀 ' if icons else ''}Change Quality": _change_quality(ctx, state), + f"{'đŸŽžī¸ ' if icons else ''}Episode List": lambda: _episodes_list(ctx, state), + 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 Translation Type (Current: {ctx.config.stream.translation_type.upper()})": _toggle_config_state( + ctx, state, "TRANSLATION_TYPE" ), f"{'🏠 ' if icons else ''}Main Menu": lambda: State( menu_name=MenuName.MAIN @@ -81,7 +86,6 @@ def player_controls(ctx: Context, state: State) -> Union[State, InternalDirectiv def _next_episode(ctx: Context, state: State) -> MenuAction: def action(): feedback = ctx.feedback - feedback.clear_console() config = ctx.config @@ -113,7 +117,41 @@ def _next_episode(ctx: Context, state: State) -> MenuAction: menu_name=MenuName.SERVERS, media_api=state.media_api, provider=state.provider.model_copy( - update={"episode_number": next_episode_num} + update={"episode": next_episode_num} + ), + ) + feedback.warning("This is the last available episode.") + return InternalDirective.RELOAD + + return action + + +def _previous_episode(ctx: Context, state: State) -> MenuAction: + def action(): + feedback = ctx.feedback + + config = ctx.config + + provider_anime = state.provider.anime + current_episode_num = state.provider.episode + + if not provider_anime or not current_episode_num: + feedback.error("Player state is incomplete. Returning.") + return InternalDirective.BACK + + available_episodes = getattr( + provider_anime.episodes, config.stream.translation_type, [] + ) + current_index = available_episodes.index(current_episode_num) + + if current_index: + prev_episode_num = available_episodes[current_index - 1] + + return State( + menu_name=MenuName.SERVERS, + media_api=state.media_api, + provider=state.provider.model_copy( + update={"episode": prev_episode_num} ), ) feedback.warning("This is the last available episode.") @@ -129,10 +167,39 @@ def _replay(ctx: Context, state: State) -> MenuAction: return action +def _toggle_config_state( + ctx: Context, + state: State, + config_state: Literal[ + "AUTO_ANIME", "AUTO_EPISODE", "CONTINUE_FROM_HISTORY", "TRANSLATION_TYPE" + ], +) -> MenuAction: + def action(): + match config_state: + case "AUTO_ANIME": + ctx.config.general.auto_select_anime_result = ( + not ctx.config.general.auto_select_anime_result + ) + case "AUTO_EPISODE": + ctx.config.stream.auto_next = not ctx.config.stream.auto_next + case "CONTINUE_FROM_HISTORY": + ctx.config.stream.continue_from_watch_history = ( + not ctx.config.stream.continue_from_watch_history + ) + case "TRANSLATION_TYPE": + ctx.config.stream.translation_type = ( + "sub" if ctx.config.stream.translation_type == "dub" else "dub" + ) + return InternalDirective.RELOAD + + return action + + def _change_server(ctx: Context, state: State) -> MenuAction: def action(): + from .....libs.provider.anime.types import ProviderServer + feedback = ctx.feedback - feedback.clear_console() selector = ctx.selector @@ -156,13 +223,37 @@ def _change_server(ctx: Context, state: State) -> MenuAction: "Select a different server:", list(server_map.keys()) ) if new_server_name: - return State( - menu_name=MenuName.SERVERS, - media_api=state.media_api, - provider=state.provider.model_copy( - update={"selected_server": server_map[new_server_name]} - ), - ) + ctx.config.stream.server = ProviderServer(new_server_name) + return InternalDirective.RELOAD + + return action + + +def _episodes_list(ctx: Context, state: State) -> MenuAction: + def action(): + ctx.switch.force_episodes_menu() + return InternalDirective.BACK + + return action + + +def _change_quality(ctx: Context, state: State) -> MenuAction: + def action(): + feedback = ctx.feedback + + selector = ctx.selector + + server_map = state.provider.servers + + if not server_map: + feedback.error("Player state is incomplete. Returning.") + return InternalDirective.BACK + + new_quality = selector.choose( + "Select a different server:", list(["360", "480", "720", "1080"]) + ) + if new_quality: + ctx.config.stream.quality = new_quality # type:ignore return InternalDirective.RELOAD return action diff --git a/fastanime/cli/interactive/menu/media/results.py b/fastanime/cli/interactive/menu/media/results.py index 0db6699..9ee2adb 100644 --- a/fastanime/cli/interactive/menu/media/results.py +++ b/fastanime/cli/interactive/menu/media/results.py @@ -31,7 +31,7 @@ def results(ctx: Context, state: State) -> State | InternalDirective: if page_info.has_next_page: choices.update( { - f"{'âžĄī¸ ' if ctx.config.general.icons else ''}Next Page (Page {page_info.current_page + 1})": lambda: _handle_pagination( + "Next Page (Page {page_info.current_page + 1})": lambda: _handle_pagination( ctx, state, 1 ) } @@ -39,7 +39,7 @@ def results(ctx: Context, state: State) -> State | InternalDirective: if page_info.current_page > 1: choices.update( { - f"{'âŦ…ī¸ ' if ctx.config.general.icons else ''}Previous Page (Page {page_info.current_page - 1})": lambda: _handle_pagination( + "Previous Page (Page {page_info.current_page - 1})": lambda: _handle_pagination( ctx, state, -1 ) } diff --git a/fastanime/cli/interactive/menu/media/servers.py b/fastanime/cli/interactive/menu/media/servers.py index c4039b7..579b5e3 100644 --- a/fastanime/cli/interactive/menu/media/servers.py +++ b/fastanime/cli/interactive/menu/media/servers.py @@ -86,6 +86,7 @@ def servers(ctx: Context, state: State) -> State | InternalDirective: title=final_title, subtitles=[sub.url for sub in selected_server.subtitles], headers=selected_server.headers, + start_time=state.provider.start_time, ) ) if media_item and episode_number: diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index 31f79a4..c3d4a5b 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -1,7 +1,7 @@ import importlib.util import logging import os -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Callable, List, Optional, Union import click @@ -27,9 +27,49 @@ logger = logging.getLogger(__name__) MENUS_DIR = APP_DIR / "cli" / "interactive" / "menu" +@dataclass +class Switch: + "Forces menus to show selector and not just pass through,once viewed it auto sets back to false" + + _provider_results: bool = False + _episodes: bool = False + _servers: bool = False + + @property + def show_provider_results_menu(self): + if self._provider_results: + self._provider_results = False + return True + return False + + def force_provider_results_menu(self): + self._provider_results = True + + @property + def show_episodes_menu(self): + if self._episodes: + self._episodes = False + return True + return False + + def force_episodes_menu(self): + self._episodes = True + + @property + def servers(self): + if self._servers: + self._servers = False + return True + return False + + def force_servers_menu(self): + self._servers = True + + @dataclass class Context: config: "AppConfig" + switch: Switch = field(default_factory=Switch) _provider: Optional["BaseAnimeProvider"] = None _selector: Optional["BaseSelector"] = None _player: Optional["BasePlayer"] = None diff --git a/fastanime/cli/interactive/state.py b/fastanime/cli/interactive/state.py index b74326f..c1d7f4f 100644 --- a/fastanime/cli/interactive/state.py +++ b/fastanime/cli/interactive/state.py @@ -65,6 +65,7 @@ class ProviderState(StateModel): episode: Optional[str] = None servers: Optional[Dict[str, Server]] = None server_name: Optional[str] = None + start_time: Optional[str] = None @property def server(self) -> Optional[Server]: diff --git a/fastanime/cli/service/watch_history/service.py b/fastanime/cli/service/watch_history/service.py index b44fb86..4ad405d 100644 --- a/fastanime/cli/service/watch_history/service.py +++ b/fastanime/cli/service/watch_history/service.py @@ -44,6 +44,50 @@ class WatchHistoryService: ) ) + def get_episode(self, media_item: MediaItem): + index_entry = self.media_registry.get_media_index_entry(media_item.id) + current_remote_episode = None + current_local_episode = None + start_time = None + episode = None + + if media_item.user_status: + # TODO: change mediaa item progress to a string + current_remote_episode = str(media_item.user_status.progress) + if index_entry: + current_local_episode = index_entry.progress + start_time = index_entry.last_watch_position + total_duration = index_entry.total_duration + if start_time and total_duration and current_local_episode: + from ....core.utils.converter import calculate_completion_percentage + + if ( + calculate_completion_percentage(start_time, total_duration) + >= self.config.stream.episode_complete_at + ): + start_time = None + try: + current_local_episode = str(int(current_local_episode) + 1) + except: + # incase its a float + pass + else: + current_local_episode = current_remote_episode + if not media_item.user_status: + current_remote_episode = current_local_episode + if current_local_episode != current_remote_episode: + if self.config.general.preferred_tracker == "local": + episode = current_local_episode + else: + episode = current_remote_episode + else: + episode = current_local_episode + + # TODO: check if start time is mostly complete and increment the episode + if episode == "0": + episode = "1" + return episode, start_time + def update( self, media_item: MediaItem, diff --git a/fastanime/core/config/defaults.py b/fastanime/core/config/defaults.py index 6efaa39..ac8a93b 100644 --- a/fastanime/core/config/defaults.py +++ b/fastanime/core/config/defaults.py @@ -3,6 +3,7 @@ from ..constants import APP_DATA_DIR, DEFAULTS_DIR, USER_VIDEOS_DIR # GeneralConfig GENERAL_PYGMENT_STYLE = "github-dark" GENERAL_API_CLIENT = "anilist" +GENERAL_PREFERRED_TRACKER = "local" GENERAL_PROVIDER = "allanime" GENERAL_SELECTOR = "default" GENERAL_AUTO_SELECT_ANIME_RESULT = True diff --git a/fastanime/core/config/descriptions.py b/fastanime/core/config/descriptions.py index c50d57a..89f1698 100644 --- a/fastanime/core/config/descriptions.py +++ b/fastanime/core/config/descriptions.py @@ -3,6 +3,9 @@ from .defaults import SESSIONS_DIR GENERAL_PYGMENT_STYLE = "The pygment style to use" GENERAL_API_CLIENT = "The media database API to use (e.g., 'anilist', 'jikan')." +GENERAL_PREFERRED_TRACKER = ( + "The preferred watch history tracker (local,remote) in cases of conflicts" +) GENERAL_PROVIDER = "The default anime provider to use for scraping." GENERAL_SELECTOR = "The interactive selector tool to use for menus." GENERAL_AUTO_SELECT_ANIME_RESULT = ( diff --git a/fastanime/core/config/model.py b/fastanime/core/config/model.py index 5b95151..8d0bb78 100644 --- a/fastanime/core/config/model.py +++ b/fastanime/core/config/model.py @@ -14,6 +14,10 @@ from . import descriptions as desc class GeneralConfig(BaseModel): """Configuration for general application behavior and integrations.""" + preferred_tracker: Literal["local", "remote"] = Field( + default=defaults.GENERAL_PREFERRED_TRACKER, + description=desc.GENERAL_PREFERRED_TRACKER, + ) pygment_style: str = Field( default=defaults.GENERAL_PYGMENT_STYLE, description=desc.GENERAL_PYGMENT_STYLE ) diff --git a/fastanime/core/utils/converter.py b/fastanime/core/utils/converter.py index bc57c4b..51826dd 100644 --- a/fastanime/core/utils/converter.py +++ b/fastanime/core/utils/converter.py @@ -8,3 +8,32 @@ def time_to_seconds(time_str: str) -> int: except (ValueError, AttributeError): pass return 0 + + +def calculate_completion_percentage(last_watch_time: str, total_duration: str) -> float: + """ + Calculates the percentage completion based on last watch time and total duration. + + Args: + last_watch_time: A string representing the last watched time in 'HH:MM:SS' format. + total_duration: A string representing the total duration in 'HH:MM:SS' format. + + Returns: + A float representing the percentage completion (0.0 to 100.0). + Returns 0.0 if total_duration is '00:00:00'. + Caps the percentage at 100.0 if last_watch_time exceeds total_duration. + + Raises: + ValueError: If the input time strings are not in the expected format. + """ + last_watch_seconds = time_to_seconds(last_watch_time) + total_duration_seconds = time_to_seconds(total_duration) + + if total_duration_seconds == 0: + return 0.0 # Avoid division by zero, return 0% for zero duration + + # Calculate raw percentage + percentage = (last_watch_seconds / total_duration_seconds) * 100.0 + + # Ensure percentage does not exceed 100% + return min(percentage, 100.0) diff --git a/fastanime/libs/player/types.py b/fastanime/libs/player/types.py index 02b04cb..3253237 100644 --- a/fastanime/libs/player/types.py +++ b/fastanime/libs/player/types.py @@ -11,5 +11,6 @@ class PlayerResult: total_time: The total duration of the media (e.g., "00:23:45"). """ + episode: str | None = None stop_time: str | None = None total_time: str | None = None