"""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 subprocess import time as _time from pathlib import Path from typing import Any, Dict, Optional, List, BinaryIO, cast from SYS.logger import debug # Fixed pipe name for persistent MPV connection across all Python sessions FIXED_IPC_PIPE_NAME = "mpv-medeia-macina" MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent / "LUA" / "main.lua") class MPVIPCError(Exception): """Raised when MPV IPC communication fails.""" pass class MPV: """High-level MPV controller for this app. Responsibilities: - Own the IPC pipe/socket path - Start MPV with the bundled Lua script - Query playlist and currently playing item via IPC This class intentionally stays "dumb": it does not implement app logic. App behavior is driven by cmdlets (e.g. `.pipe`) and the bundled Lua script. """ def __init__( self, ipc_path: Optional[str] = None, lua_script_path: Optional[str | Path] = None, timeout: float = 5.0, ) -> None: self.timeout = timeout self.ipc_path = ipc_path or get_ipc_pipe_path() if lua_script_path is None: lua_script_path = MPV_LUA_SCRIPT_PATH lua_path = Path(str(lua_script_path)).resolve() self.lua_script_path = str(lua_path) def client(self) -> "MPVIPCClient": return MPVIPCClient(socket_path=self.ipc_path, timeout=self.timeout) def is_running(self) -> bool: client = self.client() try: ok = client.connect() return bool(ok) finally: client.disconnect() def send(self, command: Dict[str, Any] | List[Any], silent: bool = False) -> Optional[Dict[str, Any]]: client = self.client() try: if not client.connect(): return None return client.send_command(command) except Exception as exc: if not silent: debug(f"MPV IPC error: {exc}") return None finally: client.disconnect() def get_property(self, name: str, default: Any = None) -> Any: resp = self.send({"command": ["get_property", name]}) if resp and resp.get("error") == "success": return resp.get("data", default) return default def set_property(self, name: str, value: Any) -> bool: resp = self.send({"command": ["set_property", name, value]}) return bool(resp and resp.get("error") == "success") def get_playlist(self, silent: bool = False) -> Optional[List[Dict[str, Any]]]: resp = self.send({"command": ["get_property", "playlist"], "request_id": 100}, silent=silent) if resp is None: return None if resp.get("error") == "success": data = resp.get("data", []) return data if isinstance(data, list) else [] return [] def get_now_playing(self) -> Optional[Dict[str, Any]]: if not self.is_running(): return None playlist = self.get_playlist(silent=True) or [] pos = self.get_property("playlist-pos", None) path = self.get_property("path", None) title = self.get_property("media-title", None) effective_path = _unwrap_memory_target(path) if isinstance(path, str) else path current_item: Optional[Dict[str, Any]] = None if isinstance(pos, int) and 0 <= pos < len(playlist): item = playlist[pos] current_item = item if isinstance(item, dict) else None else: for item in playlist: if isinstance(item, dict) and item.get("current") is True: current_item = item break return { "path": effective_path, "title": title, "playlist_pos": pos, "playlist_item": current_item, } def ensure_lua_loaded(self) -> None: try: script_path = self.lua_script_path if not script_path or not os.path.exists(script_path): return # Safe to call repeatedly; mpv will reload the script. self.send({"command": ["load-script", script_path], "request_id": 12}, silent=True) except Exception: return def wait_for_ipc(self, retries: int = 20, delay_seconds: float = 0.2) -> bool: for _ in range(max(1, retries)): client = self.client() try: if client.connect(): return True finally: client.disconnect() _time.sleep(delay_seconds) return False def kill_existing_windows(self) -> None: if platform.system() != "Windows": return try: subprocess.run( ["taskkill", "/IM", "mpv.exe", "/F"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2, ) except Exception: return def start( self, *, extra_args: Optional[List[str]] = None, ytdl_raw_options: Optional[str] = None, http_header_fields: Optional[str] = None, detached: bool = True, ) -> None: cmd: List[str] = [ "mpv", f"--input-ipc-server={self.ipc_path}", "--idle=yes", "--force-window=yes", ] # Always load the bundled Lua script at startup. if self.lua_script_path and os.path.exists(self.lua_script_path): cmd.append(f"--script={self.lua_script_path}") if ytdl_raw_options: cmd.append(f"--ytdl-raw-options={ytdl_raw_options}") if http_header_fields: cmd.append(f"--http-header-fields={http_header_fields}") if extra_args: cmd.extend([str(a) for a in extra_args if a]) kwargs: Dict[str, Any] = {} if detached and platform.system() == "Windows": kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS debug("Starting MPV") subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs) 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" def _unwrap_memory_target(text: Optional[str]) -> Optional[str]: """Return the real target from a memory:// M3U payload if present.""" if not isinstance(text, str) or not text.startswith("memory://"): return text for line in text.splitlines(): line = line.strip() if not line or line.startswith('#') or line.startswith('memory://'): continue return line return text 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: socket.socket | BinaryIO | None = 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 af_unix = getattr(socket, "AF_UNIX", None) if af_unix is None: debug("IPC AF_UNIX is not available on this platform") return False self.sock = 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) request: Dict[str, Any] 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" # Debug: log the command being sent from SYS.logger import debug as _debug _debug(f"[IPC] Sending: {payload.strip()}") # Send command if self.is_windows: pipe = cast(BinaryIO, self.sock) pipe.write(payload.encode("utf-8")) pipe.flush() else: sock_obj = cast(socket.socket, self.sock) sock_obj.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: pipe = cast(BinaryIO, self.sock) response_data = pipe.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 sock_obj = cast(socket.socket, self.sock) chunk = sock_obj.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) # Debug: log responses from SYS.logger import debug as _debug _debug(f"[IPC] Received: {line}") # Check if this is the response to our request if resp.get("request_id") == request.get("request_id"): return resp # Handle async log messages/events for visibility event_type = resp.get("event") if event_type == "log-message": level = resp.get("level", "info") prefix = resp.get("prefix", "") text = resp.get("text", "").strip() debug(f"[MPV {level}] {prefix} {text}".strip()) elif event_type: debug(f"[MPV event] {event_type}: {resp}") elif "error" in resp and "request_id" not in resp: debug(f"[MPV error] {resp}") 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()