mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:07:03 +01:00
The stateless half of talking to mpv moves out:
_mpv_ipc.py read_reply, build_argv, encode, note_errors, and
probe_properties/query lifted out of MpvPlayer.probe/_query,
which only ever needed the socket path
_player_api.py the Player Protocol that ui.py actually depends on
player.py keeps the class that owns the process and the socket, and re-exports
Player/build_argv/encode through __all__.
_note_errors stays as a bound delegate on MpvPlayer: test_player.py calls it on
the instance, and the tests are out of scope here. Caught by the suite failing,
not by guessing.
The probe constants moved across verbatim -- _PROBE_ID is 9001, and read_reply
matches replies on it, so a placeholder would have made every probe hang until
timeout while still returning a plausible error string.
player.py 373 -> 235, _mpv_ipc.py 158, _player_api.py 49. 277 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
159 lines
4.5 KiB
Python
159 lines
4.5 KiB
Python
"""Talking to mpv: the command line, the wire format, and the reply reader.
|
|
|
|
Split out of :mod:`python_pkg.wsg_grabber.player` to keep it under the 250-line
|
|
cap. Everything here is stateless -- it does not own the process or the socket,
|
|
only the shapes of what goes over them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from python_pkg.wsg_grabber import logs
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
_PROBE_ID = 9001
|
|
_PROBE_TIMEOUT_S = 2.0
|
|
|
|
# What to record when diagnosing an apparently frozen reviewer: which file mpv
|
|
# holds, whether it is actually advancing, and the size it configured its video
|
|
# output to (which changes per video, so a static value is itself a symptom).
|
|
_PROBE_PROPERTIES: Final[tuple[str, ...]] = (
|
|
"path",
|
|
"pause",
|
|
"core-idle",
|
|
"eof-reached",
|
|
"playback-time",
|
|
"width",
|
|
"height",
|
|
)
|
|
|
|
|
|
def read_reply(conn: socket.socket) -> object:
|
|
"""Read until mpv answers the probe request.
|
|
|
|
Args:
|
|
conn: A freshly connected control socket.
|
|
|
|
Returns:
|
|
object: The ``data`` field of the reply, or an ``error:`` string.
|
|
"""
|
|
buffer = b""
|
|
while True:
|
|
chunk = conn.recv(65536)
|
|
if not chunk:
|
|
return "error: socket closed"
|
|
buffer += chunk
|
|
for line in buffer.split(b"\n"):
|
|
if not line:
|
|
continue
|
|
try:
|
|
message = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if message.get("request_id") == _PROBE_ID:
|
|
return message.get("data")
|
|
|
|
|
|
def build_argv(wid: int, ipc_path: Path) -> list[str]:
|
|
"""Build the mpv command line.
|
|
|
|
``--input-vo-keyboard=no`` matters: with ``--wid`` mpv reparents a child
|
|
window over the Tk frame, and if mpv claims the keyboard the Tk bindings
|
|
never see a keypress. Declining it leaves X delivering keys to the focused
|
|
toplevel, which is the Tk window.
|
|
|
|
Args:
|
|
wid: X11 window id of the frame to draw into.
|
|
ipc_path: Where to create the control socket.
|
|
|
|
Returns:
|
|
list[str]: Argument vector for :func:`subprocess.Popen`.
|
|
"""
|
|
return [
|
|
"mpv",
|
|
f"--wid={wid}",
|
|
f"--input-ipc-server={ipc_path}",
|
|
"--idle=yes",
|
|
"--loop-file=inf",
|
|
"--keep-open=yes",
|
|
"--no-terminal",
|
|
"--osc=no",
|
|
"--input-default-bindings=no",
|
|
"--input-vo-keyboard=no",
|
|
"--no-config",
|
|
]
|
|
|
|
|
|
def encode(command: Sequence[object]) -> bytes:
|
|
"""Encode one mpv IPC command.
|
|
|
|
Args:
|
|
command: Command name followed by its arguments.
|
|
|
|
Returns:
|
|
bytes: Newline-terminated JSON, ready for the socket.
|
|
"""
|
|
return json.dumps({"command": list(command)}).encode("utf-8") + b"\n"
|
|
|
|
|
|
def note_errors(chunk: bytes) -> None:
|
|
"""Log any failure mpv reported back.
|
|
|
|
Args:
|
|
chunk: Raw bytes just read from the socket.
|
|
"""
|
|
for line in chunk.split(b"\n"):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
message = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
failure = message.get("error")
|
|
if isinstance(failure, str) and failure != "success":
|
|
logs.warning("player.reply_error", detail=failure, reply=message)
|
|
|
|
|
|
def probe_properties(ipc_path: Path) -> dict[str, object]:
|
|
"""Ask mpv for each diagnostic property in turn.
|
|
|
|
Args:
|
|
ipc_path: Where mpv's control socket lives.
|
|
|
|
Returns:
|
|
dict[str, object]: Property name to value; a property that could not
|
|
be read maps to a string starting with ``error:``.
|
|
"""
|
|
return {name: query(ipc_path, name) for name in _PROBE_PROPERTIES}
|
|
|
|
|
|
def query(ipc_path: Path, name: str) -> object:
|
|
"""Read one mpv property over a short-lived connection.
|
|
|
|
A separate connection avoids interleaving with the asynchronous events mpv
|
|
pushes down the main socket, which would otherwise make replies easy to
|
|
mis-attribute.
|
|
|
|
Args:
|
|
ipc_path: Where mpv's control socket lives.
|
|
name: Property name.
|
|
|
|
Returns:
|
|
object: The value, or an ``error:`` string.
|
|
"""
|
|
request = json.dumps({"command": ["get_property", name], "request_id": _PROBE_ID})
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
probe.settimeout(_PROBE_TIMEOUT_S)
|
|
probe.connect(str(ipc_path))
|
|
probe.sendall(request.encode("utf-8") + b"\n")
|
|
return read_reply(probe)
|
|
except OSError as exc:
|
|
return f"error: {exc}"
|