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 +1 -1
- package/lib/client.js +71 -18
- package/lib/index.js +166 -99
- package/lib/kugou.js +49 -0
- package/lib/whatsnew.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ DeepSeek Harness 音乐/小说播放插件。
|
|
|
33
33
|
|:---:|:---:|
|
|
34
34
|
|  |  |
|
|
35
35
|
| 播放酷狗音乐 | AI讲书 |
|
|
36
|
-
|  |  |  |
|
|
37
37
|
| QQ音乐面板 | 酷狗音乐面板 |
|
|
38
38
|
|  |  |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
// 名称文本(音符图标 +
|
|
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
|
|
2681
|
-
//
|
|
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
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
|
|
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
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2816
|
-
//
|
|
2817
|
-
//
|
|
2818
|
-
//
|
|
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
|
-
//
|
|
2824
|
-
//
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2828
|
-
//
|
|
2829
|
-
//
|
|
2830
|
-
//
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|