2025-11-25 20:09:33 -08:00
|
|
|
"""Background task handling and IPC helpers for mpv integration."""
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
import errno
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import socket
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
from SYS.logger import log
|
2025-11-25 20:09:33 -08:00
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from typing import IO, Iterable
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def connect_ipc(path: str, timeout: float = 5.0) -> IO[bytes] | None:
|
|
|
|
|
"""Connect to the mpv IPC server located at *path*."""
|
|
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
if not path:
|
|
|
|
|
return None
|
2025-12-29 17:05:03 -08:00
|
|
|
if os.name == "nt":
|
2025-11-25 20:09:33 -08:00
|
|
|
# mpv exposes a named pipe on Windows. Keep retrying until it is ready.
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
return open(path, "r+b", buffering=0)
|
2025-11-25 20:09:33 -08:00
|
|
|
except FileNotFoundError:
|
|
|
|
|
if time.time() > deadline:
|
|
|
|
|
return None
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
except OSError as exc: # Pipe busy
|
2025-12-18 22:50:21 -08:00
|
|
|
# Windows named pipes can intermittently raise EINVAL while the pipe exists
|
|
|
|
|
# but is not ready/accepting connections yet.
|
2025-12-29 18:42:02 -08:00
|
|
|
if exc.errno not in (errno.ENOENT,
|
|
|
|
|
errno.EPIPE,
|
|
|
|
|
errno.EBUSY,
|
|
|
|
|
errno.EINVAL):
|
2025-11-25 20:09:33 -08:00
|
|
|
raise
|
|
|
|
|
if time.time() > deadline:
|
|
|
|
|
return None
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
else:
|
|
|
|
|
sock = socket.socket(socket.AF_UNIX)
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
sock.connect(path)
|
2025-12-29 17:05:03 -08:00
|
|
|
return sock.makefile("r+b", buffering=0)
|
2025-11-25 20:09:33 -08:00
|
|
|
except FileNotFoundError:
|
|
|
|
|
if time.time() > deadline:
|
|
|
|
|
return None
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
if exc.errno not in (errno.ENOENT, errno.ECONNREFUSED):
|
|
|
|
|
raise
|
|
|
|
|
if time.time() > deadline:
|
|
|
|
|
return None
|
|
|
|
|
time.sleep(0.05)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def ipc_sender(ipc: IO[bytes] | None):
|
|
|
|
|
"""Create a helper function for sending script messages via IPC."""
|
|
|
|
|
if ipc is None:
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _noop(_event: str, _payload: dict) -> None:
|
|
|
|
|
return None
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
return _noop
|
|
|
|
|
lock = threading.Lock()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _send(event: str, payload: dict) -> None:
|
2025-12-29 17:05:03 -08:00
|
|
|
message = json.dumps(
|
2025-12-29 18:42:02 -08:00
|
|
|
{
|
|
|
|
|
"command": ["script-message",
|
|
|
|
|
event,
|
|
|
|
|
json.dumps(payload)]
|
|
|
|
|
},
|
|
|
|
|
ensure_ascii=False
|
2025-12-29 17:05:03 -08:00
|
|
|
)
|
|
|
|
|
encoded = message.encode("utf-8") + b"\n"
|
2025-11-25 20:09:33 -08:00
|
|
|
with lock:
|
|
|
|
|
try:
|
|
|
|
|
ipc.write(encoded)
|
|
|
|
|
ipc.flush()
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
return _send
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def iter_stream(stream: Iterable[str]) -> Iterable[str]:
|
|
|
|
|
for raw in stream:
|
2025-12-29 17:05:03 -08:00
|
|
|
yield raw.rstrip("\r\n")
|
|
|
|
|
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _run_task(args, parser) -> int:
|
|
|
|
|
if not args.command:
|
2025-12-29 18:42:02 -08:00
|
|
|
parser.error(
|
|
|
|
|
'run-task requires a command to execute (use "--" before the command).'
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
env = os.environ.copy()
|
|
|
|
|
for entry in args.env:
|
2025-12-29 17:05:03 -08:00
|
|
|
key, sep, value = entry.partition("=")
|
2025-11-25 20:09:33 -08:00
|
|
|
if not sep:
|
2025-12-29 17:05:03 -08:00
|
|
|
parser.error(f"Invalid environment variable definition: {entry!r}")
|
2025-11-25 20:09:33 -08:00
|
|
|
env[key] = value
|
|
|
|
|
command = list(args.command)
|
2025-12-29 17:05:03 -08:00
|
|
|
if command and command[0] == "--":
|
2025-11-25 20:09:33 -08:00
|
|
|
command.pop(0)
|
|
|
|
|
notifier = ipc_sender(connect_ipc(args.ipc, timeout=args.ipc_timeout))
|
|
|
|
|
if not command:
|
2025-12-29 17:05:03 -08:00
|
|
|
notifier(
|
|
|
|
|
"downlow-task-event",
|
|
|
|
|
{
|
|
|
|
|
"id": args.task_id,
|
|
|
|
|
"event": "error",
|
|
|
|
|
"message": "No command provided after separator",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
log("[downlow.py] No command provided for run-task", file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
return 1
|
|
|
|
|
if command and isinstance(command[0], str) and sys.executable:
|
|
|
|
|
first = command[0].lower()
|
2025-12-29 18:42:02 -08:00
|
|
|
if first in {"python",
|
|
|
|
|
"python3",
|
|
|
|
|
"py",
|
|
|
|
|
"python.exe",
|
|
|
|
|
"python3.exe",
|
|
|
|
|
"py.exe"}:
|
2025-11-25 20:09:33 -08:00
|
|
|
command[0] = sys.executable
|
2025-12-29 17:05:03 -08:00
|
|
|
if os.environ.get("DOWNLOW_DEBUG"):
|
2025-11-25 20:09:33 -08:00
|
|
|
log(f"Launching command: {command}", file=sys.stderr)
|
2025-12-29 17:05:03 -08:00
|
|
|
notifier(
|
|
|
|
|
"downlow-task-event",
|
|
|
|
|
{
|
|
|
|
|
"id": args.task_id,
|
|
|
|
|
"event": "start",
|
|
|
|
|
"command": command,
|
|
|
|
|
"cwd": args.cwd or os.getcwd(),
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-12-24 02:13:21 -08:00
|
|
|
|
|
|
|
|
popen_kwargs = {}
|
2025-12-29 17:05:03 -08:00
|
|
|
if os.name == "nt":
|
2025-12-24 02:13:21 -08:00
|
|
|
# Avoid flashing a console window when spawning console-subsystem executables.
|
|
|
|
|
flags = 0
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
|
2025-12-24 02:13:21 -08:00
|
|
|
except Exception:
|
|
|
|
|
flags |= 0x08000000
|
2025-12-29 17:05:03 -08:00
|
|
|
popen_kwargs["creationflags"] = flags
|
2025-12-24 02:13:21 -08:00
|
|
|
try:
|
|
|
|
|
si = subprocess.STARTUPINFO()
|
|
|
|
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
|
|
|
si.wShowWindow = subprocess.SW_HIDE
|
2025-12-29 17:05:03 -08:00
|
|
|
popen_kwargs["startupinfo"] = si
|
2025-12-24 02:13:21 -08:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
process = subprocess.Popen(
|
|
|
|
|
command,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.PIPE,
|
|
|
|
|
cwd=args.cwd or None,
|
|
|
|
|
env=env,
|
|
|
|
|
text=True,
|
|
|
|
|
bufsize=1,
|
|
|
|
|
universal_newlines=True,
|
2025-12-24 02:13:21 -08:00
|
|
|
**popen_kwargs,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except FileNotFoundError as exc:
|
2025-12-29 17:05:03 -08:00
|
|
|
notifier(
|
|
|
|
|
"downlow-task-event",
|
|
|
|
|
{
|
|
|
|
|
"id": args.task_id,
|
|
|
|
|
"event": "error",
|
|
|
|
|
"message": f"Executable not found: {exc.filename}",
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
log(f"{exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
stdout_lines: list[str] = []
|
|
|
|
|
stderr_lines: list[str] = []
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def pump(stream: IO[str], label: str, sink: list[str]) -> None:
|
|
|
|
|
for line in iter_stream(stream):
|
|
|
|
|
sink.append(line)
|
2025-12-29 17:05:03 -08:00
|
|
|
notifier(
|
|
|
|
|
"downlow-task-event",
|
|
|
|
|
{
|
|
|
|
|
"id": args.task_id,
|
|
|
|
|
"event": label,
|
|
|
|
|
"line": line,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
threads = []
|
|
|
|
|
if process.stdout:
|
2025-12-29 17:05:03 -08:00
|
|
|
t_out = threading.Thread(
|
2025-12-29 18:42:02 -08:00
|
|
|
target=pump,
|
|
|
|
|
args=(process.stdout,
|
|
|
|
|
"stdout",
|
|
|
|
|
stdout_lines),
|
|
|
|
|
daemon=True
|
2025-12-29 17:05:03 -08:00
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
t_out.start()
|
|
|
|
|
threads.append(t_out)
|
|
|
|
|
if process.stderr:
|
2025-12-29 17:05:03 -08:00
|
|
|
t_err = threading.Thread(
|
2025-12-29 18:42:02 -08:00
|
|
|
target=pump,
|
|
|
|
|
args=(process.stderr,
|
|
|
|
|
"stderr",
|
|
|
|
|
stderr_lines),
|
|
|
|
|
daemon=True
|
2025-12-29 17:05:03 -08:00
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
t_err.start()
|
|
|
|
|
threads.append(t_err)
|
|
|
|
|
return_code = process.wait()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join(timeout=0.1)
|
2025-12-29 17:05:03 -08:00
|
|
|
notifier(
|
|
|
|
|
"downlow-task-event",
|
|
|
|
|
{
|
|
|
|
|
"id": args.task_id,
|
|
|
|
|
"event": "exit",
|
|
|
|
|
"returncode": return_code,
|
|
|
|
|
"success": return_code == 0,
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
# Also mirror aggregated output to stdout/stderr for compatibility when IPC is unavailable.
|
|
|
|
|
if stdout_lines:
|
2025-12-29 17:05:03 -08:00
|
|
|
log("\n".join(stdout_lines))
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_lines:
|
2025-12-29 17:05:03 -08:00
|
|
|
log("\n".join(stderr_lines), file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
return return_code
|