feat: return some original menu options and functionality

This commit is contained in:
Benexl
2025-07-26 19:40:55 +03:00
parent 494104ee19
commit c59babc30d
13 changed files with 345 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = (

View File

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

View File

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

View File

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