966 lines
36 KiB
Python
966 lines
36 KiB
Python
"""
|
||
音乐播放器 -> 直播间 UI 音乐状态同步
|
||
|
||
通过 Windows 10/11 的 SMTC(System Media Transport Controls)读取当前媒体会话,
|
||
并推送到 BGI 直播间 Web 服务。默认偏向网易云音乐,但可在 config/config.json
|
||
的 music_monitor 节点或命令行中配置目标播放器。
|
||
|
||
播放状态判断(参考 now-playing-service/NeteaseMusicService.cs):
|
||
- 优先用 Windows 音频会话的峰值音量(volume>0 = Playing),最贴近"是否真在出声"
|
||
- volume=0 但 UIA 进度最近 1.5s 内变化 → 仍视为 Playing(静音播放/拖进度条场景)
|
||
- 否则 Paused
|
||
|
||
封面防闪:
|
||
- 只在 SMTC 缩略图 hash 变化时才重写 music_cover.jpg(原子 tmp→replace)
|
||
- 同一 hash 不重写文件,前端不会因文件变化触发 reload
|
||
- 标题/歌手来自 cloudmusic 窗口标题(已稳定,不改)
|
||
"""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import hashlib
|
||
import ctypes
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import traceback
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
APP_DIR = Path(__file__).resolve().parent
|
||
if str(APP_DIR) not in sys.path:
|
||
sys.path.insert(0, str(APP_DIR))
|
||
|
||
import aiohttp
|
||
import winrt.windows.media.control as wmc
|
||
import winrt.windows.storage.streams as streams
|
||
|
||
try:
|
||
import uiautomation as uia
|
||
except Exception:
|
||
uia = None
|
||
|
||
# pycaw: 读取 Windows 音频会话峰值音量(参考 now-playing-service 的 CSCore.AudioMeterInformation)
|
||
try:
|
||
from pycaw.pycaw import AudioUtilities # type: ignore
|
||
_PYCAW_OK = True
|
||
except Exception:
|
||
_PYCAW_OK = False
|
||
|
||
from core.runtime_paths import CONFIG_DIR, DATA_DIR, WEB_DIR, ensure_runtime_dirs
|
||
|
||
ensure_runtime_dirs()
|
||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||
MUSIC_FILE = DATA_DIR / "music_state.json"
|
||
COVER_FILE = WEB_DIR / "music_cover.jpg"
|
||
|
||
DEFAULT_TARGETS = [
|
||
"网易云音乐",
|
||
"Netease",
|
||
"CloudMusic",
|
||
"cloudmusic",
|
||
"YesPlayMusic",
|
||
"Listen1",
|
||
"QQMusic",
|
||
"qqmusic",
|
||
"spotify",
|
||
]
|
||
|
||
DEFAULT_MONITOR_CONFIG = {
|
||
"platform": "netease",
|
||
"targets": ["网易云音乐", "Netease", "CloudMusic", "cloudmusic"],
|
||
"allow_all": False,
|
||
"interval_sec": 1.0,
|
||
"holdover_ms": 1500,
|
||
"prefer_playing": True,
|
||
"keep_last_when_none": True,
|
||
"cover_enabled": True,
|
||
"auto_resume_enabled": False,
|
||
"auto_resume_interval_sec": 3,
|
||
"auto_resume_stall_sec": 10,
|
||
"extra_filter": "",
|
||
}
|
||
|
||
|
||
def load_monitor_config() -> dict[str, Any]:
|
||
cfg = dict(DEFAULT_MONITOR_CONFIG)
|
||
if CONFIG_FILE.exists():
|
||
try:
|
||
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||
if isinstance(data.get("music_monitor"), dict):
|
||
cfg.update(data["music_monitor"])
|
||
except Exception:
|
||
pass
|
||
targets = cfg.get("targets") or []
|
||
if isinstance(targets, str):
|
||
targets = [x.strip() for x in targets.replace(",", ",").split(",") if x.strip()]
|
||
extra = str(cfg.get("extra_filter", "")).strip()
|
||
if extra:
|
||
targets.append(extra)
|
||
cfg["targets"] = [str(x).strip() for x in targets if str(x).strip()]
|
||
return cfg
|
||
|
||
|
||
def session_identity(session) -> str:
|
||
display = getattr(session, "source_app_display_name", "") or ""
|
||
aumid = getattr(session, "source_app_user_model_id", "") or ""
|
||
return f"{display} {aumid}".strip()
|
||
|
||
|
||
def is_target_app(session, targets: list[str], allow_all: bool) -> bool:
|
||
if allow_all or not targets:
|
||
return True
|
||
combined = session_identity(session).lower()
|
||
return any(target.lower() in combined for target in targets)
|
||
|
||
|
||
def playback_status_int(session) -> int:
|
||
try:
|
||
return int(session.get_playback_info().playback_status)
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def is_playing(session) -> bool:
|
||
return playback_status_int(session) == 4
|
||
|
||
|
||
async def choose_session(mgr, cfg: dict[str, Any]):
|
||
"""从全部媒体会话中选择最合适的目标会话。"""
|
||
targets = cfg.get("targets", [])
|
||
allow_all = bool(cfg.get("allow_all", False))
|
||
prefer_playing = bool(cfg.get("prefer_playing", True))
|
||
|
||
sessions = list(mgr.get_sessions())
|
||
candidates = [s for s in sessions if is_target_app(s, targets, allow_all)]
|
||
if not candidates:
|
||
current = mgr.get_current_session()
|
||
if current and is_target_app(current, targets, allow_all):
|
||
candidates = [current]
|
||
if not candidates:
|
||
return None
|
||
|
||
# 当前系统媒体会话通常最能代表用户正在操作的播放器;优先选择它,
|
||
# 避免网易云残留多个 SMTC 会话时反复读到已停止更新的旧会话。
|
||
current = mgr.get_current_session()
|
||
if current and current in candidates and (not prefer_playing or is_playing(current)):
|
||
return current
|
||
|
||
if prefer_playing:
|
||
playing = [s for s in candidates if is_playing(s)]
|
||
if playing:
|
||
return playing[0]
|
||
if current and current in candidates:
|
||
return current
|
||
return candidates[0]
|
||
|
||
|
||
async def read_thumbnail(thumb_ref):
|
||
if thumb_ref is None:
|
||
return None
|
||
try:
|
||
stream = await thumb_ref.open_read_async()
|
||
size = stream.size
|
||
if size <= 0 or size > 5 * 1024 * 1024:
|
||
return None
|
||
buffer = streams.Buffer(size)
|
||
await stream.read_async(buffer, size, streams.InputStreamOptions.READ_AHEAD)
|
||
data = bytes(buffer)
|
||
return data if len(data) > 100 else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def write_cover_atomic(data: bytes, previous_hash: str = "") -> tuple[str, str]:
|
||
cover_hash = hashlib.md5(data).hexdigest()
|
||
if cover_hash == previous_hash and COVER_FILE.exists():
|
||
return "/music_cover.jpg", cover_hash
|
||
COVER_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = COVER_FILE.with_suffix(".tmp")
|
||
tmp.write_bytes(data)
|
||
tmp.replace(COVER_FILE)
|
||
return "/music_cover.jpg", cover_hash
|
||
|
||
|
||
def parse_time_to_seconds(text: str) -> int | None:
|
||
parts = str(text).strip().split(":")
|
||
if len(parts) not in (2, 3):
|
||
return None
|
||
try:
|
||
nums = [int(x) for x in parts]
|
||
except ValueError:
|
||
return None
|
||
if any(x < 0 for x in nums) or nums[-1] >= 60:
|
||
return None
|
||
if len(nums) == 2:
|
||
return nums[0] * 60 + nums[1]
|
||
if nums[1] >= 60:
|
||
return None
|
||
return nums[0] * 3600 + nums[1] * 60 + nums[2]
|
||
|
||
|
||
def parse_progress_text(text: str) -> tuple[int, int] | None:
|
||
cleaned = str(text).replace(" ", "")
|
||
m = re.search(r"(\d{1,2}:\d{2}(?::\d{2})?)\s*[/|/|]\s*(\d{1,2}:\d{2}(?::\d{2})?)", cleaned)
|
||
if not m:
|
||
return None
|
||
current = parse_time_to_seconds(m.group(1))
|
||
total = parse_time_to_seconds(m.group(2))
|
||
if current is None or total is None or total <= 0 or current > total + 2:
|
||
return None
|
||
return max(0, current), max(0, total)
|
||
|
||
|
||
def _cloudmusic_pids() -> list[int]:
|
||
if not hasattr(ctypes, "windll"):
|
||
return []
|
||
try:
|
||
output = subprocess.check_output(
|
||
["tasklist", "/FI", "IMAGENAME eq cloudmusic.exe", "/FO", "CSV", "/NH"],
|
||
text=True, encoding="gbk", errors="ignore", creationflags=0x08000000,
|
||
)
|
||
pids = []
|
||
for line in output.splitlines():
|
||
parts = [p.strip().strip('"') for p in line.split(",")]
|
||
if len(parts) >= 2 and parts[0].lower() == "cloudmusic.exe":
|
||
try:
|
||
pids.append(int(parts[1]))
|
||
except ValueError:
|
||
pass
|
||
return pids
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _window_titles_by_pids(pids: list[int]) -> list[str]:
|
||
if not pids or not hasattr(ctypes, "windll"):
|
||
return []
|
||
titles = []
|
||
user32 = ctypes.windll.user32
|
||
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
||
def enum_proc(hwnd, lparam):
|
||
try:
|
||
if not user32.IsWindowVisible(hwnd):
|
||
return True
|
||
pid = ctypes.c_ulong()
|
||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||
if pid.value not in pids:
|
||
return True
|
||
length = user32.GetWindowTextLengthW(hwnd)
|
||
if length <= 0:
|
||
return True
|
||
buf = ctypes.create_unicode_buffer(length + 1)
|
||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||
title = (buf.value or "").strip()
|
||
if title:
|
||
titles.append(title)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
try:
|
||
user32.EnumWindows(EnumWindowsProc(enum_proc), 0)
|
||
except Exception:
|
||
pass
|
||
return titles
|
||
|
||
|
||
def read_netease_window_title() -> str:
|
||
"""参考 now-playing-service:从 cloudmusic 进程窗口标题取“歌名 - 歌手”。"""
|
||
for title in _window_titles_by_pids(_cloudmusic_pids()):
|
||
if " - " in title and "MediaPlayer" not in title:
|
||
return title.replace("/", " / ").strip()
|
||
return ""
|
||
|
||
|
||
def read_netease_progress_uia() -> tuple[int, int] | None:
|
||
"""参考 now-playing-service:只在 cloudmusic 播放窗口子树中解析 MM:SS / MM:SS。"""
|
||
if uia is None:
|
||
return None
|
||
try:
|
||
pids = set(_cloudmusic_pids())
|
||
root = uia.GetRootControl()
|
||
for win in root.GetChildren():
|
||
try:
|
||
pid = int(getattr(win, "ProcessId", 0) or 0)
|
||
name = (getattr(win, "Name", "") or "")
|
||
if pids and pid not in pids:
|
||
continue
|
||
if " - " not in name and "cloudmusic" not in (getattr(win, "ClassName", "") or "").lower():
|
||
continue
|
||
stack = list(win.GetChildren())
|
||
deadline = time.time() + 0.3
|
||
while stack and time.time() < deadline:
|
||
ctrl = stack.pop(0)
|
||
text_value = (getattr(ctrl, "Name", "") or "").strip()
|
||
parsed = parse_progress_text(text_value)
|
||
if parsed:
|
||
return parsed
|
||
try:
|
||
stack.extend(ctrl.GetChildren())
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
_LAST_NETEASE_TITLE = ""
|
||
_LAST_NETEASE_AT = 0.0
|
||
_LAST_PROGRESS_SECONDS = -1
|
||
_LAST_PROGRESS_CHANGE_AT = 0.0
|
||
_LAST_VOLUME_PEAK = 0.0
|
||
_LAST_PLAYING = False
|
||
_LAST_COVER_HASH = ""
|
||
_LAST_COVER_SOURCE = ""
|
||
_COVER_CACHE: dict[str, tuple[str, str]] = {}
|
||
_COVER_TASKS: dict[str, asyncio.Task] = {}
|
||
_SMTC_DISABLED_UNTIL = 0.0
|
||
_LAST_AUTO_RESUME_AT = 0.0
|
||
_AUTO_RESUME_TRACK_KEY = ""
|
||
_AUTO_RESUME_LAST_PROGRESS = -1.0
|
||
_AUTO_RESUME_LAST_PROGRESS_AT = 0.0
|
||
_AUTO_RESUME_PAUSED_SINCE = 0.0
|
||
_AUTO_RESUME_FAILURES = 0
|
||
|
||
|
||
async def get_target_smtc_session(cfg: dict[str, Any]):
|
||
mgr = await wmc.GlobalSystemMediaTransportControlsSessionManager.request_async()
|
||
return await choose_session(mgr, cfg)
|
||
|
||
|
||
async def try_resume_playback(cfg: dict[str, Any], force: bool = False) -> bool:
|
||
"""通过 SMTC 尝试恢复播放;暂停/停滞时允许重复发送播放命令。"""
|
||
global _SMTC_DISABLED_UNTIL
|
||
if time.time() < _SMTC_DISABLED_UNTIL:
|
||
return False
|
||
|
||
async def _inner() -> bool:
|
||
session = await get_target_smtc_session(cfg)
|
||
if session is None:
|
||
return False
|
||
if is_playing(session) and not force:
|
||
return False
|
||
result = await session.try_play_async()
|
||
return bool(result)
|
||
|
||
def _run():
|
||
return asyncio.run(_inner())
|
||
|
||
try:
|
||
return await asyncio.wait_for(asyncio.to_thread(_run), timeout=2.5)
|
||
except Exception:
|
||
_SMTC_DISABLED_UNTIL = time.time() + 5
|
||
return False
|
||
|
||
|
||
async def maybe_auto_resume(info: dict[str, Any] | None, cfg: dict[str, Any]) -> str:
|
||
"""定期恢复明确暂停,也检测“状态为播放但进度长时间不动”的假播放。"""
|
||
global _LAST_AUTO_RESUME_AT, _AUTO_RESUME_TRACK_KEY
|
||
global _AUTO_RESUME_LAST_PROGRESS, _AUTO_RESUME_LAST_PROGRESS_AT
|
||
global _AUTO_RESUME_PAUSED_SINCE, _AUTO_RESUME_FAILURES
|
||
if not bool(cfg.get("auto_resume_enabled", False)):
|
||
return ""
|
||
if info is None or not info.get("title"):
|
||
_AUTO_RESUME_TRACK_KEY = ""
|
||
_AUTO_RESUME_LAST_PROGRESS = -1.0
|
||
_AUTO_RESUME_LAST_PROGRESS_AT = 0.0
|
||
_AUTO_RESUME_PAUSED_SINCE = 0.0
|
||
_AUTO_RESUME_FAILURES = 0
|
||
return ""
|
||
|
||
now = time.time()
|
||
title = str(info.get("title") or "").strip()
|
||
artist = str(info.get("artist") or "").strip()
|
||
track_key = f"{title}\n{artist}"
|
||
progress = max(0.0, float(info.get("progress") or 0))
|
||
duration = max(0.0, float(info.get("duration") or 0))
|
||
playing = bool(info.get("playing"))
|
||
|
||
if track_key != _AUTO_RESUME_TRACK_KEY:
|
||
_AUTO_RESUME_TRACK_KEY = track_key
|
||
_AUTO_RESUME_LAST_PROGRESS = progress
|
||
_AUTO_RESUME_LAST_PROGRESS_AT = now
|
||
_AUTO_RESUME_PAUSED_SINCE = 0.0 if playing else now
|
||
_AUTO_RESUME_FAILURES = 0
|
||
return ""
|
||
|
||
if playing:
|
||
_AUTO_RESUME_PAUSED_SINCE = 0.0
|
||
elif not _AUTO_RESUME_PAUSED_SINCE:
|
||
_AUTO_RESUME_PAUSED_SINCE = now
|
||
|
||
if abs(progress - _AUTO_RESUME_LAST_PROGRESS) >= 0.5:
|
||
_AUTO_RESUME_LAST_PROGRESS = progress
|
||
_AUTO_RESUME_LAST_PROGRESS_AT = now
|
||
_AUTO_RESUME_FAILURES = 0
|
||
return ""
|
||
|
||
interval_sec = max(1.0, float(cfg.get("auto_resume_interval_sec", 3) or 3))
|
||
stall_sec = max(interval_sec, float(cfg.get("auto_resume_stall_sec", 10) or 10))
|
||
stalled = (
|
||
playing
|
||
and progress > 0
|
||
and (duration <= 0 or progress < max(0, duration - 2))
|
||
and now - _AUTO_RESUME_LAST_PROGRESS_AT >= stall_sec
|
||
)
|
||
if playing and not stalled:
|
||
return ""
|
||
if now - _LAST_AUTO_RESUME_AT < interval_sec:
|
||
return ""
|
||
|
||
_LAST_AUTO_RESUME_AT = now
|
||
# 明确暂停也强制发送 try_play,避免 SMTC 状态缓存或会话切换导致第一次命令被吞掉。
|
||
resumed = await try_resume_playback(cfg, force=True)
|
||
if resumed:
|
||
_AUTO_RESUME_LAST_PROGRESS_AT = now
|
||
_AUTO_RESUME_FAILURES = 0
|
||
return "stalled" if stalled else "paused"
|
||
|
||
_AUTO_RESUME_FAILURES += 1
|
||
# 连续恢复失败时缩短 SMTC 禁用窗口,下一轮重新获取会话并继续尝试。
|
||
if _AUTO_RESUME_FAILURES >= 2:
|
||
global _SMTC_DISABLED_UNTIL
|
||
_SMTC_DISABLED_UNTIL = min(_SMTC_DISABLED_UNTIL, now + 1)
|
||
return ""
|
||
|
||
|
||
async def read_smtc_snapshot(cfg: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""在隔离线程里读取 SMTC,避免 WinRT 偶发卡死拖住 MusicMonitor 主循环。"""
|
||
global _SMTC_DISABLED_UNTIL
|
||
if time.time() < _SMTC_DISABLED_UNTIL:
|
||
return None
|
||
|
||
async def _inner():
|
||
session = await get_target_smtc_session(cfg)
|
||
if session is None:
|
||
return None
|
||
props = await session.try_get_media_properties_async()
|
||
playback = session.get_playback_info()
|
||
timeline = session.get_timeline_properties()
|
||
thumb_data = None
|
||
if bool(cfg.get("cover_enabled", True)):
|
||
thumb_data = await read_thumbnail(props.thumbnail)
|
||
return {
|
||
"title": props.title or "",
|
||
"artist": props.artist or "",
|
||
"duration": max(0, timeline.end_time.total_seconds()),
|
||
"progress": max(0, timeline.position.total_seconds()),
|
||
"playing": int(playback.playback_status) == 4,
|
||
"source": session_identity(session) or "smtc",
|
||
"thumb_data": thumb_data,
|
||
}
|
||
|
||
def _run():
|
||
return asyncio.run(_inner())
|
||
|
||
try:
|
||
return await asyncio.wait_for(asyncio.to_thread(_run), timeout=2.5)
|
||
except Exception:
|
||
_SMTC_DISABLED_UNTIL = time.time() + 5
|
||
return None
|
||
|
||
|
||
def _get_cloudmusic_audio_peak() -> float:
|
||
"""参考 now-playing-service: 累加 cloudmusic 所有音频会话的峰值音量。
|
||
volume>0 = 真在出声 = Playing。pycaw 不可用时返回 -1 表示未知。"""
|
||
if not _PYCAW_OK:
|
||
return -1.0
|
||
try:
|
||
from pycaw.pycaw import IAudioMeterInformation # type: ignore
|
||
total = 0.0
|
||
sessions = AudioUtilities.GetAllSessions()
|
||
for sess in sessions:
|
||
try:
|
||
proc = getattr(sess, "Process", None)
|
||
if proc is None:
|
||
continue
|
||
# pycaw 的 Process.name 是方法不是属性, 要调用
|
||
name_attr = getattr(proc, "name", None)
|
||
if callable(name_attr):
|
||
pname = name_attr()
|
||
else:
|
||
pname = str(name_attr or "")
|
||
pname = (pname or "").lower()
|
||
if "cloudmusic" in pname:
|
||
meter = sess._ctl.QueryInterface(IAudioMeterInformation)
|
||
total += meter.GetPeakValue()
|
||
except Exception:
|
||
continue
|
||
return total
|
||
except Exception:
|
||
return -1.0
|
||
|
||
|
||
def _decide_playing(volume_peak: float, progress_changed_recently: bool, holdover_sec: float) -> bool:
|
||
"""参考 now-playing-service: volume>0 → Playing; volume=0 但进度最近变化 → Playing; 否则保持/暂停。"""
|
||
global _LAST_PLAYING
|
||
if volume_peak > 0.00001:
|
||
_LAST_PLAYING = True
|
||
return True
|
||
if volume_peak < 0:
|
||
# pycaw 不可用,回退到进度判断
|
||
if progress_changed_recently:
|
||
_LAST_PLAYING = True
|
||
return True
|
||
_LAST_PLAYING = False
|
||
return False
|
||
# volume=0
|
||
if progress_changed_recently:
|
||
# 进度在动但没声音 → 静音播放,仍算 Playing
|
||
_LAST_PLAYING = True
|
||
return True
|
||
_LAST_PLAYING = False
|
||
return False
|
||
|
||
|
||
def _split_title_artist(window_title: str) -> tuple[str, str]:
|
||
title = (window_title or "").strip()
|
||
if " - " in title:
|
||
song, artist = title.split(" - ", 1)
|
||
return song.strip() or title, artist.strip()
|
||
return title, ""
|
||
|
||
|
||
import websockets as _ws_mod
|
||
|
||
def fiber_store_extract_js() -> str:
|
||
return r'''
|
||
function _ensureStore() {
|
||
try {
|
||
if (window._reduxStore) return true;
|
||
const rootEl = document.querySelector('#root');
|
||
const root = window._fiberRoot || (rootEl && rootEl._reactRootContainer && rootEl._reactRootContainer._internalRoot);
|
||
if (!root) return false;
|
||
let queue = [root.current || root];
|
||
let visited = 0;
|
||
while (queue.length > 0) {
|
||
let node = queue.shift();
|
||
if (!node) continue;
|
||
visited++;
|
||
if (visited > 20000) break;
|
||
if (node.memoizedProps && node.memoizedProps.store) { window._reduxStore = node.memoizedProps.store; return true; }
|
||
if (node.stateNode && node.stateNode.store) { window._reduxStore = node.stateNode.store; return true; }
|
||
let child = node.child;
|
||
while (child) { queue.push(child); child = child.sibling; }
|
||
}
|
||
return false;
|
||
} catch(err) { return false; }
|
||
}
|
||
'''
|
||
|
||
async def get_cdp_ws_url(port: int = 9222) -> str:
|
||
try:
|
||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1.2)) as session:
|
||
async with session.get(f"http://127.0.0.1:{port}/json") as resp:
|
||
if resp.status != 200:
|
||
return ""
|
||
targets = await resp.json(content_type=None)
|
||
for target in targets:
|
||
text = (str(target.get("url", "")) + " " + str(target.get("title", ""))).lower()
|
||
if target.get("type") == "page" and target.get("webSocketDebuggerUrl") and ("orpheus" in text or "music.163.com" in text):
|
||
return target.get("webSocketDebuggerUrl", "")
|
||
for target in targets:
|
||
if target.get("type") == "page" and target.get("webSocketDebuggerUrl"):
|
||
return target.get("webSocketDebuggerUrl", "")
|
||
except Exception:
|
||
return ""
|
||
return ""
|
||
|
||
|
||
async def read_netease_cdp_state(cfg: dict[str, Any], previous_hash: str = "") -> dict[str, Any] | None:
|
||
"""通过 CDP 读网易云 Redux 状态。只取 title/artist/progress/duration/picUrl,
|
||
不取 playing(playing 交给音量峰值判断)。"""
|
||
port = int(((cfg.get("request_player") or {}).get("cdp_port", 9222)) or 9222)
|
||
ws_url = await get_cdp_ws_url(port)
|
||
if not ws_url:
|
||
return None
|
||
script = fiber_store_extract_js() + r'''
|
||
(function(){
|
||
if(!_ensureStore()) return null;
|
||
const state = window._reduxStore.getState();
|
||
const playing = state.playing || {};
|
||
const list = (state.playingList && state.playingList.curPlayingList) || [];
|
||
const id = playing.resourceTrackId || playing.onlineResourceId || playing.resourceId || playing.trackId;
|
||
function normId(x){ return x == null ? '' : String(x); }
|
||
let item = null;
|
||
if(id) item = list.find(x => normId(x.id || x.trackId || x.resourceId) === normId(id));
|
||
if(!item && list.length === 1) item = list[0];
|
||
const track = (item && (item.track || item.resource || item)) || {};
|
||
const artists = track.artists || track.ar || item?.artists || item?.ar || [];
|
||
let artist = '';
|
||
if(Array.isArray(artists)) artist = artists.map(a => a && a.name ? a.name : '').filter(Boolean).join('/');
|
||
else if(typeof artists === 'string') artist = artists;
|
||
const album = track.album || track.al || item?.album || item?.al || {};
|
||
const picUrl = album.picUrl || album.blurPicUrl || track.picUrl || item?.picUrl || '';
|
||
const durationMs = Number(track.duration || track.dt || item?.duration || item?.dt || 0);
|
||
const positionMs = Number(playing.position || playing.currentTime || playing.progress || 0);
|
||
return {
|
||
id: normId(id || item?.id || track.id),
|
||
title: track.name || item?.name || '',
|
||
artist: artist || '',
|
||
picUrl: picUrl || '',
|
||
duration: durationMs > 10000 ? durationMs / 1000 : durationMs,
|
||
progress: positionMs > 10000 ? positionMs / 1000 : positionMs,
|
||
};
|
||
})()
|
||
'''
|
||
try:
|
||
async with _ws_mod.connect(ws_url, open_timeout=1.5, close_timeout=0.5) as ws:
|
||
await ws.send(json.dumps({
|
||
"id": 1,
|
||
"method": "Runtime.evaluate",
|
||
"params": {"expression": script, "returnByValue": True, "awaitPromise": True},
|
||
}))
|
||
deadline = time.time() + 2
|
||
while time.time() < deadline:
|
||
raw = await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - time.time()))
|
||
msg = json.loads(raw)
|
||
if msg.get("id") != 1:
|
||
continue
|
||
value = (((msg.get("result") or {}).get("result") or {}).get("value"))
|
||
if isinstance(value, dict) and (value.get("title") or value.get("id")):
|
||
return value
|
||
return None
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
async def download_cover_url(pic_url: str, previous_hash: str = "") -> tuple[str, str]:
|
||
global _LAST_COVER_SOURCE
|
||
if not pic_url:
|
||
return "", previous_hash
|
||
if pic_url.startswith("http://"):
|
||
pic_url = "https://" + pic_url[7:]
|
||
if pic_url == _LAST_COVER_SOURCE and previous_hash and COVER_FILE.exists():
|
||
return "/music_cover.jpg", previous_hash
|
||
try:
|
||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session:
|
||
async with session.get(pic_url) as resp:
|
||
if resp.status != 200:
|
||
return "", previous_hash
|
||
data = await resp.read()
|
||
if data:
|
||
cover_url, cover_hash = write_cover_atomic(data, previous_hash)
|
||
_LAST_COVER_SOURCE = pic_url
|
||
return cover_url, cover_hash
|
||
except Exception:
|
||
return "", previous_hash
|
||
return "", previous_hash
|
||
|
||
|
||
def cover_cache_key(title: str, artist: str) -> str:
|
||
return re.sub(r"\s+", " ", f"{title} - {artist}".strip().lower())
|
||
|
||
|
||
async def fetch_cover_task(key: str, title: str, artist: str):
|
||
"""后台补封面:失败也不能影响 MusicMonitor 主循环。"""
|
||
try:
|
||
keyword = " ".join(x for x in [title, artist] if x).strip()
|
||
if not keyword:
|
||
return
|
||
headers = {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Referer": "https://music.163.com/",
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"Cookie": "os=pc; appver=2.9.8;",
|
||
"X-Real-IP": "118.88.88.88",
|
||
"X-Forwarded-For": "118.88.88.88",
|
||
}
|
||
async with aiohttp.ClientSession(headers=headers, timeout=aiohttp.ClientTimeout(total=4)) as session:
|
||
async with session.post(
|
||
"https://music.163.com/api/search/get/web",
|
||
data={"s": keyword, "type": "1", "limit": "1", "offset": "0"},
|
||
) as resp:
|
||
result = await resp.json(content_type=None)
|
||
songs = ((result.get("result") or {}).get("songs") or [])
|
||
if not songs:
|
||
return
|
||
album = songs[0].get("album") or {}
|
||
pic_url = album.get("picUrl") or album.get("blurPicUrl") or ""
|
||
if not pic_url:
|
||
return
|
||
if pic_url.startswith("http://"):
|
||
pic_url = "https://" + pic_url[7:]
|
||
async with session.get(pic_url) as img_resp:
|
||
if img_resp.status != 200:
|
||
return
|
||
data = await img_resp.read()
|
||
if data:
|
||
_COVER_CACHE[key] = write_cover_atomic(data)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
_COVER_TASKS.pop(key, None)
|
||
|
||
|
||
def apply_cached_or_schedule_cover(info: dict[str, Any], cfg: dict[str, Any]):
|
||
if not bool(cfg.get("cover_enabled", True)):
|
||
return
|
||
key = cover_cache_key(info.get("title", ""), info.get("artist", ""))
|
||
if not key:
|
||
return
|
||
cached = _COVER_CACHE.get(key)
|
||
if cached:
|
||
info["cover"], info["cover_hash"] = cached
|
||
return
|
||
task = _COVER_TASKS.get(key)
|
||
if task is None or task.done():
|
||
try:
|
||
_COVER_TASKS[key] = asyncio.create_task(fetch_cover_task(key, info.get("title", ""), info.get("artist", "")))
|
||
except RuntimeError:
|
||
pass
|
||
|
||
|
||
def apply_netease_window_fallback(info: dict[str, Any], cfg: dict[str, Any]) -> dict[str, Any]:
|
||
"""用窗口标题修正歌曲名/艺人,用音量峰值+进度变化判断播放状态。
|
||
参考 now-playing-service/NeteaseMusicService.cs。"""
|
||
global _LAST_NETEASE_TITLE, _LAST_NETEASE_AT, _LAST_PROGRESS_SECONDS, _LAST_PROGRESS_CHANGE_AT
|
||
holdover = max(0, int(cfg.get("holdover_ms", 1500))) / 1000
|
||
win_title = read_netease_window_title()
|
||
now = time.time()
|
||
if win_title:
|
||
_LAST_NETEASE_TITLE = win_title
|
||
_LAST_NETEASE_AT = now
|
||
elif _LAST_NETEASE_TITLE and now - _LAST_NETEASE_AT <= holdover:
|
||
win_title = _LAST_NETEASE_TITLE
|
||
if win_title:
|
||
title, artist = _split_title_artist(win_title)
|
||
if title:
|
||
info["title"] = title
|
||
if artist:
|
||
info["artist"] = artist
|
||
info["source"] = "cloudmusic.window"
|
||
# 进度
|
||
parsed = read_netease_progress_uia()
|
||
if parsed:
|
||
progress, duration = parsed
|
||
info["progress"] = progress
|
||
info["duration"] = duration
|
||
if progress != _LAST_PROGRESS_SECONDS:
|
||
_LAST_PROGRESS_SECONDS = progress
|
||
_LAST_PROGRESS_CHANGE_AT = now
|
||
progress_changed_recently = (now - _LAST_PROGRESS_CHANGE_AT) <= holdover
|
||
# 播放状态:音量峰值优先,进度兜底
|
||
volume_peak = _get_cloudmusic_audio_peak()
|
||
info["playing"] = _decide_playing(volume_peak, progress_changed_recently, holdover)
|
||
return info
|
||
|
||
|
||
async def get_media_info(cfg: dict[str, Any], previous_cover_hash: str = ""):
|
||
"""网易云已开启 SMTC 后:优先信任 SMTC 的标题、封面、播放状态;CDP/窗口标题只做兜底。"""
|
||
is_netease = any("cloudmusic" in str(t).lower() for t in cfg.get("targets", []))
|
||
|
||
smtc = await read_smtc_snapshot(cfg)
|
||
|
||
info = {
|
||
"title": "",
|
||
"artist": "",
|
||
"duration": 0,
|
||
"progress": 0,
|
||
"playing": False,
|
||
"cover": "",
|
||
"cover_hash": previous_cover_hash,
|
||
"source": "smtc",
|
||
"updated_at": time.time(),
|
||
}
|
||
|
||
cover_url = ""
|
||
cover_hash = previous_cover_hash
|
||
|
||
if smtc is not None:
|
||
info["title"] = smtc.get("title", "")
|
||
info["artist"] = smtc.get("artist", "")
|
||
info["duration"] = float(smtc.get("duration") or 0)
|
||
info["progress"] = float(smtc.get("progress") or 0)
|
||
info["source"] = smtc.get("source") or "smtc"
|
||
info["playing"] = bool(smtc.get("playing"))
|
||
thumb_data = smtc.get("thumb_data")
|
||
if thumb_data:
|
||
cover_url, cover_hash = write_cover_atomic(thumb_data, previous_cover_hash)
|
||
|
||
if is_netease:
|
||
# SMTC 现在是主数据源;如果 SMTC 某些字段缺失,再用 CDP/窗口标题补齐。
|
||
# 现在网易云已开启 SMTC,音乐显示主链路不再碰 CDP/WebSocket,避免卡主循环。
|
||
# 如果 SMTC 暂时没给标题,再用窗口标题兜底;封面则保留上一轮,不再网络下载。
|
||
if not info.get("title"):
|
||
info = apply_netease_window_fallback(info, cfg)
|
||
|
||
if not info.get("title"):
|
||
return None
|
||
elif smtc is None:
|
||
return None
|
||
|
||
# 封面绝不因为某一轮没读到就清空,避免前台闪烁。
|
||
if cover_url:
|
||
info["cover"] = cover_url
|
||
elif previous_cover_hash and COVER_FILE.exists():
|
||
info["cover"] = "/music_cover.jpg"
|
||
else:
|
||
# SMTC/窗口标题没有封面时,按“歌名 + 歌手”异步补封面;成功后下一轮自动显示。
|
||
apply_cached_or_schedule_cover(info, cfg)
|
||
if not info.get("cover"):
|
||
info["cover"] = ""
|
||
info["cover_hash"] = info.get("cover_hash") or cover_hash
|
||
|
||
return info
|
||
|
||
|
||
def default_state() -> dict[str, Any]:
|
||
return {
|
||
"playing": False,
|
||
"current": {
|
||
"title": "暂无歌曲",
|
||
"artist": "未接入音乐源",
|
||
"cover": "",
|
||
"duration": 0,
|
||
"progress": 0,
|
||
"source": "",
|
||
},
|
||
"playlist": [],
|
||
"requests": [],
|
||
"monitor": {"online": False, "source": ""},
|
||
}
|
||
|
||
|
||
def load_state() -> dict[str, Any]:
|
||
if MUSIC_FILE.exists():
|
||
try:
|
||
state = json.loads(MUSIC_FILE.read_text(encoding="utf-8"))
|
||
if isinstance(state, dict):
|
||
return state
|
||
except Exception:
|
||
pass
|
||
return default_state()
|
||
|
||
|
||
async def save_state(state: dict[str, Any]):
|
||
MUSIC_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = MUSIC_FILE.with_suffix(".tmp")
|
||
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
tmp.replace(MUSIC_FILE)
|
||
|
||
|
||
async def post_state(state: dict[str, Any], api_url: str) -> bool:
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(api_url, json=state) as resp:
|
||
return resp.status == 200
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def run_monitor(port: int, interval: float | None = None, cli_targets=None, allow_all: bool | None = None):
|
||
api_url = f"http://localhost:{port}/api/music"
|
||
state = load_state()
|
||
last_info_time = 0.0
|
||
print(f"[MusicMonitor] start api={api_url}", flush=True)
|
||
|
||
while True:
|
||
cfg = load_monitor_config()
|
||
if interval is not None:
|
||
cfg["interval_sec"] = interval
|
||
if cli_targets:
|
||
cfg["targets"] = cli_targets
|
||
if allow_all is not None:
|
||
cfg["allow_all"] = allow_all
|
||
|
||
sleep_sec = max(0.3, float(cfg.get("interval_sec", 1.0)))
|
||
holdover_sec = max(0, int(cfg.get("holdover_ms", 1500))) / 1000
|
||
keep_last = bool(cfg.get("keep_last_when_none", True))
|
||
|
||
try:
|
||
previous_cover_hash = ((state.get("current") or {}).get("cover_hash") or "")
|
||
info = await get_media_info(cfg, previous_cover_hash)
|
||
now = time.time()
|
||
resume_reason = await maybe_auto_resume(info, cfg) if info is not None else ""
|
||
if resume_reason:
|
||
info["playing"] = True
|
||
reason_text = "进度停滞" if resume_reason == "stalled" else "检测到暂停"
|
||
print(f"[MusicMonitor] auto resume playback ({reason_text})", flush=True)
|
||
|
||
if info is None:
|
||
monitor = state.setdefault("monitor", {})
|
||
monitor["online"] = False
|
||
monitor["updated_at"] = now
|
||
monitor["source"] = ""
|
||
monitor["platform"] = cfg.get("platform", "")
|
||
monitor["targets"] = cfg.get("targets", [])
|
||
monitor["allow_all"] = bool(cfg.get("allow_all", False))
|
||
within_holdover = bool(last_info_time) and now - last_info_time <= holdover_sec
|
||
if keep_last and within_holdover:
|
||
# SMTC 偶发丢一帧时短暂保留,避免页面闪烁。
|
||
pass
|
||
else:
|
||
# 超过保留时间后不能继续把旧歌曲伪装成“已暂停”。
|
||
# 仅清理播放器状态,保留点歌队列等独立数据。
|
||
empty = default_state()
|
||
state["playing"] = False
|
||
state["current"] = empty["current"]
|
||
if not keep_last:
|
||
state["playlist"] = []
|
||
else:
|
||
last_info_time = now
|
||
state["playing"] = info["playing"]
|
||
prev_cover = (state.get("current") or {}).get("cover", "")
|
||
prev_cover_hash = (state.get("current") or {}).get("cover_hash", "")
|
||
# 封面: 新值优先, 空值保留旧值(防闪)
|
||
new_cover = info.get("cover", "") or prev_cover
|
||
new_cover_hash = info.get("cover_hash", "") or prev_cover_hash
|
||
# 如果异步封面任务刚完成,本轮 info 可能尚未带 cover;这里再查一次缓存。
|
||
if not new_cover:
|
||
cached = _COVER_CACHE.get(cover_cache_key(info.get("title", ""), info.get("artist", "")))
|
||
if cached:
|
||
new_cover, new_cover_hash = cached
|
||
state["current"] = {
|
||
"title": info["title"],
|
||
"artist": info["artist"],
|
||
"cover": new_cover,
|
||
"cover_hash": new_cover_hash,
|
||
"duration": info["duration"],
|
||
"progress": info["progress"],
|
||
"source": info["source"],
|
||
}
|
||
state["monitor"] = {
|
||
"online": True,
|
||
"source": info["source"],
|
||
"platform": cfg.get("platform", ""),
|
||
"updated_at": info["updated_at"],
|
||
"targets": cfg.get("targets", []),
|
||
"allow_all": bool(cfg.get("allow_all", False)),
|
||
}
|
||
|
||
ok = await post_state(state, api_url)
|
||
if not ok:
|
||
await save_state(state)
|
||
|
||
cur = state.get("current") or {}
|
||
print(f"[MusicMonitor] {cur.get('title', '')} - {cur.get('artist', '')} "
|
||
f"({cur.get('progress', 0):.0f}s/{cur.get('duration', 0):.0f}s) "
|
||
f"playing={state.get('playing')} source={cur.get('source', '')}", flush=True)
|
||
except Exception as e:
|
||
print(f"[MusicMonitor] error: {e}", flush=True)
|
||
traceback.print_exc()
|
||
|
||
await asyncio.sleep(sleep_sec)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser(description="BGI 直播间音乐状态同步")
|
||
parser.add_argument("--port", type=int, default=8086, help="本地 Web 服务端口号")
|
||
parser.add_argument("--interval", type=float, default=None, help="轮询间隔(秒),默认读取 config/config.json")
|
||
parser.add_argument("--allow-all", action="store_true", help="允许同步任意媒体会话(适合网页版播放器)")
|
||
parser.add_argument("--filter", default="", help="额外过滤关键字,匹配 source_app_display_name/aumid")
|
||
args = parser.parse_args()
|
||
|
||
targets = None
|
||
if args.filter:
|
||
cfg = load_monitor_config()
|
||
targets = list(cfg.get("targets", [])) + [args.filter]
|
||
asyncio.run(run_monitor(args.port, args.interval, targets, True if args.allow_all else None))
|