dsh-music-player 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ DeepSeek Harness 音乐/小说播放插件。
33
33
  |:---:|:---:|
34
34
  | ![播放本地音乐](assets/screenshot-bar.png) | ![播放QQ音乐](assets/screenshot-qq.png) |
35
35
  | 播放酷狗音乐 | AI讲书 |
36
- | ![播放酷狗音乐](assets/screenshot-kg.png) | ![播放AI讲书](assets/screenshot-spectrum.png) |
36
+ | ![播放酷狗音乐](assets/screenshot-kg.png) | ![播放AI讲书](assets/screenshot-novel.png) |
37
37
  | QQ音乐面板 | 酷狗音乐面板 |
38
38
  | ![播放面板1](assets/screenshot-panel-qq.png) | ![播放面板2](assets/screenshot-panel-kg.png) |
39
39
 
package/lib/client.js CHANGED
@@ -565,7 +565,7 @@ window.__ModuleLoader__.load({
565
565
  // QQ·酷狗登录态。供播放面板「关于」页展示运行状态。
566
566
  version: '', description: '', ttsConfigured: false, ttsReason: '', ttsProvider: '',
567
567
  qqLoggedIn: false, qqUin: '', qqNickname: '', qqLoginFrom: '',
568
- kgLoggedIn: false,
568
+ kgLoggedIn: false, kgNickname: '',
569
569
  ncLoggedIn: false, ncNickname: '',
570
570
  // 版本更新弹窗(What's New):内容与判定结论都来自 manifest——
571
571
  // whatsNew 当前版条目(null=本版没写条目)/ whatsNewHistory 历史条目(新→旧)/
@@ -2906,7 +2906,7 @@ window.__ModuleLoader__.load({
2906
2906
  // near-instant (server-side synthesis cache) so we don't flash a spinner
2907
2907
  // at every chunk boundary. Only user-initiated plays show it.
2908
2908
  const showBuffer = !silent;
2909
- set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, currentSection: '', bookToc: [], currentQuality: '', bookProgress: 0, scope: { kind: 'book' } });
2909
+ set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, currentSection: '', bookToc: [], currentQuality: '', bookProgress: 0, scope: { kind: 'book' }, bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0 });
2910
2910
  // Load the chapter structure for the toc + the current-section label (the
2911
2911
  // current section is derived from the chunk index once the meta arrives).
2912
2912
  const startFrom = from;
