""" IPC-based MPV Player implementation for FastAnime. This provides advanced features like episode navigation, quality switching, and auto-next. Usage: To enable IPC player, set `use_ipc = true` in the MPV config section. Key bindings: - Shift+N: Next episode - Shift+P: Previous episode - Shift+R: Reload current episode - Shift+T: Toggle translation type (sub/dub) - Shift+A: Toggle auto-next (placeholder) Script messages (can be sent via MPV console with 'script-message'): - select-episode : Jump to specific episode - select-server : Switch server for current episode - select-quality : Switch quality (360, 480, 720, 1080) Requirements: - MPV executable in PATH - Unix domain socket support (Linux/macOS) """ import json import logging import re import socket import subprocess import tempfile import time from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional from ....core.config import MpvConfig from ....core.exceptions import FastAnimeError from ....core.patterns import TORRENT_REGEX from ....core.utils import detect from ..base import BasePlayer from ..params import PlayerParams from ..types import PlayerResult if TYPE_CHECKING: from typing import Union from ....libs.provider.anime.base import BaseAnimeProvider from ....libs.provider.anime.params import EpisodeStreamsParams from ....libs.provider.anime.types import Server logger = logging.getLogger(__name__) def format_time(duration_in_secs: float) -> str: """Format duration in seconds to HH:MM:SS format.""" h = int(duration_in_secs // 3600) m = int((duration_in_secs % 3600) // 60) s = int(duration_in_secs % 60) return f"{h:02d}:{m:02d}:{s:02d}" class MPVIPCError(Exception): """Exception raised for MPV IPC communication errors.""" pass class MPVIPCClient: """Client for communicating with MPV via IPC socket.""" def __init__(self, socket_path: str): self.socket_path = socket_path self.socket: Optional[socket.socket] = None self._request_id = 0 def connect(self, timeout: float = 5.0) -> None: """Connect to MPV IPC socket.""" start_time = time.time() last_exception = None while time.time() - start_time < timeout: try: self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.socket.settimeout(2.0) # Set socket timeout self.socket.connect(self.socket_path) logger.info(f"Connected to MPV IPC socket at {self.socket_path}") return except (ConnectionRefusedError, FileNotFoundError, OSError) as e: last_exception = e if self.socket: try: self.socket.close() except: pass self.socket = None time.sleep(0.2) # Wait a bit longer between attempts continue error_msg = f"Failed to connect to MPV IPC socket at {self.socket_path}" if last_exception: error_msg += f": {last_exception}" raise MPVIPCError(error_msg) def disconnect(self) -> None: """Disconnect from MPV IPC socket.""" if self.socket: try: self.socket.close() except: pass self.socket = None def send_command(self, command: List[Any]) -> Dict[str, Any]: """Send a command to MPV and return the response.""" if not self.socket: raise MPVIPCError("Not connected to MPV") self._request_id += 1 request = { "command": command, "request_id": self._request_id } message = json.dumps(request) + "\n" try: self.socket.send(message.encode()) # Read response - MPV sends one JSON object per line response_data = b"" while True: chunk = self.socket.recv(1024) if not chunk: break response_data += chunk if b"\n" in response_data: break response_text = response_data.decode().strip() if response_text: # Handle multiple JSON objects on separate lines lines = response_text.split('\n') for line in lines: line = line.strip() if line: try: response = json.loads(line) # Return the response that matches our request ID if response.get("request_id") == self._request_id: return response except json.JSONDecodeError: continue # If no matching response found, return the first valid JSON for line in lines: line = line.strip() if line: try: return json.loads(line) except json.JSONDecodeError: continue return {} except Exception as e: raise MPVIPCError(f"Failed to send command: {e}") def get_property(self, property_name: str) -> Any: """Get a property value from MPV.""" response = self.send_command(["get_property", property_name]) if response.get("error") == "success": return response.get("data") return None def set_property(self, property_name: str, value: Any) -> bool: """Set a property value in MPV.""" response = self.send_command(["set_property", property_name, value]) return response.get("error") == "success" def observe_property(self, property_name: str, enable: bool = True) -> bool: """Observe a property for changes.""" command = "observe_property" if enable else "unobserve_property" response = self.send_command([command, self._request_id, property_name]) return response.get("error") == "success" class MpvIPCPlayer(BasePlayer): """MPV Player implementation using IPC for advanced features.""" def __init__(self, config: MpvConfig): self.config = config self.ipc_client: Optional[MPVIPCClient] = None self.mpv_process: Optional[subprocess.Popen] = None self.socket_path: Optional[str] = None # Player state self.last_stop_time: str = "0" self.last_total_time: str = "0" self.last_stop_time_secs: float = 0 self.last_total_time_secs: float = 0 self.current_media_title: str = "" self.player_fetching: bool = False # Runtime state - injected from outside self.anime_provider: Optional["BaseAnimeProvider"] = None self.current_anime: Optional[Any] = None self.available_episodes: List[str] = [] self.current_episode: Optional[str] = None self.current_anime_id: Optional[str] = None self.current_anime_title: Optional[str] = None self.current_translation_type: str = "sub" self.current_server: Optional["Server"] = None self.subtitles: List[Dict[str, str]] = [] # Event handlers self.event_handlers: Dict[str, List[Callable]] = {} self.property_observers: Dict[str, List[Callable]] = {} self.key_bindings: Dict[str, Callable] = {} self.message_handlers: Dict[str, Callable] = {} def play(self, params: PlayerParams) -> PlayerResult: """Play media using MPV with IPC.""" if TORRENT_REGEX.match(params.url) and detect.is_running_in_termux(): raise FastAnimeError("Unable to play torrents on termux") if detect.is_running_in_termux(): raise FastAnimeError("IPC player not supported on termux") return self._play_with_ipc(params) def _play_with_ipc(self, params: PlayerParams) -> PlayerResult: """Play media using MPV IPC.""" # Set up runtime dependencies from params if provided if params.anime_provider and params.current_anime: self.anime_provider = params.anime_provider self.current_anime = params.current_anime self.available_episodes = params.available_episodes or [] self.current_episode = params.current_episode or "" self.current_anime_id = params.current_anime_id or "" self.current_anime_title = params.current_anime_title or "" self.current_translation_type = params.current_translation_type or "sub" try: self._setup_ipc_socket() self._start_mpv_process(params) self._connect_ipc() self._setup_event_handling() self._setup_key_bindings() self._setup_message_handlers() self._configure_player(params) # Wait for playback to complete self._wait_for_playback() return PlayerResult( stop_time=self.last_stop_time, total_time=self.last_total_time ) finally: self._cleanup() def _setup_ipc_socket(self) -> None: """Create a temporary IPC socket path.""" temp_dir = Path(tempfile.gettempdir()) self.socket_path = str(temp_dir / f"mpv_ipc_{time.time()}.sock") def _start_mpv_process(self, params: PlayerParams) -> None: """Start MPV process with IPC enabled.""" mpv_args = [ "mpv", f"--input-ipc-server={self.socket_path}", "--idle=yes", "--force-window=yes", params.url ] # Add custom MPV arguments mpv_args.extend(self._create_mpv_cli_options(params)) # Add pre-args if configured pre_args = self.config.pre_args.split(",") if self.config.pre_args else [] logger.info(f"Starting MPV with IPC socket: {self.socket_path}") self.mpv_process = subprocess.Popen( pre_args + mpv_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) # Give MPV a moment to start and create the socket time.sleep(1.0) def _connect_ipc(self) -> None: """Connect to MPV IPC socket.""" 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) -> None: """Setup event handlers for MPV events.""" if not self.ipc_client: return # Request events we care about try: self.ipc_client.send_command(["request_log_messages", "info"]) except Exception as e: logger.warning(f"Failed to request log messages: {e}") # Observe properties we care about try: self.ipc_client.observe_property("time-pos") self.ipc_client.observe_property("time-remaining") self.ipc_client.observe_property("duration") self.ipc_client.observe_property("filename") except Exception as e: logger.warning(f"Failed to observe properties: {e}") def _setup_key_bindings(self) -> None: """Setup custom key bindings.""" if not self.ipc_client: return # Define key bindings using individual keybind commands key_bindings = { "shift+n": "script-message fastanime-next-episode", "shift+p": "script-message fastanime-previous-episode", "shift+a": "script-message fastanime-toggle-auto-next", "shift+t": "script-message fastanime-toggle-translation", "shift+r": "script-message fastanime-reload-episode", } # Register key bindings with MPV using keybind command for key, command in key_bindings.items(): try: response = self.ipc_client.send_command(["keybind", key, command]) logger.info(f"Key binding result for {key}: {response}") except Exception as e: logger.warning(f"Failed to bind key {key}: {e}") # Also show a message to indicate keys are ready try: self.ipc_client.send_command([ "show-text", "FastAnime IPC: Shift+N=Next, Shift+P=Prev, Shift+R=Reload, Shift+T=Toggle", "3000" ]) except Exception as e: logger.warning(f"Failed to show key binding message: {e}") def _setup_message_handlers(self) -> None: """Setup script message handlers.""" self.message_handlers.update({ "select-episode": self._handle_select_episode, "select-server": self._handle_select_server, "select-quality": self._handle_select_quality, # Key binding handlers - updated to match new key binding format "fastanime-next-episode": lambda: self._next_episode(), "fastanime-previous-episode": lambda: self._previous_episode(), "fastanime-toggle-auto-next": lambda: self._toggle_auto_next(), "fastanime-toggle-translation": lambda: self._toggle_translation_type(), "fastanime-reload-episode": lambda: self._reload_episode(), }) def _configure_player(self, params: PlayerParams) -> None: """Configure MPV player with parameters.""" if not self.ipc_client: return # Set title if params.title: try: self.ipc_client.set_property("title", params.title) self.current_media_title = params.title except MPVIPCError as e: logger.warning(f"Failed to set title: {e}") # Set start time if params.start_time: try: self.ipc_client.set_property("start", params.start_time) except MPVIPCError as e: logger.warning(f"Failed to set start time: {e}") # Add subtitles if params.subtitles: for i, subtitle_path in enumerate(params.subtitles): flag = "select" if i == 0 else "auto" try: self.ipc_client.send_command([ "sub-add", subtitle_path, flag ]) except MPVIPCError as e: logger.warning(f"Failed to add subtitle {subtitle_path}: {e}") # Add any episode-specific subtitles try: self._add_episode_subtitles() except Exception as e: logger.warning(f"Failed to add episode subtitles: {e}") # Set HTTP headers (only if not empty) if params.headers: header_str = ",".join([f"{k}:{v}" for k, v in params.headers.items()]) if header_str.strip(): # Only set if we have actual headers try: self.ipc_client.set_property("http-header-fields", header_str) except MPVIPCError as e: logger.warning(f"Failed to set HTTP headers: {e}") def _wait_for_playback(self) -> None: """Wait for playback to complete while handling events.""" if not self.ipc_client: return try: while True: # Check if MPV process is still running if self.mpv_process and self.mpv_process.poll() is not None: break # Handle property changes and events self._handle_events() # Check for file-loaded event to add subtitles # This is a simplified event handling - in a real implementation # you'd need proper event listening time.sleep(0.1) except KeyboardInterrupt: logger.info("Playback interrupted by user") def _handle_events(self) -> None: """Handle MPV events and property changes.""" if not self.ipc_client or not self.ipc_client.socket: return try: # Check for incoming messages (non-blocking) self.ipc_client.socket.settimeout(0.01) try: data = self.ipc_client.socket.recv(4096) # Increased buffer size if data: message_text = data.decode().strip() if message_text: # Handle multiple JSON objects on separate lines lines = message_text.split('\n') for line in lines: line = line.strip() if line: try: message = json.loads(line) self._handle_mpv_message(message) except json.JSONDecodeError as e: logger.debug(f"Failed to parse JSON: {line[:100]} - {e}") continue except socket.timeout: pass except Exception as e: logger.debug(f"Socket read error: {e}") pass finally: self.ipc_client.socket.settimeout(None) # Periodically update time properties (less frequently to avoid spam) import random if random.randint(1, 50) == 1: # Only update occasionally # Get current time position (with error handling) try: time_pos = self.ipc_client.get_property("time-pos") if time_pos is not None: self.last_stop_time = format_time(time_pos) self.last_stop_time_secs = time_pos except (MPVIPCError, Exception): pass # Get duration (with error handling) try: duration = self.ipc_client.get_property("duration") if duration is not None: self.last_total_time = format_time(duration) self.last_total_time_secs = duration except (MPVIPCError, Exception): pass # Get time remaining for auto-next (with error handling) try: time_remaining = self.ipc_client.get_property("time-remaining") if (time_remaining is not None and time_remaining < 1 and not self.player_fetching): self._auto_next_episode() except (MPVIPCError, Exception): pass except MPVIPCError: # IPC communication failed, probably because MPV closed pass def _handle_mpv_message(self, message: Dict[str, Any]) -> None: """Handle incoming messages from MPV.""" logger.debug(f"Received MPV message: {message}") if message.get("event") == "client-message": # Handle script messages args = message.get("args", []) if args and len(args) > 0: message_name = args[0] message_args = args[1:] if len(args) > 1 else [] logger.info(f"Handling script message: {message_name} with args: {message_args}") handler = self.message_handlers.get(message_name) if handler: try: if message_args: handler(*message_args) else: handler() except Exception as e: logger.error(f"Error handling message {message_name}: {e}") else: logger.warning(f"No handler found for message: {message_name}") elif message.get("event") == "file-loaded": # File loaded event - add subtitles logger.info("File loaded, adding episode subtitles") self._add_episode_subtitles() elif message.get("event") == "property-change": # Handle property changes property_name = message.get("name") if property_name == "time-remaining": value = message.get("data") if (value is not None and value < 1 and not self.player_fetching): self._auto_next_episode() elif message.get("event"): # Log other events for debugging logger.debug(f"MPV event: {message.get('event')}") # Handle responses to our commands elif message.get("request_id"): logger.debug(f"Command response: {message}") def _cleanup(self) -> None: """Clean up resources.""" if self.ipc_client: self.ipc_client.disconnect() if self.mpv_process: try: self.mpv_process.terminate() self.mpv_process.wait(timeout=5) except subprocess.TimeoutExpired: self.mpv_process.kill() # Remove socket file if self.socket_path and Path(self.socket_path).exists(): try: Path(self.socket_path).unlink() except: pass def _create_mpv_cli_options(self, params: PlayerParams) -> List[str]: """Create MPV CLI options from parameters.""" mpv_args = [] if params.headers: header_str = ",".join([f"{k}:{v}" for k, v in params.headers.items()]) mpv_args.append(f"--http-header-fields={header_str}") if params.subtitles: for sub in params.subtitles: mpv_args.append(f"--sub-file={sub}") if params.start_time: mpv_args.append(f"--start={params.start_time}") if params.title: mpv_args.append(f"--title={params.title}") if self.config.args: mpv_args.extend(self.config.args.split(",")) return mpv_args # Episode navigation methods (similar to original implementation) def _get_episode( self, episode_type: Literal['next', 'previous', 'reload', 'custom'], ep_no: Optional[str] = None, server: str = "top", ) -> Optional[str]: """Get episode stream URL for navigation.""" if not self.anime_provider or not self.current_anime or not self.current_episode: if self.ipc_client: self.ipc_client.send_command(["show-text", "Episode navigation not available"]) return None # Show status message if self.ipc_client: self.ipc_client.send_command(["show-text", f"Fetching {episode_type} episode..."]) # Reset timing info for new episode self.last_stop_time = "0" self.last_total_time = "0" self.last_stop_time_secs = 0 self.last_total_time_secs = 0 # Determine target episode try: current_index = self.available_episodes.index(self.current_episode) if episode_type == "next": if current_index >= len(self.available_episodes) - 1: if self.ipc_client: self.ipc_client.send_command(["show-text", "Already at last episode"]) return None target_episode = self.available_episodes[current_index + 1] elif episode_type == "previous": if current_index <= 0: if self.ipc_client: self.ipc_client.send_command(["show-text", "Already at first episode"]) return None target_episode = self.available_episodes[current_index - 1] elif episode_type == "reload": target_episode = self.current_episode elif episode_type == "custom": if not ep_no or ep_no not in self.available_episodes: if self.ipc_client: self.ipc_client.send_command([ "show-text", f"Invalid episode. Available: {', '.join(self.available_episodes)}" ]) return None target_episode = ep_no except ValueError: if self.ipc_client: self.ipc_client.send_command(["show-text", "Current episode not found in available episodes"]) return None # Get streams for the target episode try: from ....libs.provider.anime.params import EpisodeStreamsParams # Validate required fields if not self.current_anime_id: if self.ipc_client: self.ipc_client.send_command(["show-text", "Missing anime ID"]) return None # Cast translation type to proper literal translation_type: Literal["sub", "dub"] = "sub" if self.current_translation_type == "sub" else "dub" stream_params = EpisodeStreamsParams( anime_id=self.current_anime_id, query=self.current_anime_title or "", episode=target_episode, translation_type=translation_type, ) episode_streams = self.anime_provider.episode_streams(stream_params) if not episode_streams: if self.ipc_client: self.ipc_client.send_command(["show-text", "No streams found for episode"]) return None # Select server (top or specific) if server == "top": selected_server = next(episode_streams, None) else: # Find specific server selected_server = None for stream_server in episode_streams: if stream_server.name.lower() == server.lower(): selected_server = stream_server break if not selected_server: if self.ipc_client: self.ipc_client.send_command(["show-text", f"Server '{server}' not found"]) return None if not selected_server: if self.ipc_client: self.ipc_client.send_command(["show-text", "No server available"]) return None # Get stream link - prefer highest quality if not selected_server.links: if self.ipc_client: self.ipc_client.send_command(["show-text", "No stream links available"]) return None # Sort by quality and get the best one sorted_links = sorted( selected_server.links, key=lambda x: int(x.quality), reverse=True ) stream_link = sorted_links[0].link # Update current state self.current_episode = target_episode self.current_server = selected_server self.current_media_title = selected_server.episode_title or f"Episode {target_episode}" self.subtitles = [ {"url": sub.url, "language": sub.language or "unknown"} for sub in selected_server.subtitles ] return stream_link except Exception as e: logger.error(f"Error fetching episode {target_episode}: {e}") if self.ipc_client: self.ipc_client.send_command(["show-text", f"Error fetching episode: {str(e)}"]) return None def _next_episode(self) -> None: """Navigate to next episode.""" url = self._get_episode("next") if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() def _previous_episode(self) -> None: """Navigate to previous episode.""" url = self._get_episode("previous") if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() def _reload_episode(self) -> None: """Reload current episode.""" url = self._get_episode("reload") if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() def _toggle_auto_next(self) -> None: """Toggle auto-next feature.""" # This would be controlled by config, but for now just show message if self.ipc_client: self.ipc_client.send_command(["show-text", "Auto-next feature toggle not implemented"]) def _toggle_translation_type(self) -> None: """Toggle between sub and dub.""" if not self.anime_provider: return new_type = "sub" if self.current_translation_type == "dub" else "dub" if self.ipc_client: self.ipc_client.send_command(["show-text", f"Switching to {new_type}..."]) # Try to reload current episode with new translation type old_type = self.current_translation_type self.current_translation_type = new_type url = self._get_episode("reload") if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) self.ipc_client.send_command(["show-text", f"Switched to {new_type}"]) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() else: # Revert if failed self.current_translation_type = old_type if self.ipc_client: self.ipc_client.send_command(["show-text", f"Failed to switch to {new_type}"]) def _auto_next_episode(self) -> None: """Automatically play next episode.""" if not self.player_fetching: logger.info("Auto fetching next episode") self.player_fetching = True url = self._get_episode("next") if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() # Message handlers def _handle_select_episode(self, episode: Optional[str] = None) -> None: """Handle episode selection message.""" if not episode: if self.ipc_client: self.ipc_client.send_command(["show-text", "No episode was selected"]) return url = self._get_episode("custom", episode) if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() def _handle_select_server(self, server: Optional[str] = None) -> None: """Handle server selection message.""" if not server: if self.ipc_client: self.ipc_client.send_command(["show-text", "No server was selected"]) return url = self._get_episode("reload", server=server) if url and self.ipc_client: self.ipc_client.send_command(["loadfile", url]) self.ipc_client.set_property("title", self.current_media_title) # Add subtitles after a short delay to ensure file is loaded time.sleep(0.5) self._add_episode_subtitles() def _handle_select_quality(self, quality: Optional[str] = None) -> None: """Handle quality selection message.""" if not quality or not self.current_server: if self.ipc_client: self.ipc_client.send_command(["show-text", "No quality was selected"]) return # Find link with matching quality matching_link = None for link in self.current_server.links: if link.quality == quality: matching_link = link break if matching_link: if self.ipc_client: self.ipc_client.send_command(["show-text", f"Switching to {quality}p quality..."]) self.ipc_client.send_command(["loadfile", matching_link.link]) else: available_qualities = [link.quality for link in self.current_server.links] if self.ipc_client: self.ipc_client.send_command([ "show-text", f"Quality {quality}p not available. Available: {', '.join(available_qualities)}" ]) def show_text(self, text: str, duration: int = 2000) -> None: """Show text on MPV OSD.""" if self.ipc_client: self.ipc_client.send_command(["show-text", text, str(duration)]) def _add_episode_subtitles(self) -> None: """Add episode-specific subtitles after loading new episode.""" if not self.ipc_client or not self.subtitles: return for i, subtitle in enumerate(self.subtitles): flag = "select" if i == 0 else "auto" try: self.ipc_client.send_command([ "sub-add", subtitle["url"], flag, None, subtitle.get("language", "unknown") ]) except Exception as e: logger.warning(f"Failed to add subtitle: {e}") # Factory function for creating IPC player def create_ipc_player(config: MpvConfig) -> MpvIPCPlayer: """Create an IPC-based MPV player instance.""" return MpvIPCPlayer(config)