Update Live-streaming code (auto-daily features)
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user