"""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}