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-12-12 21:55:38 -08:00
|
|
|
import sys
|
2025-11-27 10:59:01 -08:00
|
|
|
import time as _time
|
2025-12-13 12:09:50 -08:00
|
|
|
import shutil
|
2025-12-07 00:21:30 -08:00
|
|
|
from pathlib import Path
|
2025-12-13 12:09:50 -08:00
|
|
|
from typing import Any, Dict, Optional, List, BinaryIO, Tuple, 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
|
|
|
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
_LYRIC_PROCESS: Optional[subprocess.Popen] = None
|
|
|
|
|
_LYRIC_LOG_FH: Optional[Any] = None
|
|
|
|
|
|
|
|
|
|
|
2025-12-13 12:09:50 -08:00
|
|
|
_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None
|
|
|
|
|
|
|
|
|
|
|
2025-12-16 01:45:01 -08:00
|
|
|
def _windows_hidden_subprocess_kwargs() -> Dict[str, Any]:
|
|
|
|
|
"""Best-effort kwargs to avoid flashing console windows on Windows.
|
|
|
|
|
|
|
|
|
|
Applies to subprocess.run/check_output/Popen.
|
|
|
|
|
"""
|
|
|
|
|
if platform.system() != "Windows":
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
kwargs: Dict[str, Any] = {}
|
|
|
|
|
try:
|
|
|
|
|
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
|
|
|
kwargs["creationflags"] = int(create_no_window)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Also set startupinfo to hidden, for APIs that honor it.
|
|
|
|
|
try:
|
|
|
|
|
si = subprocess.STARTUPINFO()
|
|
|
|
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
|
|
|
si.wShowWindow = subprocess.SW_HIDE
|
|
|
|
|
kwargs["startupinfo"] = si
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return kwargs
|
|
|
|
|
|
|
|
|
|
|
2025-12-13 12:09:50 -08:00
|
|
|
def _check_mpv_availability() -> Tuple[bool, Optional[str]]:
|
|
|
|
|
"""Return (available, reason) for the mpv executable.
|
|
|
|
|
|
|
|
|
|
This checks that:
|
|
|
|
|
- `mpv` is present in PATH
|
|
|
|
|
- `mpv --version` can run successfully
|
|
|
|
|
|
|
|
|
|
Result is cached per-process to avoid repeated subprocess calls.
|
|
|
|
|
"""
|
|
|
|
|
global _MPV_AVAILABILITY_CACHE
|
|
|
|
|
if _MPV_AVAILABILITY_CACHE is not None:
|
|
|
|
|
return _MPV_AVAILABILITY_CACHE
|
|
|
|
|
|
|
|
|
|
mpv_path = shutil.which("mpv")
|
|
|
|
|
if not mpv_path:
|
|
|
|
|
_MPV_AVAILABILITY_CACHE = (False, "Executable 'mpv' not found in PATH")
|
|
|
|
|
return _MPV_AVAILABILITY_CACHE
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[mpv_path, "--version"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=2,
|
2025-12-16 01:45:01 -08:00
|
|
|
**_windows_hidden_subprocess_kwargs(),
|
2025-12-13 12:09:50 -08:00
|
|
|
)
|
|
|
|
|
if result.returncode == 0:
|
|
|
|
|
_MPV_AVAILABILITY_CACHE = (True, None)
|
|
|
|
|
return _MPV_AVAILABILITY_CACHE
|
|
|
|
|
_MPV_AVAILABILITY_CACHE = (False, f"MPV returned non-zero exit code: {result.returncode}")
|
|
|
|
|
return _MPV_AVAILABILITY_CACHE
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_MPV_AVAILABILITY_CACHE = (False, f"Error running MPV: {exc}")
|
|
|
|
|
return _MPV_AVAILABILITY_CACHE
|
|
|
|
|
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]:
|
|
|
|
|
"""Return PIDs of `python -m MPV.lyric --ipc <ipc_path>` helpers (Windows only)."""
|
|
|
|
|
if platform.system() != "Windows":
|
|
|
|
|
return []
|
|
|
|
|
try:
|
|
|
|
|
ipc_path = str(ipc_path or "")
|
|
|
|
|
except Exception:
|
|
|
|
|
ipc_path = ""
|
|
|
|
|
if not ipc_path:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
# Use CIM to query command lines; output as JSON for robust parsing.
|
|
|
|
|
# Note: `ConvertTo-Json` returns a number for single item, array for many, or null.
|
|
|
|
|
ps_script = (
|
|
|
|
|
"$ipc = "
|
|
|
|
|
+ json.dumps(ipc_path)
|
|
|
|
|
+ "; "
|
|
|
|
|
"Get-CimInstance Win32_Process | "
|
|
|
|
|
"Where-Object { $_.CommandLine -and $_.CommandLine -match ' -m\\s+MPV\\.lyric(\\s|$)' -and $_.CommandLine -match ('--ipc\\s+' + [regex]::Escape($ipc)) } | "
|
|
|
|
|
"Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
out = subprocess.check_output(
|
|
|
|
|
["powershell", "-NoProfile", "-Command", ps_script],
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
timeout=2,
|
|
|
|
|
text=True,
|
2025-12-16 01:45:01 -08:00
|
|
|
**_windows_hidden_subprocess_kwargs(),
|
2025-12-12 21:55:38 -08:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
txt = (out or "").strip()
|
|
|
|
|
if not txt or txt == "null":
|
|
|
|
|
return []
|
|
|
|
|
try:
|
|
|
|
|
obj = json.loads(txt)
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
pids: List[int] = []
|
|
|
|
|
if isinstance(obj, list):
|
|
|
|
|
for v in obj:
|
|
|
|
|
try:
|
|
|
|
|
pids.append(int(v))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
pids.append(int(obj))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# De-dupe and filter obvious junk.
|
|
|
|
|
uniq: List[int] = []
|
|
|
|
|
for pid in pids:
|
|
|
|
|
if pid and pid > 0 and pid not in uniq:
|
|
|
|
|
uniq.append(pid)
|
|
|
|
|
return uniq
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _windows_kill_pids(pids: List[int]) -> None:
|
|
|
|
|
if platform.system() != "Windows":
|
|
|
|
|
return
|
|
|
|
|
for pid in pids or []:
|
|
|
|
|
try:
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["taskkill", "/PID", str(int(pid)), "/F"],
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
timeout=2,
|
2025-12-16 01:45:01 -08:00
|
|
|
**_windows_hidden_subprocess_kwargs(),
|
2025-12-12 21:55:38 -08:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
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.
|
2025-12-12 21:55:38 -08:00
|
|
|
App behavior is driven by cmdlet (e.g. `.pipe`) and the bundled Lua script.
|
2025-12-11 19:04:02 -08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
ipc_path: Optional[str] = None,
|
|
|
|
|
lua_script_path: Optional[str | Path] = None,
|
|
|
|
|
timeout: float = 5.0,
|
|
|
|
|
) -> None:
|
2025-12-13 12:09:50 -08:00
|
|
|
|
|
|
|
|
ok, reason = _check_mpv_availability()
|
|
|
|
|
if not ok:
|
|
|
|
|
raise MPVIPCError(reason or "MPV unavailable")
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
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)
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
def client(self, silent: bool = False) -> "MPVIPCClient":
|
|
|
|
|
return MPVIPCClient(socket_path=self.ipc_path, timeout=self.timeout, silent=bool(silent))
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
def is_running(self) -> bool:
|
2025-12-12 21:55:38 -08:00
|
|
|
client = self.client(silent=True)
|
2025-12-11 19:04:02 -08:00
|
|
|
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]]:
|
2025-12-12 21:55:38 -08:00
|
|
|
client = self.client(silent=bool(silent))
|
2025-12-11 19:04:02 -08:00
|
|
|
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
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
def ensure_lyric_loader_running(self) -> None:
|
|
|
|
|
"""Start (or keep) the Python lyric overlay helper.
|
|
|
|
|
|
|
|
|
|
Uses the fixed IPC pipe name so it can follow playback.
|
|
|
|
|
"""
|
|
|
|
|
global _LYRIC_PROCESS, _LYRIC_LOG_FH
|
|
|
|
|
|
|
|
|
|
# Cross-session guard (Windows): avoid spawning multiple helpers across separate CLI runs.
|
|
|
|
|
# Also clean up stale helpers when mpv isn't running anymore.
|
|
|
|
|
if platform.system() == "Windows":
|
|
|
|
|
try:
|
|
|
|
|
existing = _windows_list_lyric_helper_pids(str(self.ipc_path))
|
|
|
|
|
if existing:
|
|
|
|
|
if not self.is_running():
|
|
|
|
|
_windows_kill_pids(existing)
|
|
|
|
|
return
|
|
|
|
|
# If multiple exist, kill them and start fresh (prevents double overlays).
|
|
|
|
|
if len(existing) == 1:
|
|
|
|
|
return
|
|
|
|
|
_windows_kill_pids(existing)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if _LYRIC_PROCESS is not None and _LYRIC_PROCESS.poll() is None:
|
|
|
|
|
return
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if _LYRIC_PROCESS is not None:
|
|
|
|
|
try:
|
|
|
|
|
_LYRIC_PROCESS.terminate()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
_LYRIC_PROCESS = None
|
|
|
|
|
try:
|
|
|
|
|
if _LYRIC_LOG_FH is not None:
|
|
|
|
|
_LYRIC_LOG_FH.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
_LYRIC_LOG_FH = None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
try:
|
|
|
|
|
tmp_dir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
|
|
|
|
|
except Exception:
|
|
|
|
|
tmp_dir = Path(".")
|
|
|
|
|
log_path = str((tmp_dir / "medeia-mpv-lyric.log").resolve())
|
|
|
|
|
|
|
|
|
|
# Ensure the module can be imported even when the app is launched from a different cwd.
|
|
|
|
|
# Repo root = parent of the MPV package directory.
|
|
|
|
|
try:
|
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
|
|
|
except Exception:
|
|
|
|
|
repo_root = Path.cwd()
|
|
|
|
|
|
|
|
|
|
cmd: List[str] = [
|
|
|
|
|
sys.executable,
|
|
|
|
|
"-m",
|
|
|
|
|
"MPV.lyric",
|
|
|
|
|
"--ipc",
|
|
|
|
|
str(self.ipc_path),
|
|
|
|
|
"--log",
|
|
|
|
|
log_path,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Redirect helper stdout/stderr to the log file so we can see crashes/import errors.
|
|
|
|
|
try:
|
|
|
|
|
_LYRIC_LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace")
|
|
|
|
|
except Exception:
|
|
|
|
|
_LYRIC_LOG_FH = None
|
|
|
|
|
|
|
|
|
|
kwargs: Dict[str, Any] = {
|
|
|
|
|
"stdin": subprocess.DEVNULL,
|
|
|
|
|
"stdout": _LYRIC_LOG_FH or subprocess.DEVNULL,
|
|
|
|
|
"stderr": _LYRIC_LOG_FH or subprocess.DEVNULL,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Ensure immediate flushing to the log file.
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
|
|
|
try:
|
|
|
|
|
existing_pp = env.get("PYTHONPATH")
|
|
|
|
|
env["PYTHONPATH"] = str(repo_root) if not existing_pp else (str(repo_root) + os.pathsep + str(existing_pp))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
kwargs["env"] = env
|
|
|
|
|
|
|
|
|
|
# Make the current directory the repo root so `-m MPV.lyric` resolves reliably.
|
|
|
|
|
kwargs["cwd"] = str(repo_root)
|
|
|
|
|
if platform.system() == "Windows":
|
|
|
|
|
kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS
|
|
|
|
|
|
|
|
|
|
_LYRIC_PROCESS = subprocess.Popen(cmd, **kwargs)
|
|
|
|
|
debug(f"Lyric loader started (log={log_path})")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
debug(f"Lyric loader failed to start: {exc}")
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
def wait_for_ipc(self, retries: int = 20, delay_seconds: float = 0.2) -> bool:
|
|
|
|
|
for _ in range(max(1, retries)):
|
2025-12-12 21:55:38 -08:00
|
|
|
client = self.client(silent=True)
|
2025-12-11 19:04:02 -08:00
|
|
|
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,
|
2025-12-16 01:45:01 -08:00
|
|
|
**_windows_hidden_subprocess_kwargs(),
|
2025-12-11 19:04:02 -08:00
|
|
|
)
|
|
|
|
|
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] = {}
|
2025-12-16 01:45:01 -08:00
|
|
|
if platform.system() == "Windows":
|
|
|
|
|
# Ensure we don't flash a console window when spawning mpv.
|
|
|
|
|
flags = 0
|
|
|
|
|
try:
|
|
|
|
|
flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008)) if detached else 0
|
|
|
|
|
except Exception:
|
|
|
|
|
flags |= 0x00000008 if detached else 0
|
|
|
|
|
try:
|
|
|
|
|
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
|
|
|
|
|
except Exception:
|
|
|
|
|
flags |= 0x08000000
|
|
|
|
|
kwargs["creationflags"] = flags
|
|
|
|
|
# startupinfo is harmless for GUI apps; helps hide flashes for console-subsystem builds.
|
|
|
|
|
kwargs.update({k: v for k, v in _windows_hidden_subprocess_kwargs().items() if k != "creationflags"})
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
"""
|
2025-12-17 17:42:46 -08:00
|
|
|
override = os.environ.get("MEDEIA_MPV_IPC") or os.environ.get("MPV_IPC_SERVER")
|
|
|
|
|
if override:
|
|
|
|
|
return str(override)
|
|
|
|
|
|
2025-11-27 10:59:01 -08:00
|
|
|
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).
|
|
|
|
|
"""
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
def __init__(self, socket_path: Optional[str] = None, timeout: float = 5.0, silent: bool = False):
|
2025-11-27 10:59:01 -08:00
|
|
|
"""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"
|
2025-12-12 21:55:38 -08:00
|
|
|
self.silent = bool(silent)
|
2025-11-27 10:59:01 -08:00
|
|
|
|
|
|
|
|
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:
|
2025-12-12 21:55:38 -08:00
|
|
|
if not self.silent:
|
|
|
|
|
debug(f"Failed to connect to MPV named pipe: {exc}")
|
2025-11-27 10:59:01 -08:00
|
|
|
return False
|
|
|
|
|
else:
|
|
|
|
|
# Unix domain socket (Linux, macOS)
|
|
|
|
|
if not os.path.exists(self.socket_path):
|
2025-12-12 21:55:38 -08:00
|
|
|
if not self.silent:
|
|
|
|
|
debug(f"IPC socket not found: {self.socket_path}")
|
2025-11-27 10:59:01 -08:00
|
|
|
return False
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
af_unix = getattr(socket, "AF_UNIX", None)
|
|
|
|
|
if af_unix is None:
|
2025-12-12 21:55:38 -08:00
|
|
|
if not self.silent:
|
|
|
|
|
debug("IPC AF_UNIX is not available on this platform")
|
2025-12-11 19:04:02 -08:00
|
|
|
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:
|
2025-12-12 21:55:38 -08:00
|
|
|
if not self.silent:
|
|
|
|
|
debug(f"Failed to connect to MPV IPC: {exc}")
|
2025-11-27 10:59:01 -08:00
|
|
|
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()
|
|
|
|
|
|