Files
Live-streaming/app/music_monitor.py
T

966 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
音乐播放器 -> 直播间 UI 音乐状态同步
通过 Windows 10/11 的 SMTCSystem 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
不取 playingplaying 交给音量峰值判断)。"""
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))