Files
Medios-Macina/MPV/mpv_ipc.py

408 lines
14 KiB
Python
Raw Normal View History

2025-11-27 10:59:01 -08:00
"""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
2025-12-11 19:04:02 -08:00
import subprocess
2025-11-27 10:59:01 -08:00
import time as _time
2025-12-07 00:21:30 -08:00
from pathlib import Path
2025-12-11 19:04:02 -08:00
from typing import Any, Dict, Optional, List, BinaryIO, cast
2025-11-27 10:59:01 -08:00
2025-12-11 19:04:02 -08:00
from SYS.logger import debug
2025-11-27 10:59:01 -08:00
# Fixed pipe name for persistent MPV connection across all Python sessions
FIXED_IPC_PIPE_NAME = "mpv-medeia-macina"
2025-12-11 19:04:02 -08:00
MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent / "LUA" / "main.lua")
2025-11-27 10:59:01 -08:00
class MPVIPCError(Exception):
"""Raised when MPV IPC communication fails."""
pass
2025-12-11 19:04:02 -08:00
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)
2025-11-27 10:59:01 -08:00
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"
2025-12-07 00:21:30 -08:00
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
2025-11-27 10:59:01 -08:00
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()
2025-12-11 19:04:02 -08:00
self.sock: socket.socket | BinaryIO | None = None
2025-11-27 10:59:01 -08:00
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
2025-12-11 19:04:02 -08:00
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)
2025-11-27 10:59:01 -08:00
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)
2025-12-11 19:04:02 -08:00
request: Dict[str, Any]
2025-11-27 10:59:01 -08:00
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"
2025-12-01 14:42:30 -08:00
# Debug: log the command being sent
2025-12-11 19:04:02 -08:00
from SYS.logger import debug as _debug
2025-12-01 14:42:30 -08:00
_debug(f"[IPC] Sending: {payload.strip()}")
2025-11-27 10:59:01 -08:00
# Send command
if self.is_windows:
2025-12-11 19:04:02 -08:00
pipe = cast(BinaryIO, self.sock)
pipe.write(payload.encode("utf-8"))
pipe.flush()
2025-11-27 10:59:01 -08:00
else:
2025-12-11 19:04:02 -08:00
sock_obj = cast(socket.socket, self.sock)
sock_obj.sendall(payload.encode("utf-8"))
2025-11-27 10:59:01 -08:00
# 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:
2025-12-11 19:04:02 -08:00
pipe = cast(BinaryIO, self.sock)
response_data = pipe.readline()
2025-11-27 10:59:01 -08:00
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
2025-12-11 19:04:02 -08:00
sock_obj = cast(socket.socket, self.sock)
chunk = sock_obj.recv(4096)
2025-11-27 10:59:01 -08:00
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)
2025-12-01 14:42:30 -08:00
# Debug: log responses
2025-12-11 19:04:02 -08:00
from SYS.logger import debug as _debug
2025-12-01 14:42:30 -08:00
_debug(f"[IPC] Received: {line}")
2025-11-27 10:59:01 -08:00
# Check if this is the response to our request
if resp.get("request_id") == request.get("request_id"):
return resp
2025-12-07 00:21:30 -08:00
# 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}")
2025-11-27 10:59:01 -08:00
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()