405 lines
15 KiB
Python
405 lines
15 KiB
Python
"""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/urls 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}")
|
|
|