Files
Live-streaming/app/mpv_player.py
2026-08-15 14:43:56 +08:00

467 lines
18 KiB
Python

"""Independent mpv audio player controlled through Windows JSON IPC."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
class MpvPlayer:
def __init__(
self,
exe_path: str | Path,
logger: logging.Logger,
*,
pipe_name: str = "",
log_path: str | Path | None = None,
):
self.exe_path = Path(exe_path)
self.logger = logger
self.log_path = Path(log_path) if log_path else None
pipe_name = pipe_name or f"live_streaming_mpv_{os.getpid()}"
self.pipe_path = rf"\\.\pipe\{pipe_name}"
self.process: subprocess.Popen | None = None
self.current_url = ""
self.current_metadata: dict[str, Any] = {}
self.desired_state = "stopped"
self.generation = 0
self.started_at = 0.0
self.last_progress = 0.0
self.last_progress_at = 0.0
self.last_snapshot_at = 0.0
self.recovery_count = 0
self._request_id = 0
self._ipc_lock = asyncio.Lock()
self._pipe_state_lock = threading.Lock()
self._pipe = None
def available(self) -> bool:
return self.exe_path.is_file()
def running(self) -> bool:
return self.process is not None and self.process.poll() is None
def update_exe_path(self, exe_path: str | Path) -> None:
next_path = Path(exe_path)
if next_path == self.exe_path:
return
if self.running():
self.logger.warning(f"[mpv] 播放器路径已修改,将在进程下次重启后生效: {next_path}")
self.exe_path = next_path
async def ensure_started(self) -> bool:
if self.running():
return True
await asyncio.to_thread(self._reset_pipe_sync)
if not self.available():
self.logger.error(f"[mpv] 播放器不存在: {self.exe_path}")
return False
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
args = [
str(self.exe_path),
"--idle=yes",
"--no-video",
"--force-window=no",
"--no-terminal",
"--msg-level=all=warn",
f"--input-ipc-server={self.pipe_path}",
"--keep-open=no",
"--audio-buffer=5",
"--cache=yes",
"--cache-secs=20",
"--demuxer-max-bytes=50MiB",
"--network-timeout=10",
]
if self.log_path:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
args.append(f"--log-file={self.log_path}")
try:
self.process = subprocess.Popen(
args,
cwd=str(self.exe_path.parent),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
)
for _ in range(30):
if not self.running():
break
if await self._command(["get_property", "idle-active"], retry=False) is not None:
self.logger.info(f"[mpv] 播放服务已启动: {self.exe_path}")
return True
await asyncio.sleep(0.1)
except Exception as exc:
self.logger.error(f"[mpv] 启动失败: {exc}")
return False
def _pipe_request_sync(self, command: list[Any], request_id: int) -> Any:
request = json.dumps(
{"command": command, "request_id": request_id},
ensure_ascii=False,
).encode("utf-8") + b"\n"
pipe = None
try:
with self._pipe_state_lock:
pipe = self._pipe
if pipe is None or pipe.closed:
pipe = open(self.pipe_path, "r+b", buffering=0)
self._pipe = pipe
pipe.write(request)
deadline = time.time() + 2.5
while time.time() < deadline:
response = pipe.readline()
if not response:
continue
payload = json.loads(response.decode("utf-8", errors="replace"))
if payload.get("request_id") != request_id:
continue
if payload.get("error") != "success":
return None
if "data" not in payload:
return True
data = payload["data"]
if data is None and command and command[0] != "get_property":
return True
return data
except Exception:
self._reset_pipe_sync(pipe)
raise
return None
def _reset_pipe_sync(self, expected_pipe=None) -> None:
with self._pipe_state_lock:
pipe = self._pipe
if expected_pipe is not None and pipe is not expected_pipe:
return
self._pipe = None
if pipe is not None:
try:
pipe.close()
except Exception:
pass
async def _command(self, command: list[Any], *, retry: bool = True) -> Any:
async with self._ipc_lock:
attempts = 2 if retry else 1
for attempt in range(attempts):
self._request_id += 1
request_id = self._request_id
try:
return await asyncio.wait_for(
asyncio.to_thread(self._pipe_request_sync, command, request_id),
timeout=3.0,
)
except Exception:
self._reset_pipe_sync()
if attempt + 1 < attempts:
await asyncio.sleep(0.15)
return None
def _clear_current(self) -> None:
self.current_url = ""
self.current_metadata = {}
self.started_at = 0.0
self.last_progress = 0.0
self.last_progress_at = 0.0
self.recovery_count = 0
@staticmethod
def _path_matches(expected: str, actual: str) -> bool:
expected = str(expected or "").strip()
actual = str(actual or "").strip()
if not expected or not actual:
return False
if expected == actual:
return True
if expected.lower().startswith(("http://", "https://")):
return False
try:
actual_path = actual[8:] if actual.lower().startswith("file:///") else actual
return Path(expected).resolve() == Path(actual_path).resolve()
except Exception:
return False
async def _wait_until_loaded(
self,
*,
expected_generation: int,
start_at: float,
timeout: float = 12.0,
) -> bool:
deadline = time.time() + max(2.0, timeout)
expected_url = self.current_url
while time.time() < deadline:
if expected_generation != self.generation or self.desired_state != "playing":
return False
if not self.running():
return False
path = await self._command(["get_property", "path"], retry=False)
idle = await self._command(["get_property", "idle-active"], retry=False)
if self._path_matches(expected_url, str(path or "")) and idle is False:
if start_at > 0:
seek_result = None
seek_deadline = min(deadline, time.time() + 4.0)
while time.time() < seek_deadline:
duration = await self._command(["get_property", "duration"], retry=False)
try:
duration_value = max(0.0, float(duration or 0.0))
except (TypeError, ValueError):
duration_value = 0.0
if duration_value > 0:
seek_result = await self._command(["seek", float(start_at), "absolute+exact"])
if seek_result is not None:
break
await asyncio.sleep(0.2)
if seek_result is None:
self.logger.warning(f"[mpv] 续播定位失败: {start_at:.1f} 秒")
return False
if await self._command(["set_property", "pause", False]) is None:
return False
progress_deadline = min(deadline, time.time() + 4.0)
baseline = max(0.0, float(start_at or 0.0))
while time.time() < progress_deadline:
progress = await self._command(["get_property", "time-pos"], retry=False)
duration = await self._command(["get_property", "duration"], retry=False)
try:
progress_value = max(0.0, float(progress or 0.0))
except (TypeError, ValueError):
progress_value = 0.0
try:
duration_value = max(0.0, float(duration or 0.0))
except (TypeError, ValueError):
duration_value = 0.0
idle_now = await self._command(["get_property", "idle-active"], retry=False)
if idle_now is False and (progress_value > 0.05 or duration_value > 0 or baseline > 0):
now = time.time()
self.started_at = now
self.last_progress = max(baseline, progress_value)
self.last_progress_at = now
return True
await asyncio.sleep(0.15)
await asyncio.sleep(0.15)
self.logger.warning(
f"[mpv] 音频加载超时,未进入可播放状态: "
f"{self.current_metadata.get('name') or self.current_url}"
)
return False
async def _load_current(self, *, start_at: float, expected_generation: int) -> bool:
if expected_generation != self.generation or self.desired_state != "playing" or not self.current_url:
return False
if not await self.ensure_started():
return False
if expected_generation != self.generation or self.desired_state != "playing":
return False
result = await self._command(["loadfile", self.current_url, "replace"])
if result is None or expected_generation != self.generation or self.desired_state != "playing":
return False
return await self._wait_until_loaded(
expected_generation=expected_generation,
start_at=start_at,
)
async def play(self, url: str, metadata: dict[str, Any], *, start_at: float = 0.0) -> bool:
self.generation += 1
generation = self.generation
self.desired_state = "playing"
self.current_url = str(url)
self.current_metadata = dict(metadata)
self.recovery_count = 0
ok = await self._load_current(start_at=start_at, expected_generation=generation)
if not ok and generation == self.generation:
self.desired_state = "stopped"
self._clear_current()
return ok
async def pause(self) -> bool:
if not self.current_url:
return False
self.desired_state = "paused"
if not self.running():
return True
return await self._command(["set_property", "pause", True]) is not None
async def resume(self) -> bool:
if not self.current_url:
return False
self.desired_state = "playing"
self.last_progress_at = time.time()
if not self.running():
return await self._load_current(start_at=self.last_progress, expected_generation=self.generation)
return await self._command(["set_property", "pause", False]) is not None
async def stop(self) -> bool:
self.generation += 1
self.desired_state = "stopped"
self._clear_current()
if not self.running():
return True
return await self._command(["stop"]) is not None
async def close(self) -> None:
self.generation += 1
self.desired_state = "stopped"
self._clear_current()
if self.running():
await self._command(["quit"])
await asyncio.sleep(0.15)
if self.running():
self.process.terminate()
try:
await asyncio.to_thread(self.process.wait, 2)
except Exception:
if self.running():
self.process.kill()
self.process = None
await asyncio.to_thread(self._reset_pipe_sync)
async def snapshot(self) -> dict[str, Any]:
now = time.time()
if not self.running():
return self._snapshot_payload(False, 0.0, 0.0, True, False, False, "", now)
progress, duration, paused, idle, eof, path = await asyncio.gather(
self._command(["get_property", "time-pos"]),
self._command(["get_property", "duration"]),
self._command(["get_property", "pause"]),
self._command(["get_property", "idle-active"]),
self._command(["get_property", "eof-reached"]),
self._command(["get_property", "path"]),
)
try:
progress_value = max(0.0, float(progress or 0.0))
except (TypeError, ValueError):
progress_value = 0.0
try:
duration_value = max(0.0, float(duration or self.current_metadata.get("duration_sec", 0) or 0))
except (TypeError, ValueError):
duration_value = 0.0
idle_value = bool(idle) if idle is not None else not bool(self.current_url)
paused_value = bool(paused)
eof_value = bool(eof)
playing = (
self.desired_state == "playing"
and bool(self.current_url)
and not paused_value
and not idle_value
and not eof_value
)
if progress_value > self.last_progress + 0.2:
self.last_progress = progress_value
self.last_progress_at = now
self.recovery_count = 0
self.last_snapshot_at = now
return self._snapshot_payload(
playing,
progress_value,
duration_value,
idle_value,
paused_value,
eof_value,
str(path or ""),
now,
)
def _snapshot_payload(
self,
playing: bool,
progress: float,
duration: float,
idle: bool,
paused: bool,
eof: bool,
path: str,
now: float,
) -> dict[str, Any]:
metadata = self.current_metadata
return {
"playing": playing,
"paused": paused,
"idle": idle,
"eof": eof,
"desired_state": self.desired_state,
"generation": self.generation,
"path": path,
"current": {
"id": str(metadata.get("id", "")),
"title": metadata.get("name") or ("暂无歌曲" if idle else "正在加载"),
"artist": metadata.get("artist") or "mpv",
"cover": metadata.get("cover", ""),
"cover_hash": metadata.get("cover_hash", ""),
"duration": duration,
"progress": progress,
"source": "mpv",
},
"playlist": [],
"requests": [],
"monitor": {
"online": self.running(),
"source": "mpv.ipc",
"platform": "mpv",
"updated_at": now,
"targets": [],
"allow_all": False,
},
}
def mark_ended(self) -> None:
self.generation += 1
self.desired_state = "stopped"
self._clear_current()
async def maintain(self, *, stall_seconds: float = 12.0) -> dict[str, Any]:
"""Recover only unexpected failures while the desired state is playing."""
if self.desired_state != "playing" or not self.current_url:
return {"action": "none"}
generation = self.generation
now = time.time()
if not self.running():
progress = self.last_progress
self.process = None
if await self._load_current(start_at=progress, expected_generation=generation):
self.logger.warning(f"[mpv] 进程退出后已从 {progress:.1f} 秒恢复")
return {"action": "process_restarted", "progress": progress}
return {"action": "failed", "reason": "process_restart_failed"}
state = await self.snapshot()
if generation != self.generation or self.desired_state != "playing":
return {"action": "superseded", "snapshot": state}
progress = float((state.get("current") or {}).get("progress", 0) or 0)
duration = float((state.get("current") or {}).get("duration", 0) or 0)
effective_progress = max(progress, self.last_progress)
near_end = duration > 0 and effective_progress >= max(0.0, duration - 2.0)
unloaded_after_progress = (
state.get("idle")
and not str(state.get("path") or "")
and effective_progress >= 0.5
)
if state.get("eof") or (state.get("idle") and near_end) or unloaded_after_progress:
self.mark_ended()
return {"action": "ended", "progress": effective_progress, "duration": duration, "snapshot": state}
loading_grace = now - self.started_at < 2.5
if state.get("paused") and not loading_grace:
if await self._command(["set_property", "pause", False]) is not None:
self.last_progress_at = now
self.logger.warning("[mpv] 检测到非预期暂停,已自动继续播放")
return {"action": "resumed", "progress": progress, "snapshot": state}
if state.get("idle") and not loading_grace:
return {"action": "reload_required", "reason": "unexpected_idle", "progress": progress, "snapshot": state}
if self.last_progress_at and not loading_grace and now - self.last_progress_at >= max(3.0, stall_seconds):
self.recovery_count += 1
if self.recovery_count == 1:
await self._command(["set_property", "pause", False])
self.last_progress_at = now
return {"action": "unstalled", "progress": progress, "snapshot": state}
return {"action": "reload_required", "reason": "stalled", "progress": progress, "snapshot": state}
return {"action": "none", "snapshot": state}