"""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 pathlib import Path 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" MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent.parent / "LUA" / "main.lua") 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" 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 def _normalize_target(text: Optional[str]) -> Optional[str]: """Normalize playlist targets for deduping across raw/memory:// wrappers.""" if not text: return None real = _unwrap_memory_target(text) if not real: return None real = real.strip() if not real: return None lower = real.lower() # Hydrus bare hash if len(lower) == 64 and all(ch in "0123456789abcdef" for ch in lower): return lower # Hydrus file URL with hash query try: parsed = __import__("urllib.parse").parse.urlparse(real) qs = __import__("urllib.parse").parse.parse_qs(parsed.query) hash_qs = qs.get("hash", [None])[0] if hash_qs and len(hash_qs) == 64 and all(ch in "0123456789abcdef" for ch in hash_qs.lower()): return hash_qs.lower() except Exception: pass # Normalize paths/url for comparison return lower.replace('\\', '\\') 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" # Debug: log the command being sent from helper.logger import debug as _debug _debug(f"[IPC] Sending: {payload.strip()}") # 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) # Debug: log responses from helper.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() 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 0: Subscribe to log messages so MPV console errors surface in REPL _subscribe_log_messages(client) # Command 1: Ensure our Lua helper is loaded for in-window controls _ensure_lua_script_loaded(client) # Command 2: 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) # Deduplicate: if target already exists in playlist, just play it normalized_new = _normalize_target(file_url) existing_index = None existing_title = None if normalized_new: playlist_resp = client.send_command({"command": ["get_property", "playlist"], "request_id": 98}) if playlist_resp and playlist_resp.get("error") == "success": for idx, item in enumerate(playlist_resp.get("data", []) or []): for key in ("playlist-path", "filename"): norm_existing = _normalize_target(item.get(key)) if isinstance(item, dict) else None if norm_existing and norm_existing == normalized_new: existing_index = idx existing_title = item.get("title") if isinstance(item, dict) else None break if existing_index is not None: break if existing_index is not None and append: play_cmd = {"command": ["playlist-play-index", existing_index], "request_id": 99} play_resp = client.send_command(play_cmd) if play_resp and play_resp.get("error") == "success": client.send_command({"command": ["set_property", "pause", False], "request_id": 100}) safe_title = (title or existing_title or "").replace("\n", " ").replace("\r", " ").strip() if safe_title: client.send_command({"command": ["set_property", "force-media-title", safe_title], "request_id": 101}) debug(f"Already in playlist, playing existing entry: {safe_title or file_url}") return True # Command 2: Load file and inject title via memory:// wrapper so playlist shows friendly names immediately target = file_url load_mode = "append-play" if append else "replace" safe_title = (title or "").replace("\n", " ").replace("\r", " ").strip() target_to_send = target if safe_title and not str(target).startswith("memory://"): m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" target_to_send = f"memory://{m3u_content}" cmd_load = { "command": ["loadfile", target_to_send, 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 safe_title: cmd_title = { "command": ["set_property", "force-media-title", safe_title], "request_id": 2 } client.send_command(cmd_title) debug(f"Sent to existing MPV: {safe_title or 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 def _subscribe_log_messages(client: MPVIPCClient) -> None: """Ask MPV to emit log messages over IPC so we can surface console errors.""" try: client.send_command({"command": ["request_log_messages", "warn"], "request_id": 11}) except Exception as exc: debug(f"Failed to subscribe to MPV logs: {exc}") def _ensure_lua_script_loaded(client: MPVIPCClient) -> None: """Load the bundled MPV Lua script to enable in-window controls. Safe to call repeatedly; mpv will simply reload the script if already present. """ try: script_path = MPV_LUA_SCRIPT_PATH if not script_path or not os.path.exists(script_path): return resp = client.send_command({"command": ["load-script", script_path], "request_id": 12}) if resp and resp.get("error") == "success": debug(f"Loaded MPV Lua script: {script_path}") else: debug(f"MPV Lua load response: {resp}") except Exception as exc: debug(f"Failed to load MPV Lua script: {exc}")