"""MPV IPC client for cross-platform communication. This module provides a cross-platform interface to communicate with mpv using either named pipes (Windows) or Unix domain sockets (Linux/macOS). This is the central hub for all Python-mpv IPC communication. The Lua script should use the Python CLI, which uses this module to manage mpv connections. """ import json import os import platform import socket import time as _time from typing import Any, Dict, Optional, List from helper.logger import debug # Fixed pipe name for persistent MPV connection across all Python sessions FIXED_IPC_PIPE_NAME = "mpv-medeia-macina" class MPVIPCError(Exception): """Raised when MPV IPC communication fails.""" pass def get_ipc_pipe_path() -> str: """Get the fixed IPC pipe/socket path for persistent MPV connection. Uses a fixed name so all playback sessions connect to the same MPV window/process instead of creating new instances. Returns: Path to IPC pipe (Windows) or socket (Linux/macOS) """ system = platform.system() if system == "Windows": return f"\\\\.\\pipe\\{FIXED_IPC_PIPE_NAME}" elif system == "Darwin": # macOS return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" else: # Linux and others return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" class MPVIPCClient: """Client for communicating with mpv via IPC socket/pipe. This is the unified interface for all Python code to communicate with mpv. It handles platform-specific differences (Windows named pipes vs Unix sockets). """ def __init__(self, socket_path: Optional[str] = None, timeout: float = 5.0): """Initialize MPV IPC client. Args: socket_path: Path to IPC socket/pipe. If None, uses the fixed persistent path. timeout: Socket timeout in seconds. """ self.timeout = timeout self.socket_path = socket_path or get_ipc_pipe_path() self.sock = None self.is_windows = platform.system() == "Windows" def connect(self) -> bool: """Connect to mpv IPC socket. Returns: True if connection successful, False otherwise. """ try: if self.is_windows: # Windows named pipes try: # Try to open the named pipe self.sock = open(self.socket_path, 'r+b', buffering=0) return True except (OSError, IOError) as exc: debug(f"Failed to connect to MPV named pipe: {exc}") return False else: # Unix domain socket (Linux, macOS) if not os.path.exists(self.socket_path): debug(f"IPC socket not found: {self.socket_path}") return False self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.settimeout(self.timeout) self.sock.connect(self.socket_path) return True except Exception as exc: debug(f"Failed to connect to MPV IPC: {exc}") self.sock = None return False def send_command(self, command_data: Dict[str, Any] | List[Any]) -> Optional[Dict[str, Any]]: """Send a command to mpv and get response. Args: command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...]) Returns: Response dict with 'error' key (value 'success' on success), or None on error. """ if not self.sock: if not self.connect(): return None try: # Format command as JSON (mpv IPC protocol) if isinstance(command_data, list): request = {"command": command_data} else: request = command_data # Add request_id if not present to match response if "request_id" not in request: request["request_id"] = int(_time.time() * 1000) % 100000 payload = json.dumps(request) + "\n" # Send command if self.is_windows: self.sock.write(payload.encode('utf-8')) self.sock.flush() else: self.sock.sendall(payload.encode('utf-8')) # Receive response # We need to read lines until we find the one with matching request_id # or until timeout/error. MPV might send events in between. start_time = _time.time() while _time.time() - start_time < self.timeout: response_data = b"" if self.is_windows: try: response_data = self.sock.readline() except (OSError, IOError): return None else: try: # This is simplistic for Unix socket (might not get full line) # But for now assuming MPV sends line-buffered JSON chunk = self.sock.recv(4096) if not chunk: break response_data = chunk # TODO: Handle partial lines if needed except socket.timeout: return None if not response_data: break try: lines = response_data.decode('utf-8').strip().split('\n') for line in lines: if not line: continue resp = json.loads(line) # Check if this is the response to our request if resp.get("request_id") == request.get("request_id"): return resp # If it's an error without request_id (shouldn't happen for commands) if "error" in resp and "request_id" not in resp: # Might be an event or async error pass except json.JSONDecodeError: pass return None except Exception as exc: debug(f"Error sending command to MPV: {exc}") self.disconnect() return None def disconnect(self) -> None: """Disconnect from mpv IPC socket.""" if self.sock: try: self.sock.close() except Exception: pass self.sock = None def __del__(self) -> None: """Cleanup on object destruction.""" self.disconnect() def __enter__(self): """Context manager entry.""" self.connect() return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.disconnect() def send_to_mpv(file_url: str, title: str, headers: Optional[Dict[str, str]] = None, append: bool = True) -> bool: """Send a file to be played in the existing MPV instance via IPC. This attempts to send to an existing MPV instance. If it fails, the calling code should start a new MPV instance with the IPC pipe. Args: file_url: URL or path to file to play title: Display title for the file headers: Optional HTTP headers (dict) append: If True, append to playlist; if False, replace Returns: True if successfully sent to existing MPV, False if pipe unavailable. """ # Try to connect using the robust client client = get_mpv_client() if not client: return False try: # Command 1: Set headers if provided if headers: header_str = ",".join([f"{k}: {v}" for k, v in headers.items()]) cmd_headers = { "command": ["set_property", "http-header-fields", header_str], "request_id": 0 } client.send_command(cmd_headers) # Command 2: Load file # Use memory:// M3U to preserve title in playlist if provided # This is required for YouTube URLs and proper playlist display if title: # Sanitize title for M3U (remove newlines) safe_title = title.replace("\n", " ").replace("\r", "") # M3U format: #EXTM3U\n#EXTINF:-1,Title\nURL m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{file_url}\n" target = f"memory://{m3u_content}" else: target = file_url load_mode = "append-play" if append else "replace" cmd_load = { "command": ["loadfile", target, load_mode], "request_id": 1 } resp = client.send_command(cmd_load) if not resp or resp.get('error') != 'success': debug(f"MPV loadfile failed: {resp}") return False # Command 3: Set title (metadata for display) - still useful for window title if title: safe_title_prop = title.replace('"', '\\"') cmd_title = { "command": ["set_property", "force-media-title", safe_title_prop], "request_id": 2 } client.send_command(cmd_title) debug(f"Sent to existing MPV: {title}") return True except Exception as e: debug(f"Error in send_to_mpv: {e}") return False finally: client.disconnect() def get_mpv_client(socket_path: Optional[str] = None) -> Optional[MPVIPCClient]: """Get an MPV IPC client, attempting to connect. Args: socket_path: Custom socket path (uses default if None) Returns: Connected MPVIPCClient or None if connection fails. """ client = MPVIPCClient(socket_path=socket_path) if client.connect(): return client return None