""" 音乐播放器 -> 直播间 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))