This commit is contained in:
nose
2025-12-11 19:04:02 -08:00
parent 6863c6c7ea
commit 16d8a763cd
103 changed files with 4759 additions and 9156 deletions

407
MPV/mpv_ipc.py Normal file
View File

@@ -0,0 +1,407 @@
"""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()