@@ -3089,7 +3089,18 @@ window.__ModuleLoader__.load({
3089
3089
  // 冷启动续播跳块时下一块尚未合成(需数秒~数十秒),必须显示「AI 合成中」
3090
3090
  // 并带上 60s 超时兜底,否则用户看到的是无声无息的「卡住」(既没声音、
3091
3091
  // 字幕也不动,又没有任何反馈)。
3092
- const warmed = bookBufferedFrom === bookFromRef + 1;
3092
+ // warmed 的语义修正:不能只看「预取请求已发出」(preloadBook 同步置位
3093
+ // bookBufferedFrom),还要确认 preAudio 真的拿到了可播数据——否则预取请求
3094
+ // 卡在慢合成(如 TTS 上游 67s 才返回)时会被误判为「已预热」而静默切块,
3095
+ // 用户看到的就是无提示的卡住。真实 <audio> 用 readyState/buffered 判断;
3096
+ // jsdom/FakeAudio 无媒体能力(二者 undefined)→ 按就绪处理,保持既有语义。
3097
+ const preloadActuallyReady = () => {
3098
+ const rs = preAudio.readyState;
3099
+ const buf = preAudio.buffered;
3100
+ if (rs === undefined && buf === undefined) return true; // 测试环境回退
3101
+ return (Number(rs) >= 1) || (buf !== null && buf !== undefined && buf.length > 0);
3102
+ };
3103
+ const warmed = bookBufferedFrom === bookFromRef + 1 && preloadActuallyReady();
3093
3104
  bookJustAdvanced = true; // 后续紧到的重复 ended 属旧块陈旧事件(onEnded 据此吞掉)
3094
3105
  playBookFrom(id, bookFromRef + 1, warmed);
3095
3106
  // Persist the new chunk/base immediately so a refresh after the switch
@@ -4008,7 +4019,7 @@ window.__ModuleLoader__.load({
4008
4019
  ttsConfigured: !!result.ttsConfigured, ttsReason: result.ttsReason || '',
4009
4020
  ttsProvider: result.ttsProvider || '',
4010
4021
  qqLoggedIn: !!result.qqLoggedIn, qqUin: result.qqUin || '', qqNickname: result.qqNickname || '', qqLoginFrom: result.qqLoginFrom || '',
4011
- kgLoggedIn: !!result.kgLoggedIn,
4022
+ kgLoggedIn: !!result.kgLoggedIn, kgNickname: result.kgNickname || '',
4012
4023
  ncLoggedIn: !!result.ncLoggedIn, ncNickname: result.ncNickname || '',
4013
4024
  // 版本更新弹窗四件套(Host 判定结论 + 内容),见 scheduleWhatsNewAuto。
4014
4025
  whatsNew: result.whatsNew || null,
@@ -4070,7 +4081,7 @@ window.__ModuleLoader__.load({
4070
4081
  ttsConfigured: !!result.ttsConfigured, ttsReason: result.ttsReason || '',
4071
4082
  ttsProvider: result.ttsProvider || '',
4072
4083
  qqLoggedIn: !!result.qqLoggedIn, qqUin: result.qqUin || '', qqNickname: result.qqNickname || '', qqLoginFrom: result.qqLoginFrom || '',
4073
- kgLoggedIn: !!result.kgLoggedIn,
4084
+ kgLoggedIn: !!result.kgLoggedIn, kgNickname: result.kgNickname || '',
4074
4085
  // 只同步弹窗内容,不重新触发自动弹窗(whatsNewAutoShown 一次性)。
4075
4086
  whatsNew: result.whatsNew || null,
4076
4087
  whatsNewHistory: Array.isArray(result.whatsNewHistory) ? result.whatsNewHistory : [],
@@ -4165,6 +4176,13 @@ window.__ModuleLoader__.load({
4165
4176
  return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
4166
4177
  React.createElement('path', { d: 'M4 5h13v14H5a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1zm15 2h1a1 1 0 0 1 1 1v9.5a2.5 2.5 0 0 1-2.5 2.5H19V7zM6.5 8h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5zm.5 5h3v1.5H7V13zm4.5 0h3.5v1.5H11.5V13zM7 15.5h3V17H7v-1.5zm4.5 0h3.5V17H11.5v-1.5z' }));
4167
4178
  }
4179
+ // 收音机图标(网络电台直播的名称前缀,与音符/话筒/报纸同尺寸同色系):
4180
+ // 一台带天线的收音机,一眼区别于「本地/在线点播」的音符——电台是直播流不是曲库。
4181
+ function RadioIcon(props) {
4182
+ const cls = props.className || '';
4183
+ return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
4184
+ React.createElement('path', { d: 'M3.24 6.15C2.51 6.43 2 7.17 2 8v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.89-2-2-2H8.3l8.26-3.34L15.88 1 3.24 6.15zM7 20c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm13-8h-2v-2h-2v2H4V8h16v4z' }));
4185
+ }
4168
4186
  // 爱心图标(收藏钮通用):收藏=实心(currentColor)、未收藏=空心描边。播放条与电台行
4169
4187
  // 收藏钮共用,替代 ♥/♡ 文本字形——字符宽度/基线随字体变化,SVG 恒定尺寸、颜色精确可控。
4170
4188
  function HeartIcon({ className = '', filled = false, size = 16 }) {
@@ -4265,11 +4283,20 @@ window.__ModuleLoader__.load({
4265
4283
  const [now, setNow] = useState(Date.now());
4266
4284
  useEffect(() => {
4267
4285
  if (!s.bookBuffering) return;
4286
+ // 立即用当前时间同步基准,再启动每秒 tick:interval 每次因 bookBuffering
4287
+ // 翻转而重建,若首帧仍用冻结的旧 now(挂载或上次 interval 结束时残留),
4288
+ // 会与新的 bookBufferingSince 算出错误的大秒数——表现是「合成中…300s」
4289
+ // 闪现一帧后跳回「合成中…1s」。先同步一次即可让首帧即为真实差(≈0s 起)。
4290
+ setNow(Date.now());
4268
4291
  const t = setInterval(() => setNow(Date.now()), 1000);
4269
4292
  return () => clearInterval(t);
4270
4293
  }, [s.bookBuffering]);
4271
4294
  if (s.bookBuffering && !s.bookBufferingSilent) {
4272
- const secs = s.bookBufferingSince > 0 ? Math.floor((now - s.bookBufferingSince) / 1000) : 0;
4295
+ // 秒数下限保护:now 异常早于 since(时钟回拨/极端冻结)时钳到 0,
4296
+ // 绝不闪现负秒或荒谬的大数。
4297
+ const secs = s.bookBufferingSince > 0
4298
+ ? Math.max(0, Math.floor((now - s.bookBufferingSince) / 1000))
4299
+ : 0;
4273
4300
  return React.createElement('span', { className: 'dsh-music-bar-buffering' },
4274
4301
  React.createElement('span', { className: 'dsh-music-spinner' }),
4275
4302
  ' AI 合成中… ' + secs + 's');
@@ -4413,8 +4440,9 @@ window.__ModuleLoader__.load({
4413
4440
  // 本地音乐/AI讲书:播放条显示的文件名去掉扩展名(如 .mp3、.txt);在线 QQ 的
4414
4441
  // 歌名不是文件名,原样保留。
4415
4442
  const displayName = (isQQTrack || isRadio) ? name : stripExt(name);
4416
- // 名称前的图标:新闻用报纸图标,讲书用话筒图标,音乐用音符图标(空闲态无曲目 = 音乐)。
4417
- const note = React.createElement(isNews ? NewsIcon : (isBook ? MicIcon : MusicNote), { className: 'dsh-music-note' });
4443
+ // 名称前的图标:新闻用报纸图标,讲书用话筒图标,网络电台用收音机图标,
4444
+ // 音乐用音符图标(空闲态无曲目 = 音乐)。
4445
+ const note = React.createElement(isNews ? NewsIcon : (isBook ? MicIcon : (isRadio ? RadioIcon : MusicNote)), { className: 'dsh-music-note' });
4418
4446
  // While a novel chunk is being synthesized, show a live "合成中… Ns"
4419
4447
  // counter (so the wait is transparent, not an endless spinner) and, on
4420
4448
  // error, the real message plus a retry button. Music keeps its old UI.
@@ -4527,7 +4555,7 @@ window.__ModuleLoader__.load({
4527
4555
  // togglePanelOnName 内部排除音质徽章(.dsh-music-bar-src)不触发。
4528
4556
  React.createElement('span', { className: 'dsh-music-bar-name-text' }, note, ' ', displayName),
4529
4557
  artistEl, sourceBadge, localQualityBadge, chapterEl, afterName)
4530
- : React.createElement('span', { className: 'dsh-music-bar-idle', title: '双击打开播放列表', onDoubleClick: togglePanel }, note, ' DSH音乐播放器'),
4558
+ : React.createElement('span', { className: 'dsh-music-bar-idle', title: '单击打开播放列表', onClick: togglePanel }, note, ' DSH音乐播放器'),
4531
4559
  !isBook && hasTrack && s.playing && s.showViz ? React.createElement('canvas', { className: 'dsh-music-viz', width: 60, height: 20, ref: (el) => { barCanvasNode = el; } }) : null,
4532
4560
  // 歌词/字幕:位于频谱之后、时长之前;仅"非使用态"(控件组已折叠、播放条
4533
4561
  // 半透明)显示——正在操作时收起,不给滑入的按钮组让路。换行动效由系统配置
@@ -5601,8 +5629,8 @@ window.__ModuleLoader__.load({
5601
5629
  if (playingKg) stop();
5602
5630
  clearPref(PREF_PLAYBACK_KG);
5603
5631
  kgFavPromise = null;
5604
- set({ kgQueue: [], kgSource: '', kgQueueFrom: null, kgQueueRev: 0, kgFaved: false, kgFavHashes: [], kgFavFiles: [], kgFavListId: 0, kgAuthDeadMsg: '' });
5605
- setLoggedIn(false); setUserid(''); setMinePlays([]); setMineLoaded(false);
5632
+ set({ kgQueue: [], kgSource: '', kgQueueFrom: null, kgQueueRev: 0, kgFaved: false, kgFavHashes: [], kgFavFiles: [], kgFavListId: 0, kgAuthDeadMsg: '', kgLoggedIn: false, kgNickname: '' });
5633
+ setLoggedIn(false); setUserid(''); setNickname(''); setMinePlays([]); setMineLoaded(false);
5606
5634
  setKgPlCounts(null);
5607
5635
  setBrowseTab('recommend'); setBrowseErr(''); setMineErr(''); setQError('');
5608
5636
  setLayer('main'); setActivePl(null); setPlLoading(false);
@@ -5616,6 +5644,7 @@ window.__ModuleLoader__.load({
5616
5644
  // ---- 登录态 ----
5617
5645
  const [loggedIn, setLoggedIn] = useState(null); // null=检测中 false=未登录 true=已登录
5618
5646
  const [userid, setUserid] = useState('');
5647
+ const [nickname, setNickname] = useState(''); // 酷狗账号昵称(对齐网易云显示「退出登录(昵称)」)
5619
5648
  const [qrImage, setQrImage] = useState('');
5620
5649
  const [qrKey, setQrKey] = useState('');
5621
5650
  const [loginMsg, setLoginMsg] = useState('');
@@ -5624,7 +5653,7 @@ window.__ModuleLoader__.load({
5624
5653
  const pollTimer = useRef(null);
5625
5654
  useEffect(() => {
5626
5655
  jsonGet('/dsh-music/kg/status').then((d) => {
5627
- if (d) { setLoggedIn(!!d.loggedIn); setUserid(d.userid || ''); }
5656
+ if (d) { setLoggedIn(!!d.loggedIn); setUserid(d.userid || ''); setNickname(d.nickname || ''); }
5628
5657
  }).catch(() => {});
5629
5658
  return () => { if (pollTimer.current !== null) clearTimeout(pollTimer.current); };
5630
5659
  }, []);
@@ -5645,7 +5674,9 @@ window.__ModuleLoader__.load({
5645
5674
  .then((c) => {
5646
5675
  if (!c || !c.ok) { setLoginMsg((c && c.error) || '状态未知'); return; }
5647
5676
  if (c.status === 'success') {
5648
- setLoggedIn(true); setUserid(c.userid || ''); setQrImage(''); setQrKey('');
5677
+ setLoggedIn(true); setUserid(c.userid || ''); setNickname(c.nickname || ''); setQrImage(''); setQrKey('');
5678
+ // 同步全局 store:让「关于」页运行状态即时显示酷狗昵称(对齐 QQ 登录同步 qqLoggedIn 的先例)。
5679
+ set({ kgLoggedIn: true, kgNickname: c.nickname || '' });
5649
5680
  // 登录成功直接关闭二维码弹窗,不显示任何提示。
5650
5681
  setLoginOpen(false); setLoginMsg('');
5651
5682
  } else if (c.status === 'expired') { setLoginMsg('二维码已过期,请点击刷新'); }
@@ -5662,12 +5693,12 @@ window.__ModuleLoader__.load({
5662
5693
  const playingKg = String(store.currentId || '').startsWith('kg:') || store.scope?.kind === 'kg';
5663
5694
  if (playingKg) stop();
5664
5695
  clearPref(PREF_PLAYBACK_KG);
5665
- setLoggedIn(false); setUserid(''); setMinePlays([]); setMineLoaded(false);
5696
+ setLoggedIn(false); setUserid(''); setNickname(''); setMinePlays([]); setMineLoaded(false);
5666
5697
  setKgPlCounts(null);
5667
5698
  set({ kgQueue: [], kgSource: '', kgQueueFrom: null, kgQueueRev: 0 });
5668
5699
  // 清空「我喜欢」集合与爱心状态,下次登录重新拉取。
5669
5700
  kgFavPromise = null;
5670
- set({ kgFaved: false, kgFavHashes: [], kgFavFiles: [], kgFavListId: 0 });
5701
+ set({ kgFaved: false, kgFavHashes: [], kgFavFiles: [], kgFavListId: 0, kgLoggedIn: false, kgNickname: '' });
5671
5702
  }).catch(() => {});
5672
5703
  }
5673
5704
 
@@ -6529,7 +6560,7 @@ window.__ModuleLoader__.load({
6529
6560
  const head = React.createElement('div', { className: 'dsh-music-qq-head' },
6530
6561
  React.createElement('div', { className: 'dsh-music-qq-toolbar' },
6531
6562
  React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: openQueue }, '播放列表'),
6532
- React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: doLogout }, '退出登录')),
6563
+ React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: doLogout }, '退出登录' + (nickname ? '(' + nickname + ')' : ''))),
6533
6564
  React.createElement('div', { className: 'dsh-music-qq-viewtabs' },
6534
6565
  browseTabBtn('mine', '我的歌单'),
6535
6566
  browseTabBtn('recommend', '推荐歌单'),
@@ -8891,6 +8922,28 @@ window.__ModuleLoader__.load({
8891
8922
  document.addEventListener('mousedown', onDown);
8892
8923
  return () => document.removeEventListener('mousedown', onDown);
8893
8924
  }, [s.panelOpen]);
8925
+ // Esc 关闭播放面板:仅当有「更内层、自带 Esc 语义」的弹层在场时不生效——
8926
+ // 名称输入/确认/登录/定时任务编辑等全屏 overlay(.dsh-music-picker-overlay)
8927
+ // 以及版本更新弹窗(.dsh-music-whatsnew,自带 document 级 Esc)打开时,
8928
+ // Esc 属于它们(关弹层或先由其处理),不在这里把整个面板一起关掉。
8929
+ // 监听挂在 capture 阶段:必须在任何内层处理器运行前先看到弹层还在场——
8930
+ // 若挂在冒泡阶段,焦点在弹层输入框内按 Esc 时,内层先关弹层(React 在
8931
+ // 容器上同步刷新 DOM),事件冒泡到 document 时弹层已消失,会误把面板
8932
+ // 一起关掉(一次 Esc 连关两层)。capture 先到场检查则无此竞态。
8933
+ // 歌词/字幕面板与播放条弹层(模式/音量/目录)暂不处理 Esc(本次改动最小)。
8934
+ useEffect(() => {
8935
+ if (!s.panelOpen) return;
8936
+ const hasInnerEscModal = () => document.body.querySelector('.dsh-music-picker-overlay') !== null
8937
+ || document.body.querySelector('.dsh-music-whatsnew') !== null;
8938
+ const onKey = (e) => {
8939
+ if (e.key !== 'Escape') return;
8940
+ if (hasInnerEscModal()) return;
8941
+ e.preventDefault();
8942
+ set({ panelOpen: false });
8943
+ };
8944
+ document.addEventListener('keydown', onKey, true);
8945
+ return () => document.removeEventListener('keydown', onKey, true);
8946
+ }, [s.panelOpen]);
8894
8947
  useEffect(() => {
8895
8948
  if (!s.panelOpen) return;
8896
8949
  const list = listRef.current;
@@ -9607,7 +9660,7 @@ window.__ModuleLoader__.load({
9607
9660
  // 未知方式时只显示「已登录」。
9608
9661
  const qqLoginLabel = s.qqLoginFrom === 'wx' ? '微信' : (s.qqLoginFrom === 'qq' ? 'QQ' : '');
9609
9662
  const qqTxt = s.qqLoggedIn ? ('已登录' + (qqLoginLabel ? '(' + qqLoginLabel + ')' : '')) : '未登录(扫码后可用)';
9610
- const kgTxt = s.kgLoggedIn ? '已登录' : '未登录(扫码后可用)';
9663
+ const kgTxt = s.kgLoggedIn ? ('已登录' + (s.kgNickname ? '(' + s.kgNickname + ')' : '')) : '未登录(扫码后可用)';
9611
9664
  const ncTxt = s.ncLoggedIn ? ('已登录' + (s.ncNickname ? '(' + s.ncNickname + ')' : '')) : '未登录(匿名可用)';
9612
9665
  return React.createElement('div', { className: 'dsh-music-about' },
9613
9666
  React.createElement('div', { className: 'dsh-music-about-card' },
@@ -10001,7 +10054,7 @@ window.__ModuleLoader__.load({
10001
10054
  '.dsh-music-bar-progress-fill { height: 100%; background: color-mix(in srgb, var(--dsh-music-accent, #2f9e6e) 55%, var(--dsw-alias-bg-base)); transition: width 0.12s linear; }\n' +
10002
10055
  '.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
10003
10056
  '.dsh-music-bar-name { max-width: 36%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-flex; align-items: center; min-width: 0; }\n' +
10004
- // 名称文本(音符图标 + 歌名)内层:承载双击打开面板;参与外层 flex、可省略截断。
10057
+ // 名称文本(音符图标 + 歌名)内层:承载单击打开面板;参与外层 flex、可省略截断。
10005
10058
  '.dsh-music-bar-name-text { display: inline-flex; align-items: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n' +
10006
10059
  '.dsh-music-note { color: var(--dsh-music-accent, #2f9e6e); flex: none; margin-right: 4px; }\n' +
10007
10060
  // canvas 默认是 inline 级元素,会有基线留白导致在 flex 行里整体偏上;
package/lib/index.js CHANGED
@@ -2670,15 +2670,16 @@ export function apply(ctx) {
2670
2670
  // 设备身份(guid/mid/dfid)首登时生成并长期复用——酷狗侧按设备指纹做风控,
2671
2671
  // 每次换新指纹反而更危险。mid 是 30+ 位十进制大数,全程字符串处理。
2672
2672
  const kg = {
2673
- session: { guid: '', mid: '', dfid: '-', token: '', userid: '', vip_type: '', vip_token: '' },
2673
+ session: { guid: '', mid: '', dfid: '-', token: '', userid: '', vip_type: '', vip_token: '', nickname: '' },
2674
2674
  loggedIn: false,
2675
2675
  isVip: null,
2676
2676
  }
2677
2677
  let kgLoginSession = null
2678
2678
  let kgCookieLoaded = false
2679
2679
  let kgCookieLoadInFlight = null // 首次读取进行中的共享 Promise(见 loadKGCookie 的在途去重)
2680
- // 最近一次写 cookie 的时间戳(ms),≈ 最近一次登录/刷新 token 的时间。
2681
- // 主动续命(maybeRefreshKgSession)据此判断 token 是否已陈旧、需要提前换新。
2680
+ // 最近一次写 cookie 文件的时间戳(ms),随 cookie 持久化(savedAt 字段)。
2681
+ // 曾用于主动续命的 TTL 判定;续命已停用(2026-09-06),现仅作为写入元数据保留
2682
+ //(读盘恢复 / 落盘记录),不参与任何登录态逻辑。
2682
2683
  let kgSavedAt = 0
2683
2684
 
2684
2685
  const kgCookieFile = async () => {
@@ -2742,37 +2743,27 @@ export function apply(ctx) {
2742
2743
  // Promise 共享给并发请求,同一首歌只真正取链一次。
2743
2744
  const kgTakeInFlight = new Map() // hash -> Promise<entry>
2744
2745
 
2745
- // 登录态动作的统一包装:碰到「登录态与设备不匹配」(20017/20018) 先静默
2746
- // 刷新一次标准作用域 token(v5/login_by_token)再原样重试。
2747
- // 若连刷新都返回「设备不匹配」(token 已被酷狗判定失效/与设备解绑,20018),
2748
- // 登录态不可挽回——自动清空本地会话并带上 kgLoginDead 标记,让客户端回到扫码
2749
- // 登录页,而不是一直挂在「刷新登录态失败」的报错里(酷狗无续命接口,只能重扫)。
2750
- const KG_AUTH_DEAD_RE = /20017|20018|20028|设备不匹配/
2746
+ // 登录态动作的统一包装:直接原样请求,不做任何前置续命/刷新。
2747
+ // 决策(2026-09-06):主动续命已停用(见下方 maybeRefreshKgSession 注释)——
2748
+ // 社区实证 login_by_token 是「探活/续期」而非换发,token 常态可活数周;
2749
+ // 与其定时打扰官方(反感频繁调用),不如 token 真过期时由业务请求撞
2750
+ // 20017/20018 → 让用户重新扫码一次(酷狗无其他续命手段,重扫是唯一正道)。
2751
+ // ⚠️ 20028「本次请求需要验证」是临时风控(稍后重试即可),不是 token 失效——
2752
+ // 曾因误归入此正则导致播放中一次风控抖动就把用户登出(见 lib/kugou.js explainKgError)。
2753
+ const KG_AUTH_DEAD_RE = /20017|20018|设备不匹配/
2751
2754
  const kgWithFreshToken = async (fn) => {
2752
- // 主动续命:token 陈旧(>12h 未刷新)先静默换新,避免过期后只能重扫。
2753
- await maybeRefreshKgSession()
2754
- try { return await fn() } catch (e) {
2755
- const m = String((e && e.message) || e)
2756
- if (!KG_AUTH_DEAD_RE.test(m) || !kg.loggedIn) throw e
2757
- let refreshed
2758
- try {
2759
- refreshed = await KG.refreshSession(kg.session)
2760
- } catch (re) {
2761
- const rm = String((re && re.message) || re)
2762
- if (!KG_AUTH_DEAD_RE.test(rm)) throw re
2763
- // 刷新也失败 → 会话确实死了:清掉 cookie 并打标记,客户端据此重扫。
2755
+ try {
2756
+ return await fn()
2757
+ } catch (e) {
2758
+ // 业务报「设备不匹配」→ 会话确实死了:清掉 cookie 并打标记,客户端据此
2759
+ // 跳回扫码登录页重新扫码(不重试、不补救、不续命)。
2760
+ if (KG_AUTH_DEAD_RE.test(String((e && e.message) || e)) && kg.loggedIn) {
2764
2761
  await clearKGCookie()
2765
2762
  const dead = new Error('酷狗登录已失效(登录态与设备不匹配),请重新扫码登录')
2766
2763
  dead.kgLoginDead = true
2767
2764
  throw dead
2768
2765
  }
2769
- if (!refreshed.token) throw e
2770
- kg.session.token = refreshed.token
2771
- kg.session.vip_type = refreshed.vip_type || kg.session.vip_type
2772
- kg.session.vip_token = refreshed.vip_token || kg.session.vip_token
2773
- if (refreshed.t1) kg.session.t1 = refreshed.t1
2774
- await saveKGCookie()
2775
- return await fn()
2766
+ throw e
2776
2767
  }
2777
2768
  }
2778
2769
  // 酷狗路由错误序列化:带出 kgLoginDead 标记,供前端识别「登录已失效需重扫」。
@@ -2804,7 +2795,7 @@ export function apply(ctx) {
2804
2795
  guid: kg.session.guid || '',
2805
2796
  mid: kg.session.mid || '',
2806
2797
  dfid: kg.session.dfid && kg.session.dfid !== '-' ? kg.session.dfid : '-',
2807
- token: '', userid: '', vip_type: '', vip_token: '',
2798
+ token: '', userid: '', vip_type: '', vip_token: '', nickname: '',
2808
2799
  }
2809
2800
  kg.loggedIn = false; kg.isVip = null
2810
2801
  kgSavedAt = 0
@@ -2812,59 +2803,62 @@ export function apply(ctx) {
2812
2803
  const file = await kgCookieFile()
2813
2804
  if (file !== null) { try { writeFileSync(file, JSON.stringify({ session: kg.session, loggedIn: false, savedAt: Date.now() }), { encoding: 'utf8', mode: 0o600 }) } catch { /* ignore */ } }
2814
2805
  }
2815
- // 主动续命:登录态还在但距上次刷新超过 KG_REFRESH_TTL 时,先静默调一次
2816
- // v5/login_by_token 换新 token(社区标准 24h 一刷,留余量取 12h),让 token
2817
- // 在过期前被续上,避免「好端端突然要重扫」。刷新失败且报设备不匹配 → token
2818
- // 已死,自动登出并带 kgLoginDead 标记;其他失败(网络等)忽略,原请求照常。
2806
+ // ══════════════════════════════════════════════════════════════════
2807
+ // 【停用】酷狗主动续命(maybeRefreshKgSession)——2026-09-06 起不再调用。
2808
+ //
2809
+ // 决策背景:社区实证(MakcRe issue #117 作者亲述 + lx-music + kgqd)表明——
2810
+ // 1. login_by_token 是「用原 token+userid 重新登录探活」而非换发新 token,
2811
+ // token 无硬性 24h 寿命,常态可活数周(官方有时半月~一月才作废);
2812
+ // 2. 官方反感频繁调用续命接口;且登录后立刻 refresh / 定时 refresh 的额外
2813
+ // 调用,实测曾导致 token 被提前作废(登录后 ~24h 就失效的异常现象);
2814
+ // 3. 与其定时打扰官方,不如 token 真过期时由业务请求撞 20017/20018 → 让用户
2815
+ // 重新扫码一次(酷狗无其他续命手段,重扫是唯一正道)。
2816
+ // 停用后 token 生命周期 = 完全由酷狗侧决定;过期时 kgWithFreshToken 的 catch
2817
+ // 分支清 cookie + kgLoginDead,前端跳回扫码页提示重新登录。
2818
+ //
2819
+ // 保留下方实现注释供未来需要时恢复(若酷狗 token 寿命显著缩短再评估启用)。
2820
+ // ══════════════════════════════════════════════════════════════════
2821
+ // 主动续命:登录态还在但距上次刷新超过 KG_REFRESH_TTL(24h)时,先静默调一次
2822
+ // v5/login_by_token(社区惯例:每天一次/每次重启一次,官方反感频繁调用)。
2823
+ // ⚠️ 续命失败【不登出】——对齐 lx-music(refresh 失败仅记日志、保留旧 token,
2824
+ // 下轮再试)。login_by_token 是「用原 token 探活/续期」而非换发(MakcRe issue
2825
+ // #117:token 无硬性 24h 寿命,常态可活数周);续命失败多数是临时网络/风控,
2826
+ // 直接 clearKGCookie 会把用户硬登出。token 是否真死由后续业务请求的
2827
+ // 20017/20018 判定(kgWithFreshToken 的 catch 分支负责,那里才登出)。
2819
2828
  // ⚠️ 在途去重:TTL 到期瞬间的并发请求(取链 HEAD/GET 几乎同时到达)会各自
2820
2829
  // 用同一个旧 token 发两次 login_by_token——若酷狗换发新 token 时作废旧
2821
2830
  // token,第二发必收 20018,会把还活着的会话误判成「登录已死」清掉。这里把
2822
2831
  // 在途刷新的 Promise 共享给并发调用:同一时刻最多只有一次真实刷新请求。
2823
- // 【临时观察 2026-08-30】12h 主动续命屏蔽中:观察 token 不主动刷新时能否自然
2824
- // 存活 24h+(社区称 login_by_token 24h 一刷即可续命,但酷狗真实过期线未知)。
2825
- // 被动路径全部保留:业务接口撞 20017/20018 仍会先静默补救刷新(kgWithFreshToken
2826
- // 的 catch 分支),扫码登录成功后的立即换标准作用域刷新(login/check)也照旧。
2827
- // 因此观察期的两种可能结果都可见:token 到期时若续命接口仍受理 → 播放不中断、
2828
- // cookie 文件 token 变化(savedAt 更新);若已死 → 面板弹「请重新扫码登录」。
2829
- // 恢复方式:本行改为 () => true,或运行环境设 DSH_KG_PROACTIVE_REFRESH=on。
2830
- // 注意:kg-collect 测试套件显式设 on,主动续命与在途去重逻辑仍有测试看护。
2831
- const kgProactiveRefreshOn = () => process.env.DSH_KG_PROACTIVE_REFRESH === 'on'
2832
- const KG_REFRESH_TTL = 12 * 60 * 60 * 1000
2833
- let kgRefreshInFlight = null
2834
- const maybeRefreshKgSession = async () => {
2835
- await loadKGCookie()
2836
- if (!kgProactiveRefreshOn()) return // 【临时观察】12h 主动续命屏蔽中,见上方注释
2837
- if (!kg.loggedIn) return
2838
- if (kgSavedAt > 0 && Date.now() - kgSavedAt < KG_REFRESH_TTL) return
2839
- if (kgRefreshInFlight) return kgRefreshInFlight
2840
- const tokenAtStart = kg.session.token
2841
- kgRefreshInFlight = (async () => {
2842
- try {
2843
- const refreshed = await KG.refreshSession(kg.session)
2844
- // 刷新期间发生了重扫/登出(token 已换)→ 本次结果作废:不回写旧 token 的
2845
- // 刷新产物,避免覆盖刚登录的新会话。
2846
- if (kg.session.token !== tokenAtStart) return
2847
- if (!refreshed.token) return
2848
- kg.session.token = refreshed.token
2849
- kg.session.vip_type = refreshed.vip_type || kg.session.vip_type
2850
- kg.session.vip_token = refreshed.vip_token || kg.session.vip_token
2851
- if (refreshed.t1) kg.session.t1 = refreshed.t1
2852
- await saveKGCookie()
2853
- } catch (e) {
2854
- // 同上:刷新期间已重扫 → 旧 token 的死活不再关我们的事,别误清新会话。
2855
- if (kg.session.token !== tokenAtStart) return
2856
- const m = String((e && e.message) || e)
2857
- if (!KG_AUTH_DEAD_RE.test(m)) return
2858
- await clearKGCookie()
2859
- const dead = new Error('酷狗登录已失效(登录态与设备不匹配),请重新扫码登录')
2860
- dead.kgLoginDead = true
2861
- throw dead
2862
- } finally {
2863
- kgRefreshInFlight = null
2864
- }
2865
- })()
2866
- return kgRefreshInFlight
2867
- }
2832
+ // const KG_REFRESH_TTL = 24 * 60 * 60 * 1000
2833
+ // let kgRefreshInFlight = null
2834
+ // const maybeRefreshKgSession = async () => {
2835
+ // await loadKGCookie()
2836
+ // if (!kg.loggedIn) return
2837
+ // if (kgSavedAt > 0 && Date.now() - kgSavedAt < KG_REFRESH_TTL) return
2838
+ // if (kgRefreshInFlight) return kgRefreshInFlight
2839
+ // const tokenAtStart = kg.session.token
2840
+ // kgRefreshInFlight = (async () => {
2841
+ // try {
2842
+ // const refreshed = await KG.refreshSession(kg.session)
2843
+ // // 刷新期间发生了重扫/登出(token 已换)→ 本次结果作废:不回写旧 token 的
2844
+ // // 刷新产物,避免覆盖刚登录的新会话。
2845
+ // if (kg.session.token !== tokenAtStart) return
2846
+ // if (!refreshed.token) return
2847
+ // kg.session.token = refreshed.token
2848
+ // kg.session.vip_type = refreshed.vip_type || kg.session.vip_type
2849
+ // kg.session.vip_token = refreshed.vip_token || kg.session.vip_token
2850
+ // if (refreshed.t1) kg.session.t1 = refreshed.t1
2851
+ // await saveKGCookie()
2852
+ // } catch (e) {
2853
+ // // 续命失败不登出:可能只是瞬时网络/风控抖动。token 是否真死交给后续业务
2854
+ // // 请求的 20017/20018 判定(kgWithFreshToken catch 分支才 clearKGCookie)。
2855
+ // kg.logger && kg.logger.warn && kg.logger.warn('酷狗主动续命失败(保留登录态):' + String((e && e.message) || e))
2856
+ // } finally {
2857
+ // kgRefreshInFlight = null
2858
+ // }
2859
+ // })()
2860
+ // return kgRefreshInFlight
2861
+ // }
2868
2862
 
2869
2863
  // 取链结果短缓存(同 QQ):同一首歌的 <audio> 加载与频谱信封两次请求命中 /kg/play,
2870
2864
  // 缓存保证两拿到的档位一致且省一次 tracker 往返。失败原因随 entry 带回给路由展示。
@@ -3154,6 +3148,9 @@ export function apply(ctx) {
3154
3148
  // MIMO_API_KEY 环境变量 + 固定 MiMo 端点作兜底,方便未在 DSH 里配 provider 时。
3155
3149
  const MIMO_DEFAULT_BASE_URL = 'https://api.xiaomimimo.com/v1/chat/completions'
3156
3150
  const MIMO_DEFAULT_MODEL = 'mimo-v2.5-tts'
3151
+ // TTS 单次请求整体硬超时(fetch 连接 + 响应体读取):上游偶发慢传输会拖到分钟级,
3152
+ // 必须在此前失败以释放 in-flight 槽位——否则客户端点「重试」会挂到同一慢请求上。
3153
+ const TTS_REQ_TIMEOUT_MS = 60 * 1000
3157
3154
  // 只保留四种中文声音,默认白桦(男声)。
3158
3155
  const MIMO_DEFAULT_VOICE = '白桦'
3159
3156
  // Built-in Chinese voices for mimo-v2.5-tts (official list). Exposed to the
@@ -3298,8 +3295,19 @@ export function apply(ctx) {
3298
3295
  audio: { format: 'wav', voice: safeVoice(voice) },
3299
3296
  stream: false,
3300
3297
  }
3298
+ // 整体硬超时(fetch 连接 + 响应体读取都要覆盖):TTS 上游偶发「响应头先到、
3299
+ // body 却卡住几十秒~几分钟」的慢传输——若只给 fetch 加 abort,res.json() 读
3300
+ // body 会无限挂起(实测单块卡 233s),占用 in-flight slot 导致「点重试没反应」
3301
+ // (重试挂到同一慢请求上)。body 读取也须在超时内完成,超时即整体失败、
3302
+ // 释放 slot,重试立即重新合成。
3301
3303
  const ctrl = new AbortController()
3302
- const timer = setTimeout(() => ctrl.abort(), 60000)
3304
+ const timer = setTimeout(() => ctrl.abort(), TTS_REQ_TIMEOUT_MS)
3305
+ // Body 读取也受同一超时约束:abort 触发时 reject 正在进行的 res.json()。
3306
+ // (fetch resolve 后其 body 流的读取不再受原 signal 约束,需手动 race。)
3307
+ const abortBody = new Promise((_, reject) => {
3308
+ ctrl.signal.addEventListener('abort', () => reject(Object.assign(new Error('TTS 请求超时'), { name: 'AbortError' })), { once: true })
3309
+ })
3310
+ const readJson = () => Promise.race([res.json(), abortBody])
3303
3311
  let res
3304
3312
  try {
3305
3313
  res = await fetch(baseURL, {
@@ -3313,12 +3321,21 @@ export function apply(ctx) {
3313
3321
  if (err.name === 'AbortError') throw new Error('TTS 请求超时')
3314
3322
  throw err
3315
3323
  }
3316
- clearTimeout(timer)
3317
3324
  if (!res.ok) {
3318
- const detail = await res.text().catch(() => '')
3325
+ clearTimeout(timer)
3326
+ let detail = ''
3327
+ try { detail = await Promise.race([res.text(), abortBody]) } catch { /* 超时/读不到则略 */ }
3319
3328
  throw new Error('TTS 请求失败 ' + res.status + ' ' + detail.slice(0, 300))
3320
3329
  }
3321
- const json = await res.json()
3330
+ let json
3331
+ try {
3332
+ json = await readJson()
3333
+ } catch (err) {
3334
+ clearTimeout(timer)
3335
+ if (err.name === 'AbortError') throw new Error('TTS 请求超时')
3336
+ throw err
3337
+ }
3338
+ clearTimeout(timer)
3322
3339
  const data = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.audio && json.choices[0].message.audio.data
3323
3340
  if (typeof data !== 'string') throw new Error('TTS 响应缺少音频数据')
3324
3341
  const buf = Buffer.from(data, 'base64')
@@ -3400,6 +3417,47 @@ export function apply(ctx) {
3400
3417
  }
3401
3418
  if (peak <= 200) problems.push('音频内容静音(峰值=' + peak + ')')
3402
3419
  }
3420
+ // Degenerate long-silence detection: reject wavs that are structurally valid
3421
+ // (header ok, has speech at the start/end) but contain a multi-second dead
3422
+ // stretch in the middle — the TTS API occasionally returns a normal reading
3423
+ // followed by ~200s of near-zero samples (6-10x the expected length; the
3424
+ // browser then "plays" the silence while the bar/subtitles crawl). Real
3425
+ // speech has clause/sentence pauses of well under a second, never minutes.
3426
+ // Scan per-second segments (sampled) for a peak above a small threshold and
3427
+ // flag when the voiced share is too low or a silent run is absurdly long.
3428
+ if (fmtBits === 16 && actualData >= 2
3429
+ && !problems.some((p) => p.startsWith('音频数据为空') || p.startsWith('音频数据被截断') || p.startsWith('音频内容静音'))
3430
+ && fmtRate >= 4000) {
3431
+ const bytesPerSec = fmtRate * fmtCh * 2
3432
+ const totalSec = Math.floor(actualData / bytesPerSec)
3433
+ if (totalSec >= 8) {
3434
+ const VOICE_PEAK = 200 // same "audible" threshold as the all-silent check
3435
+ const voiced = []
3436
+ for (let s = 0; s < totalSec; s++) {
3437
+ const start = Math.floor(s * bytesPerSec / 2)
3438
+ const end = Math.min(Math.floor((s + 1) * bytesPerSec / 2), Math.floor(actualData / 2))
3439
+ let p = 0
3440
+ // Sample ~100 points per second (real speech moves slowly); early-exit
3441
+ // on a clearly audible sample. Worst case (fully silent file) is
3442
+ // totalSec*100 reads — trivial for even a 5-minute wav.
3443
+ const step = Math.max(1, Math.floor((end - start) / 100))
3444
+ for (let i = start; i < end; i += step) {
3445
+ const a = Math.abs(buf.readInt16LE(dataStart + i * 2))
3446
+ if (a > p) p = a
3447
+ if (p > VOICE_PEAK) break
3448
+ }
3449
+ voiced.push(p > VOICE_PEAK ? 1 : 0)
3450
+ }
3451
+ const voicedSecs = voiced.reduce((a, b) => a + b, 0)
3452
+ // Longest consecutive silent run (in seconds).
3453
+ let longestRun = 0; let run = 0
3454
+ for (const v of voiced) { run = v ? 0 : run + 1; if (run > longestRun) longestRun = run }
3455
+ const ratio = voicedSecs / totalSec
3456
+ if (ratio < 0.5 || longestRun > 6) {
3457
+ problems.push('音频含超长静音(总' + totalSec + 's,语音仅' + voicedSecs + 's,最长连续静音' + longestRun + 's)')
3458
+ }
3459
+ }
3460
+ }
3403
3461
  if (problems.length > 0) {
3404
3462
  logTts({
3405
3463
  kind: 'degenerate',
@@ -3654,6 +3712,7 @@ export function apply(ctx) {
3654
3712
  // JSON API routes
3655
3713
  if (pathname === '/dsh-music/manifest' && req.method === 'GET') {
3656
3714
  await ensureStarted()
3715
+ await loadKGCookie()
3657
3716
  await loadQQCookie()
3658
3717
  await loadNCCookie()
3659
3718
  const ts = await ttsState()
@@ -3663,7 +3722,7 @@ export function apply(ctx) {
3663
3722
  playlists: publicPlaylists(),
3664
3723
  ttsConfigured: ts.configured, ttsReason: ts.reason, ttsProvider: ts.provider || '', voices: MIMO_VOICES,
3665
3724
  qqLoggedIn: !!qq.cookie, qqUin: qqUin(), qqNickname: qq.nickname, qqLoginFrom: qq.loginFrom,
3666
- kgLoggedIn: kg.loggedIn,
3725
+ kgLoggedIn: kg.loggedIn, kgNickname: kg.session.nickname || '',
3667
3726
  ncLoggedIn: nc.loggedIn, ncNickname: nc.nickname,
3668
3727
  version: PKG_VERSION,
3669
3728
  description: PKG_DESCRIPTION,
@@ -3678,6 +3737,7 @@ export function apply(ctx) {
3678
3737
  const r = await refresh()
3679
3738
  const ts = await ttsState()
3680
3739
  const wn = await whatsNewPayload()
3740
+ await loadKGCookie()
3681
3741
  await loadNCCookie()
3682
3742
  writeJson(res, {
3683
3743
  ok: true, root: r.root, bookRoot: r.bookRoot,
@@ -3685,7 +3745,7 @@ export function apply(ctx) {
3685
3745
  playlists: publicPlaylists(),
3686
3746
  ttsConfigured: ts.configured, ttsReason: ts.reason, ttsProvider: ts.provider || '', voices: MIMO_VOICES,
3687
3747
  qqLoggedIn: !!qq.cookie, qqUin: qqUin(), qqNickname: qq.nickname, qqLoginFrom: qq.loginFrom,
3688
- kgLoggedIn: kg.loggedIn,
3748
+ kgLoggedIn: kg.loggedIn, kgNickname: kg.session.nickname || '',
3689
3749
  ncLoggedIn: nc.loggedIn, ncNickname: nc.nickname,
3690
3750
  version: PKG_VERSION,
3691
3751
  description: PKG_DESCRIPTION,
@@ -4522,7 +4582,14 @@ export function apply(ctx) {
4522
4582
 
4523
4583
  if (pathname === '/dsh-music/kg/status' && req.method === 'GET') {
4524
4584
  await loadKGCookie()
4525
- writeJson(res, { loggedIn: kg.loggedIn, userid: kg.session.userid || '' })
4585
+ // 已登录但 cookie 里没有昵称(老会话升级/上次抓取失败)→ 补抓一次,失败静默。
4586
+ if (kg.loggedIn && !kg.session.nickname) {
4587
+ try {
4588
+ const ui = await KG.getMyUserInfo(kg.session)
4589
+ if (ui.nickname) { kg.session.nickname = ui.nickname; await saveKGCookie() }
4590
+ } catch (e) { void e /* 补抓失败静默,下次 status 再试 */ }
4591
+ }
4592
+ writeJson(res, { loggedIn: kg.loggedIn, userid: kg.session.userid || '', nickname: kg.session.nickname || '' })
4526
4593
  return
4527
4594
  }
4528
4595
  if (pathname === '/dsh-music/kg/login/start' && req.method === 'POST') {
@@ -4560,28 +4627,28 @@ export function apply(ctx) {
4560
4627
  try {
4561
4628
  const result = await KG.checkQRLogin(key, kg.session)
4562
4629
  if (result.status === 'success' && result.tokenInfo) {
4630
+ // 直接用扫码返回的 token,不做「登录后立刻 refresh」——对齐社区(MakcRe
4631
+ // login_qr_check 扫码成功后仅存 token;lx-music/kgqd 同样不二次处理)。
4632
+ // 调研实证(MakcRe issue #117):login_by_token 是「用原 token+userid
4633
+ // 重新登录判断是否可用」的探活/续期,不是换发新 token;登录瞬间多调一次
4634
+ // 反而可能被官方当作异常会话操作、触发对 token 的提前作废。主动续命已
4635
+ // 停用:token 过期时业务请求撞 20017/20018 → 前端提示重新扫码即可。
4563
4636
  kg.session.token = result.tokenInfo.token
4564
4637
  kg.session.userid = result.tokenInfo.userid
4565
4638
  kg.session.vip_type = result.tokenInfo.vip_type || ''
4566
4639
  kg.session.vip_token = result.tokenInfo.vip_token || ''
4567
- // 立刻刷新一次(v5/login_by_token):把扫码 token 兑换为标准作用域,
4568
- // 消除云歌单/取链的设备作用域拒绝(kgqd 生产环境同款动作)。
4569
- let refreshNote = ''
4570
- try {
4571
- const refreshed = await KG.refreshSession(kg.session)
4572
- if (refreshed.token) {
4573
- kg.session.token = refreshed.token
4574
- kg.session.vip_type = refreshed.vip_type || kg.session.vip_type
4575
- kg.session.vip_token = refreshed.vip_token || kg.session.vip_token
4576
- if (refreshed.t1) kg.session.t1 = refreshed.t1
4577
- refreshNote = '已刷新为标准会话'
4578
- }
4579
- } catch (e) { void e /* 刷新失败不阻断登录 */ }
4580
4640
  kg.loggedIn = !!(kg.session.token && kg.session.userid)
4581
4641
  kg.isVip = null
4582
4642
  kgTakeCacheKg.clear()
4583
4643
  await saveKGCookie()
4584
- writeJson(res, { ok: true, status: 'success', loggedIn: kg.loggedIn, userid: kg.session.userid, message: result.message, refresh: refreshNote })
4644
+ // 登录后抓一次用户资料(昵称/头像),失败不阻断登录(cookie 里 nickname
4645
+ // 留空,后续 status/资料刷新可补)。对齐网易云面板显示「退出登录(昵称)」。
4646
+ try {
4647
+ const ui = await KG.getMyUserInfo(kg.session)
4648
+ if (ui.nickname) kg.session.nickname = ui.nickname
4649
+ await saveKGCookie()
4650
+ } catch (e) { void e /* 抓昵称失败不阻断登录 */ }
4651
+ writeJson(res, { ok: true, status: 'success', loggedIn: kg.loggedIn, userid: kg.session.userid, nickname: kg.session.nickname || '', message: result.message })
4585
4652
  } else {
4586
4653
  writeJson(res, { ok: true, status: result.status, message: result.message, extra: result.extra })
4587
4654
  }
package/lib/kugou.js CHANGED
@@ -877,6 +877,50 @@ export async function registerDevice(session = {}) {
877
877
  return { dfid: String(parsed.data.dfid) }
878
878
  }
879
879
 
880
+ /**
881
+ * 获取当前登录用户资料(昵称/头像/签名等)。需登录(token+userid)。
882
+ * 端点 relation.user.kugou.com/v1/get_my_userinfo(MakcRe module/user_info.js 移植)。
883
+ * ⚠️ clienttime 用毫秒 Date.now()(非秒);p = RSA-PKCS1 加密 {clienttime, token};
884
+ * key = signParamsKey(clienttime)(同为毫秒)。body JSON + android 签名。
885
+ * 返回 { userid, nickname, pic, signature, username, ... };失败抛错。
886
+ */
887
+ export async function getMyUserInfo(session = {}) {
888
+ const s = normSession(session)
889
+ if (!s.token) throw new Error('未登录,无法获取用户资料')
890
+ const clienttime = Date.now()
891
+ const p = kxRsaWrap({ clienttime, token: s.token })
892
+ const dataMap = {
893
+ p,
894
+ appid: APPID,
895
+ mid: s.mid,
896
+ clientver: CLIENTVER,
897
+ source: 0,
898
+ clienttime, // 毫秒(与参考实现一致,勿改秒)
899
+ uuid: '-',
900
+ userid: Number(s.userid) || 0,
901
+ key: signParamsKey(clienttime),
902
+ }
903
+ const bodyText = JSON.stringify(dataMap)
904
+ const { d } = gatewayDefaults(s, String(CLIENTVER))
905
+ const q = new URLSearchParams({ ...d, signature: signAndroid(d, bodyText) })
906
+ const res = await fetchWithTimeout(`http://relation.user.kugou.com/v1/get_my_userinfo?${q.toString()}`, {
907
+ method: 'POST',
908
+ headers: { ...GATEWAY_HEADERS(normSession(s), d.clienttime), 'Content-Type': 'application/json', Host: 'relation.user.kugou.com' },
909
+ body: bodyText,
910
+ }, 15000)
911
+ const j = await res.json().catch(() => null)
912
+ if (!j || Number(j.status) !== 1 || !j.data) {
913
+ throw new Error('获取酷狗用户资料失败:' + explainKgError(j, res.status))
914
+ }
915
+ return {
916
+ userid: String(j.data.userid != null ? j.data.userid : s.userid),
917
+ nickname: String(j.data.nickname || ''),
918
+ pic: String(j.data.pic || ''),
919
+ signature: String(j.data.signature || ''),
920
+ username: String(j.data.username || ''),
921
+ }
922
+ }
923
+
880
924
  /** 把服务端业务错误码翻译成人话(统一贯穿各接口)。 */
881
925
  export function explainKgError(json, httpStatus) {
882
926
  const code = Number(json && (json.error_code ?? json.errcode))
@@ -937,6 +981,11 @@ export async function refreshSession(session = {}) {
937
981
  dfid: s.dfid || '-',
938
982
  p3,
939
983
  plat: 1,
984
+ // 设备型号与客户端构建串:贴近真实 Android 上报形态,供服务端风控辨识
985
+ //(lx-music-api-server modules/refresh/kg.py 同款字段,gitversion 抄同值;
986
+ // UA/t3/need_toneinfo 维持现状即可,两套变体均被服务端接受)。
987
+ dev: '23127PN0CC',
988
+ gitversion: 'a23c277',
940
989
  t1: 0,
941
990
  t2: 0,
942
991
  t3: 'MCwwLDAsMCwwLDAsMCwwLDA=',
package/lib/whatsnew.js CHANGED
@@ -41,6 +41,31 @@ export const WELCOME = {
41
41
  // 版本更新条目:新 → 旧。type 仅支持 feature / improve / fix(未知类型按
42
42
  // improve 渲染兜底)。date 为该版本发布日期(YYYY-MM-DD)。
43
43
  export const WHATS_NEW = [
44
+ {
45
+ version: '1.0.1',
46
+ date: '2026-09-14',
47
+ title: 'ESC 关闭面板与 AI 讲书修复',
48
+ sections: [
49
+ {
50
+ type: 'feature',
51
+ items: [
52
+ '支持按 ESC 键关闭面板窗口',
53
+ ],
54
+ },
55
+ {
56
+ type: 'fix',
57
+ items: [
58
+ '修复 AI 讲书的一些小问题',
59
+ ],
60
+ },
61
+ {
62
+ type: 'improve',
63
+ items: [
64
+ '完善一些小细节',
65
+ ],
66
+ },
67
+ ],
68
+ },
44
69
  {
45
70
  version: '1.0.0',
46
71
  date: '2026-09-03',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Vibe coding时的好伴侣",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",