mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-30 22:50:45 -08:00
762 lines
29 KiB
Python
762 lines
29 KiB
Python
"""
|
|
IPC-based MPV Player implementation for FastAnime.
|
|
This provides advanced features like episode navigation, quality switching, and auto-next.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from queue import Empty, Queue
|
|
from typing import Any, Callable, Dict, List, Literal, Optional
|
|
|
|
from .....core.config.model import StreamConfig
|
|
from .....core.exceptions import FastAnimeError
|
|
from .....core.utils import formatter
|
|
from .....libs.aniskip.api import AniSkip, SkipTimeResult
|
|
from .....libs.media_api.types import MediaItem
|
|
from .....libs.player.base import BasePlayer
|
|
from .....libs.player.params import PlayerParams
|
|
from .....libs.player.types import PlayerResult
|
|
from .....libs.provider.anime.base import BaseAnimeProvider
|
|
from .....libs.provider.anime.params import EpisodeStreamsParams
|
|
from .....libs.provider.anime.types import Anime, ProviderServer, Server
|
|
from ....service.registry.models import DownloadStatus
|
|
from ...registry import MediaRegistryService
|
|
from .base import BaseIPCPlayer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MPVIPCError(FastAnimeError):
|
|
"""Exception raised for MPV IPC communication errors."""
|
|
|
|
pass
|
|
|
|
|
|
class MPVIPCClient:
|
|
"""Client for communicating with MPV via IPC socket with a dedicated reader thread."""
|
|
|
|
def __init__(self, socket_path: str):
|
|
self.socket_path = socket_path
|
|
self.socket: Optional[socket.socket] = None
|
|
self._request_id_counter = 0
|
|
self._lock = threading.Lock()
|
|
|
|
self._reader_thread: Optional[threading.Thread] = None
|
|
self._stop_event = threading.Event()
|
|
self._message_buffer = b""
|
|
|
|
self._event_queue: Queue = Queue()
|
|
self._response_dict: Dict[int, Any] = {}
|
|
self._response_events: Dict[int, threading.Event] = {}
|
|
|
|
def connect(self, timeout: float = 5.0) -> None:
|
|
"""Connect to MPV IPC socket and start the reader thread."""
|
|
start_time = time.time()
|
|
while time.time() - start_time < timeout:
|
|
try:
|
|
self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.socket.connect(self.socket_path)
|
|
logger.info(f"Connected to MPV IPC socket at {self.socket_path}")
|
|
self._start_reader_thread()
|
|
return
|
|
except (ConnectionRefusedError, FileNotFoundError, OSError):
|
|
time.sleep(0.2)
|
|
raise MPVIPCError(f"Failed to connect to MPV IPC socket at {self.socket_path}")
|
|
|
|
def disconnect(self) -> None:
|
|
"""Disconnect from MPV IPC socket and stop the reader thread."""
|
|
self._stop_event.set()
|
|
if self._reader_thread and self._reader_thread.is_alive():
|
|
self._reader_thread.join(timeout=2.0)
|
|
if self.socket:
|
|
try:
|
|
self.socket.close()
|
|
except Exception:
|
|
pass
|
|
self.socket = None
|
|
|
|
def _start_reader_thread(self):
|
|
"""Starts the background thread to read messages from the socket."""
|
|
self._stop_event.clear()
|
|
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
self._reader_thread.start()
|
|
|
|
def _read_loop(self):
|
|
"""Continuously reads data from the socket and processes messages."""
|
|
while not self._stop_event.is_set():
|
|
try:
|
|
if not self.socket:
|
|
break
|
|
# A blocking recv is efficient as the thread will sleep until data is available.
|
|
data = self.socket.recv(4096)
|
|
if not data:
|
|
logger.info("MPV IPC socket closed.")
|
|
# Put a special event to signal the main loop that MPV has shut down.
|
|
self._event_queue.put({"event": "shutdown"})
|
|
break
|
|
|
|
self._message_buffer += data
|
|
self._process_buffer()
|
|
except (socket.timeout, BlockingIOError):
|
|
continue
|
|
except Exception as e:
|
|
if not self._stop_event.is_set():
|
|
logger.error(f"Error in IPC read loop: {e}")
|
|
break
|
|
|
|
def _process_buffer(self):
|
|
"""Processes the internal buffer to extract full JSON messages."""
|
|
while b"\n" in self._message_buffer:
|
|
message_data, self._message_buffer = self._message_buffer.split(b"\n", 1)
|
|
if not message_data:
|
|
continue
|
|
|
|
try:
|
|
message = json.loads(message_data.decode("utf-8"))
|
|
# Responses have a 'request_id' and 'error' field, events do not.
|
|
if "request_id" in message and "error" in message:
|
|
req_id = message["request_id"]
|
|
with self._lock:
|
|
self._response_dict[req_id] = message
|
|
if req_id in self._response_events:
|
|
self._response_events[req_id].set()
|
|
else: # It's an event
|
|
self._event_queue.put(message)
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
logger.warning(
|
|
f"Failed to decode MPV message: {message_data[:100]}... Error: {e}"
|
|
)
|
|
|
|
def get_event(self, block: bool = True, timeout: Optional[float] = None) -> Any:
|
|
"""Retrieves an event from the event queue."""
|
|
try:
|
|
return self._event_queue.get(block=block, timeout=timeout)
|
|
except Empty:
|
|
return None
|
|
|
|
def send_command(self, command: List[Any], timeout: float = 5.0) -> Dict[str, Any]:
|
|
"""Send a command and wait for a specific response."""
|
|
if not self.socket:
|
|
raise MPVIPCError("Not connected to MPV")
|
|
|
|
with self._lock:
|
|
self._request_id_counter += 1
|
|
request_id = self._request_id_counter
|
|
|
|
request = {"command": command, "request_id": request_id}
|
|
|
|
response_event = threading.Event()
|
|
self._response_events[request_id] = response_event
|
|
|
|
try:
|
|
message = json.dumps(request) + "\n"
|
|
self.socket.sendall(message.encode("utf-8"))
|
|
|
|
if response_event.wait(timeout=timeout):
|
|
with self._lock:
|
|
return self._response_dict.pop(request_id, {})
|
|
else:
|
|
raise MPVIPCError(f"Timeout waiting for response to command: {command}")
|
|
finally:
|
|
with self._lock:
|
|
self._response_events.pop(request_id, None)
|
|
|
|
|
|
@dataclass
|
|
class PlayerState:
|
|
"""Represents the dynamic state of the media player."""
|
|
|
|
stream_config: StreamConfig
|
|
query: str
|
|
episode: str
|
|
servers: Dict[ProviderServer, Server] = field(default_factory=dict)
|
|
server_name: Optional[ProviderServer] = None
|
|
media_item: Optional[MediaItem] = None
|
|
stop_time_secs: float = 0
|
|
total_time_secs: float = 0
|
|
|
|
@property
|
|
def episode_title(self) -> str:
|
|
if self.media_item:
|
|
if (
|
|
self.media_item.streaming_episodes
|
|
and self.episode in self.media_item.streaming_episodes
|
|
):
|
|
return (
|
|
self.media_item.streaming_episodes[self.episode].title
|
|
or f"Episode {self.episode}"
|
|
)
|
|
return f"{self.media_item.title.english or self.media_item.title.romaji} - Episode {self.episode}"
|
|
if server := self.server:
|
|
return server.episode_title or f"Episode {self.episode}"
|
|
return f"Episode {self.episode}"
|
|
|
|
@property
|
|
def server(self) -> Optional[Server]:
|
|
if not self.servers:
|
|
logger.warning("Attempt to access server when servers are unavailable.")
|
|
return None
|
|
|
|
server_name = self.stream_config.server
|
|
if server_name not in self.servers:
|
|
if self.server_name and self.server_name in self.servers:
|
|
server_name = self.server_name
|
|
else:
|
|
server_name = list(self.servers.keys())[0]
|
|
self.server_name = server_name
|
|
|
|
return self.servers.get(server_name)
|
|
|
|
@property
|
|
def stream_url(self) -> Optional[str]:
|
|
if server := self.server:
|
|
# Simple quality selection for now
|
|
return server.links[0].link
|
|
return None
|
|
|
|
@property
|
|
def stream_subtitles(self) -> List[str]:
|
|
return [sub.url for sub in self.server.subtitles] if self.server else []
|
|
|
|
@property
|
|
def stream_headers(self) -> Dict[str, str]:
|
|
return self.server.headers if self.server else {}
|
|
|
|
@property
|
|
def stop_time(self) -> Optional[str]:
|
|
return (
|
|
formatter.format_time(self.stop_time_secs)
|
|
if self.stop_time_secs > 0
|
|
else None
|
|
)
|
|
|
|
@property
|
|
def total_time(self) -> Optional[str]:
|
|
return (
|
|
formatter.format_time(self.total_time_secs)
|
|
if self.total_time_secs > 0
|
|
else None
|
|
)
|
|
|
|
def reset(self):
|
|
self.stop_time_secs = 0
|
|
self.total_time_secs = 0
|
|
|
|
|
|
class MpvIPCPlayer(BaseIPCPlayer):
|
|
"""MPV Player implementation using IPC for advanced features."""
|
|
|
|
_skip_times: Optional[SkipTimeResult] = None
|
|
_skipped_ids: set[str] = set() # To prevent re-skipping the same segment
|
|
stream_config: StreamConfig
|
|
mpv_process: subprocess.Popen
|
|
ipc_client: MPVIPCClient
|
|
player_state: PlayerState
|
|
player_fetching: bool = False
|
|
player_first_run: bool = True
|
|
event_handlers: Dict[str, List[Callable]] = {}
|
|
property_observers: Dict[str, List[Callable]] = {}
|
|
key_bindings: Dict[str, Callable] = {}
|
|
message_handlers: Dict[str, Callable] = {}
|
|
provider: Optional[BaseAnimeProvider] = None
|
|
anime: Optional[Anime] = None
|
|
media_item: Optional[MediaItem] = None
|
|
|
|
registry: Optional[MediaRegistryService] = None
|
|
|
|
def __init__(self, stream_config: StreamConfig):
|
|
super().__init__(stream_config)
|
|
self.socket_path: Optional[str] = None
|
|
self._fetch_thread: Optional[threading.Thread] = None
|
|
self._fetch_result_queue: Queue = Queue()
|
|
|
|
def play(
|
|
self,
|
|
player: BasePlayer,
|
|
player_params: PlayerParams,
|
|
provider: Optional[BaseAnimeProvider] = None,
|
|
anime: Optional[Anime] = None,
|
|
registry: Optional[MediaRegistryService] = None,
|
|
media_item: Optional[MediaItem] = None,
|
|
) -> PlayerResult:
|
|
self._skip_times = None # Reset on each new play call
|
|
self._skipped_ids = set()
|
|
|
|
self.provider = provider
|
|
self.anime = anime
|
|
self.media_item = media_item
|
|
self.registry = registry
|
|
self.player_state = PlayerState(
|
|
self.stream_config,
|
|
player_params.query,
|
|
player_params.episode,
|
|
media_item=media_item,
|
|
)
|
|
|
|
return self._play_with_ipc(player, player_params)
|
|
|
|
def _play_with_ipc(self, player: BasePlayer, params: PlayerParams) -> PlayerResult:
|
|
"""Play media using MPV IPC."""
|
|
try:
|
|
self._start_mpv_process(player, params)
|
|
self._connect_ipc()
|
|
self._setup_event_handling()
|
|
self._setup_key_bindings()
|
|
self._setup_message_handlers()
|
|
self._wait_for_playback()
|
|
|
|
return PlayerResult(
|
|
episode=self.player_state.episode,
|
|
stop_time=self.player_state.stop_time,
|
|
total_time=self.player_state.total_time,
|
|
)
|
|
except MPVIPCError as e:
|
|
logger.warning(
|
|
f"IPC connection failed: {e}. Falling back to non-IPC playback."
|
|
)
|
|
if (
|
|
input("Failed to play with IPC. Continue without it? (Y/n): ").lower()
|
|
!= "n"
|
|
):
|
|
return player.play(params)
|
|
else:
|
|
return PlayerResult(
|
|
episode=params.episode, stop_time=None, total_time=None
|
|
)
|
|
finally:
|
|
self._cleanup()
|
|
|
|
def _start_mpv_process(self, player: BasePlayer, params: PlayerParams) -> None:
|
|
"""Start MPV process with IPC enabled."""
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
self.socket_path = str(temp_dir / f"mpv_ipc_{time.time()}.sock")
|
|
self.mpv_process = player.play_with_ipc(params, self.socket_path)
|
|
time.sleep(1.0)
|
|
|
|
def _connect_ipc(self):
|
|
if not self.socket_path:
|
|
raise MPVIPCError("Socket path not set")
|
|
self.ipc_client = MPVIPCClient(self.socket_path)
|
|
self.ipc_client.connect()
|
|
|
|
def _setup_event_handling(self):
|
|
if not self.ipc_client:
|
|
return
|
|
self.ipc_client.send_command(["request_log_messages", "info"])
|
|
self.ipc_client.send_command(["observe_property", 1, "time-pos"])
|
|
self.ipc_client.send_command(["observe_property", 2, "duration"])
|
|
self.ipc_client.send_command(["observe_property", 3, "percent-pos"])
|
|
self.ipc_client.send_command(["observe_property", 4, "filename"])
|
|
|
|
def _bind_key(self, key, command, description):
|
|
if not self.ipc_client:
|
|
return
|
|
try:
|
|
response = self.ipc_client.send_command(["keybind", key, command])
|
|
if response.get("error") != "success":
|
|
logger.warning(f"Failed to bind key {key}: {response.get('error')}")
|
|
self._show_text(f"Error binding '{description}' key", duration=3000)
|
|
except Exception as e:
|
|
logger.error(f"Exception binding key {key}: {e}")
|
|
|
|
def _setup_key_bindings(self):
|
|
key_bindings = {
|
|
"shift+n": ("script-message fastanime-next-episode", "Next Episode"),
|
|
"shift+p": (
|
|
"script-message fastanime-previous-episode",
|
|
"Previous Episode",
|
|
),
|
|
"shift+a": (
|
|
"script-message fastanime-toggle-auto-next",
|
|
"Toggle Auto-Next",
|
|
),
|
|
"shift+t": (
|
|
"script-message fastanime-toggle-translation",
|
|
"Toggle Translation",
|
|
),
|
|
"shift+r": ("script-message fastanime-reload-episode", "Reload Episode"),
|
|
}
|
|
for key, (command, description) in key_bindings.items():
|
|
self._bind_key(key, command, description)
|
|
|
|
self._show_text(
|
|
"FastAnime IPC: Shift+N=Next, Shift+P=Prev, Shift+R=Reload", 3000
|
|
)
|
|
|
|
def _setup_message_handlers(self):
|
|
self.message_handlers.update(
|
|
{
|
|
"fastanime-next-episode": self._next_episode,
|
|
"fastanime-previous-episode": self._previous_episode,
|
|
"fastanime-reload-episode": self._reload_episode,
|
|
"fastanime-toggle-auto-next": self._toggle_auto_next,
|
|
"fastanime-toggle-translation": self._toggle_translation_type,
|
|
"select-episode": self._handle_select_episode,
|
|
"select-server": self._handle_select_server,
|
|
"select-quality": self._handle_select_quality,
|
|
}
|
|
)
|
|
|
|
def _wait_for_playback(self):
|
|
"""A non-blocking loop that checks for MPV process exit and processes events."""
|
|
if not self.ipc_client:
|
|
return
|
|
|
|
should_stop = False
|
|
try:
|
|
while not should_stop:
|
|
if self.mpv_process and self.mpv_process.poll() is not None:
|
|
logger.info("MPV process has exited.")
|
|
break
|
|
|
|
while True:
|
|
message = self.ipc_client.get_event(block=False)
|
|
if message is None:
|
|
break
|
|
|
|
if message.get("event") == "shutdown":
|
|
should_stop = True
|
|
break
|
|
|
|
self._handle_mpv_message(message)
|
|
|
|
try:
|
|
fetch_result = self._fetch_result_queue.get(block=False)
|
|
self._handle_fetch_result(fetch_result)
|
|
except Empty:
|
|
pass
|
|
|
|
if should_stop:
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
except KeyboardInterrupt:
|
|
logger.info("Playback interrupted by user")
|
|
|
|
def _handle_mpv_message(self, message: Dict[str, Any]):
|
|
event = message.get("event")
|
|
if event == "property-change":
|
|
self._handle_property_change(message)
|
|
elif event == "client-message":
|
|
self._handle_client_message(message)
|
|
elif event == "file-loaded":
|
|
self._fetch_and_load_skip_times()
|
|
time.sleep(0.1)
|
|
self._configure_player()
|
|
elif event:
|
|
logger.debug(f"MPV event: {event}")
|
|
|
|
def _handle_property_change(self, message: Dict[str, Any]):
|
|
name = message.get("name")
|
|
data = message.get("data")
|
|
if name == "time-pos" and isinstance(data, (int, float)):
|
|
self.player_state.stop_time_secs = data
|
|
self._check_for_skip(data)
|
|
|
|
elif name == "duration" and isinstance(data, (int, float)):
|
|
self.player_state.total_time_secs = data
|
|
elif name == "percent-pos" and isinstance(data, (int, float)):
|
|
if (
|
|
self.stream_config.auto_next
|
|
and data >= self.stream_config.episode_complete_at
|
|
and not self.player_fetching
|
|
):
|
|
self._auto_next_episode()
|
|
|
|
def _handle_client_message(self, message: Dict[str, Any]):
|
|
args = message.get("args", [])
|
|
if args:
|
|
handler_name = args[0]
|
|
handler_args = args[1:]
|
|
handler = self.message_handlers.get(handler_name)
|
|
if handler:
|
|
try:
|
|
handler(*handler_args)
|
|
except Exception as e:
|
|
logger.error(f"Error in message handler for '{handler_name}': {e}")
|
|
|
|
def _cleanup(self):
|
|
if self.ipc_client:
|
|
self.ipc_client.disconnect()
|
|
if self.mpv_process:
|
|
try:
|
|
self.mpv_process.terminate()
|
|
self.mpv_process.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
self.mpv_process.kill()
|
|
if self.socket_path and Path(self.socket_path).exists():
|
|
Path(self.socket_path).unlink(missing_ok=True)
|
|
|
|
def _get_episode(
|
|
self,
|
|
episode_type: Literal["next", "previous", "reload", "custom"],
|
|
ep_no: Optional[str] = None,
|
|
):
|
|
if self.player_fetching:
|
|
self._show_text("Player is busy. Please wait.")
|
|
return
|
|
|
|
self.player_fetching = True
|
|
self._show_text(f"Fetching {episode_type} episode...")
|
|
|
|
self._fetch_thread = threading.Thread(
|
|
target=self._fetch_episode_task, args=(episode_type, ep_no), daemon=True
|
|
)
|
|
self._fetch_thread.start()
|
|
|
|
def _fetch_episode_task(
|
|
self,
|
|
episode_type: Literal["next", "previous", "reload", "custom"],
|
|
ep_no: Optional[str] = None,
|
|
):
|
|
"""This function runs in a background thread to fetch episode streams."""
|
|
try:
|
|
if self.anime and self.provider:
|
|
available_episodes = getattr(
|
|
self.anime.episodes, self.stream_config.translation_type
|
|
)
|
|
if not available_episodes:
|
|
raise ValueError(
|
|
f"No {self.stream_config.translation_type} episodes available."
|
|
)
|
|
|
|
current_index = available_episodes.index(self.player_state.episode)
|
|
|
|
if episode_type == "next":
|
|
if current_index >= len(available_episodes) - 1:
|
|
raise ValueError("Already at the last episode.")
|
|
target_episode = available_episodes[current_index + 1]
|
|
elif episode_type == "previous":
|
|
if current_index <= 0:
|
|
raise ValueError("Already at first episode")
|
|
target_episode = available_episodes[current_index - 1]
|
|
elif episode_type == "reload":
|
|
target_episode = self.player_state.episode
|
|
elif episode_type == "custom":
|
|
if not ep_no or ep_no not in available_episodes:
|
|
raise ValueError(
|
|
f"Invalid episode. Available: {', '.join(available_episodes)}"
|
|
)
|
|
target_episode = ep_no
|
|
else:
|
|
return
|
|
|
|
stream_params = EpisodeStreamsParams(
|
|
anime_id=self.anime.id,
|
|
query=self.player_state.query,
|
|
episode=target_episode,
|
|
translation_type=self.stream_config.translation_type,
|
|
)
|
|
# This is the blocking network call, now safely in a thread
|
|
episode_streams = list(
|
|
self.provider.episode_streams(stream_params) or []
|
|
)
|
|
if not episode_streams:
|
|
raise ValueError(f"No streams found for episode {target_episode}")
|
|
|
|
result = {
|
|
"type": "success",
|
|
"target_episode": target_episode,
|
|
"servers": {ProviderServer(s.name): s for s in episode_streams},
|
|
}
|
|
self._fetch_result_queue.put(result)
|
|
elif self.registry and self.media_item:
|
|
record = self.registry.get_media_record(self.media_item.id)
|
|
if not record or not record.media_episodes:
|
|
logger.warning("No downloaded episodes found for this anime.")
|
|
return
|
|
|
|
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()
|
|
}
|
|
available_episodes = list(sorted(downloaded_episodes.keys(), key=float))
|
|
current_index = available_episodes.index(self.player_state.episode)
|
|
|
|
if episode_type == "next":
|
|
if current_index >= len(available_episodes) - 1:
|
|
raise ValueError("Already at the last episode.")
|
|
target_episode = available_episodes[current_index + 1]
|
|
elif episode_type == "previous":
|
|
if current_index <= 0:
|
|
raise ValueError("Already at first episode")
|
|
target_episode = available_episodes[current_index - 1]
|
|
elif episode_type == "reload":
|
|
target_episode = self.player_state.episode
|
|
elif episode_type == "custom":
|
|
if not ep_no or ep_no not in available_episodes:
|
|
raise ValueError(
|
|
f"Invalid episode. Available: {', '.join(available_episodes)}"
|
|
)
|
|
target_episode = ep_no
|
|
else:
|
|
return
|
|
file_path = downloaded_episodes[target_episode]
|
|
|
|
self.player_state.reset()
|
|
self.player_state.episode = target_episode
|
|
self.ipc_client.send_command(["loadfile", str(file_path)])
|
|
# time.sleep(1)
|
|
# self.ipc_client.send_command(["seek", 0, "absolute"])
|
|
# self.ipc_client.send_command(
|
|
# ["set_property", "title", self.player_state.episode_title]
|
|
# )
|
|
self._show_text(f"Fetched {file_path}")
|
|
self.player_fetching = False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Episode fetch task failed: {e}")
|
|
self._fetch_result_queue.put({"type": "error", "message": str(e)})
|
|
|
|
def _handle_fetch_result(self, result: Dict[str, Any]):
|
|
"""Handles the result from the background fetch thread in the main thread."""
|
|
self.player_fetching = False
|
|
if result["type"] == "success":
|
|
self.player_state.episode = result["target_episode"]
|
|
self.player_state.servers = result["servers"]
|
|
self.player_state.reset()
|
|
self._show_text(f"Fetched {self.player_state.episode_title}")
|
|
self._load_current_stream()
|
|
else:
|
|
self._show_text(f"Error: {result['message']}")
|
|
|
|
def _next_episode(self):
|
|
self._get_episode("next")
|
|
|
|
def _previous_episode(self):
|
|
self._get_episode("previous")
|
|
|
|
def _reload_episode(self):
|
|
self._get_episode("reload")
|
|
|
|
def _auto_next_episode(self):
|
|
if self.stream_config.auto_next:
|
|
self._next_episode()
|
|
|
|
def _load_current_stream(self):
|
|
if self.ipc_client and self.player_state and self.player_state.stream_url:
|
|
self.ipc_client.send_command(["loadfile", self.player_state.stream_url])
|
|
|
|
def _show_text(self, text: str, duration: int = 2000):
|
|
if self.ipc_client:
|
|
self.ipc_client.send_command(["show-text", text, str(duration)])
|
|
|
|
def _configure_player(self):
|
|
if not self.ipc_client or self.player_first_run:
|
|
self.player_first_run = False
|
|
return
|
|
|
|
self.ipc_client.send_command(["seek", 0, "absolute"])
|
|
self.ipc_client.send_command(
|
|
["set_property", "title", self.player_state.episode_title]
|
|
)
|
|
self._add_episode_subtitles()
|
|
|
|
def _add_episode_subtitles(self):
|
|
if not self.ipc_client or not self.player_state.stream_subtitles:
|
|
return
|
|
|
|
time.sleep(0.5)
|
|
for i, sub_url in enumerate(self.player_state.stream_subtitles):
|
|
flag = "select" if i == 0 else "auto"
|
|
self.ipc_client.send_command(["sub-add", sub_url, flag])
|
|
|
|
def _toggle_auto_next(self):
|
|
self.stream_config.auto_next = not self.stream_config.auto_next
|
|
self._show_text(
|
|
f"Auto-next {'enabled' if self.stream_config.auto_next else 'disabled'}"
|
|
)
|
|
|
|
def _toggle_translation_type(self):
|
|
new_type = "sub" if self.stream_config.translation_type == "dub" else "dub"
|
|
self._show_text(f"Switching to {new_type}...")
|
|
self.stream_config.translation_type = new_type
|
|
self._reload_episode()
|
|
|
|
def _handle_select_episode(self, episode: Optional[str] = None):
|
|
if episode:
|
|
self._get_episode("custom", episode)
|
|
|
|
def _handle_select_server(self, server: Optional[str] = None):
|
|
if not server or not self.player_state:
|
|
return
|
|
try:
|
|
provider_server = ProviderServer(server)
|
|
if provider_server in self.player_state.servers:
|
|
self.player_state.server_name = provider_server
|
|
self._reload_episode()
|
|
else:
|
|
self._show_text(f"Server '{server}' not available.")
|
|
except ValueError:
|
|
available_servers = ", ".join(
|
|
[s.value for s in self.player_state.servers.keys()]
|
|
)
|
|
self._show_text(
|
|
f"Invalid server name: {server}. Available: {available_servers}"
|
|
)
|
|
|
|
def _handle_select_quality(self, quality: Optional[str] = None):
|
|
self._show_text("Quality switching is not yet implemented.")
|
|
|
|
def _check_for_skip(self, current_time: float):
|
|
"""Checks if the current playback time falls within a skip interval."""
|
|
if (
|
|
not self.stream_config.auto_skip
|
|
or not self._skip_times
|
|
or not self._skip_times.found
|
|
):
|
|
return
|
|
|
|
for skip in self._skip_times.results:
|
|
if skip.skip_id in self._skipped_ids:
|
|
continue
|
|
|
|
start_time, end_time = skip.interval
|
|
# Trigger skip slightly after the start time
|
|
if start_time <= current_time < end_time:
|
|
logger.info(
|
|
f"Skipping {skip.skip_type.upper()} from {start_time} to {end_time}"
|
|
)
|
|
self._show_text(f"Skipping {skip.skip_type.upper()}...", duration=1500)
|
|
self.ipc_client.send_command(["set_property", "time-pos", end_time])
|
|
self._skipped_ids.add(skip.skip_id)
|
|
break
|
|
|
|
def _fetch_and_load_skip_times(self):
|
|
"""Fetches skip times for the current episode in a background thread."""
|
|
if (
|
|
not self.stream_config.auto_skip
|
|
or not self.media_item
|
|
or not self.media_item.id_mal
|
|
):
|
|
return
|
|
|
|
try:
|
|
episode_num = int(float(self.player_state.episode))
|
|
mal_id = self.media_item.id_mal
|
|
|
|
def task():
|
|
self._skip_times = AniSkip.get_skip_times(mal_id, episode_num)
|
|
if self._skip_times and self._skip_times.found:
|
|
logger.info(
|
|
f"Found {len(self._skip_times.results)} skip intervals for Ep {episode_num}"
|
|
)
|
|
self._show_text("Skip times loaded.", duration=2000)
|
|
|
|
# Run in a thread to not block playback
|
|
threading.Thread(target=task, daemon=True).start()
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
f"Could not parse episode number for Aniskip: {self.player_state.episode}"
|
|
)
|