dsh-music-player 0.6.3 → 0.6.5

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.
Files changed (3) hide show
  1. package/lib/client.js +102 -15
  2. package/lib/qq.js +68 -4
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1921,6 +1921,10 @@ window.__ModuleLoader__.load({
1921
1921
  if (bookFromRef + 1 < bookTotal) {
1922
1922
  // 进入新块:每个块各拥有一次自动重试的机会。
1923
1923
  bookAutoRetried = false;
1924
+ // 恢复定位钉只属于「被恢复的那一块」:切到下一块必须丢弃,否则下一块的
1925
+ // 音频会被 seek 回上一块的恢复位置(跳到末尾即刻结束 → 卡住/无声音/字幕不动)。
1926
+ bookRestorePos = -1;
1927
+ restoredMusicPos = null;
1924
1928
  const endedDur = Number.isFinite(audio.duration) ? audio.duration : (audio.currentTime || 0);
1925
1929
  if (Number.isFinite(endedDur)) bookBaseTime += endedDur;
1926
1930
  playBookFrom(id, bookFromRef + 1, true);
@@ -1980,9 +1984,33 @@ window.__ModuleLoader__.load({
1980
1984
  // 恢复续播的这块之前没有拉取过字幕(restore 不动 audio、playBookFrom 不经过
1981
1985
  // 这里),必须补一次 loadBookSubtitle,否则续播后整块无字幕、直到切块才出现。
1982
1986
  loadBookSubtitle(id, bookFromRef);
1987
+ // 重启续播:Host TTS 合成缓存已清空,恢复的这块若尚未预热完成,点 ▶ 后仍要
1988
+ // 重新合成(数秒~数十秒)。若不置缓冲提示/合成超时,播放条会停在恢复位置、
1989
+ // 无声音、字幕不动——表现为「卡住」。这里沿用 playBookFrom 的「AI 合成中… Ns」
1990
+ // 提示 + 60s 超时兜底,让等待透明、且合成失败/挂起时有明确错误而非无限卡死。
1991
+ if (bookStuckTimer !== null) clearTimeout(bookStuckTimer);
1992
+ set({ bookBuffering: true, bookBufferingSilent: false, bookBufferingSince: Date.now() });
1993
+ bookStuckTimer = setTimeout(() => {
1994
+ if (store.bookBuffering) set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0, bookError: 'AI 合成超时,请点击「重试」' });
1995
+ }, 60000);
1983
1996
  }
1984
1997
  const promise = audio.play();
1985
- if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' }); });
1998
+ if (promise !== undefined && typeof promise.then === 'function') {
1999
+ promise.then(() => {
2000
+ // 恢复续播路径已置缓冲提示/超时:播放真正开始时清除(与 playBookFrom 的
2001
+ // doneBuffering 一致)。其它模式下 bookBuffering/超时本就不存在,跳过 set。
2002
+ if (bookStuckTimer !== null || store.bookBuffering) {
2003
+ if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2004
+ set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0 });
2005
+ }
2006
+ }).catch((err) => {
2007
+ // 播放被拒绝(如 autoplay 拦截)时同样清掉缓冲/超时:否则「AI 合成中…」
2008
+ // 会一直挂着,而错误提示又不显示(这里只对 autoplay 拦截报错)。
2009
+ if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2010
+ set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0 });
2011
+ if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' });
2012
+ });
2013
+ }
1986
2014
  // Envelope decoding is deferred to play (see loadTracks) — decode the
1987
2015
  // restored track lazily so its spectrum is ready once it resumes.
1988
2016
  if (!String(store.currentId).startsWith('book:')) {
@@ -2175,9 +2203,25 @@ window.__ModuleLoader__.load({
2175
2203
  // start until then) — anchored on top of the book-wide clock.
2176
2204
  if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:')) {
2177
2205
  const ct = audio.currentTime || 0;
2178
- if (ct > bookRestorePos + 1) {
2179
- bookRestorePos = -1; // real playback advanced past the spot
2180
- } else {
2206
+ // 释放/处理条件:真实播放已明显越过恢复点(正常放过去),或恢复点已超出本块
2207
+ // 的实际时长。块可能被重新合成得更短(尤其重启后冷合成)——此时若仍把
2208
+ // currentTime seek 到保存位置,会被浏览器钳到块末尾,且未必派发 ended,于是
2209
+ // 「响一下→没声音、字幕不动」。若用户其实已越过本块(保存位置 ≥ 块长),则
2210
+ // 直接跳到下一块;否则释放定位钉、让它从当前处自然播到块尾再切块。
2211
+ const dur = (Number.isFinite(audio.duration) && audio.duration > 0) ? audio.duration : 0;
2212
+ const pastSpot = ct > bookRestorePos + 1;
2213
+ const pastEnd = dur > 0 && bookRestorePos >= dur;
2214
+ if (pastSpot) {
2215
+ bookRestorePos = -1; // real playback advanced past the spot — live time
2216
+ } else if (pastEnd) {
2217
+ // 保存位置已越过本块实际末尾(重启后重合成块变短)→ 用户其实已读完这一块,
2218
+ // 直接跳到下一块继续,而不是重听本块(否则会整块重播,或响一下→卡住)。
2219
+ // maybeAdvanceBook 内部会清 bookRestorePos 并推进 bookBaseTime;若已到最后一
2220
+ // 页(无下一块)则释放定位钉、让本块自然播完后再结束。
2221
+ if (!maybeAdvanceBook()) bookRestorePos = -1;
2222
+ } else if (dur > 0) {
2223
+ // 时长已就绪且恢复点在本块内:才把音频 seek 到恢复点(以免提前 seek 到一个
2224
+ // 随后才发现越界、被钳到块末尾的位置而卡死)。
2181
2225
  if (store.playing && ct < bookRestorePos - 0.5) {
2182
2226
  try { audio.currentTime = bookRestorePos; } catch (e) {}
2183
2227
  }
@@ -2187,6 +2231,8 @@ window.__ModuleLoader__.load({
2187
2231
  updateLyric();
2188
2232
  return;
2189
2233
  }
2234
+ // dur 尚未知(元数据未加载):先不 seek,走下方正常读值;等时长就绪后上面的
2235
+ // pin 会再次评估并正确 seek/释放,绝不提前把一个可能越界的位置塞进 currentTime。
2190
2236
  }
2191
2237
  set({ position: bookTimeBase() + (audio.currentTime || 0) });
2192
2238
  // Persist the playback spot periodically (≈every 5s) for BOTH music and
@@ -2438,6 +2484,12 @@ window.__ModuleLoader__.load({
2438
2484
  void ensureBookTotal(book.id).then((total) => {
2439
2485
  if (!Number.isFinite(total) || total < 0) { bookTotal = savedTotal; return; }
2440
2486
  bookTotal = total;
2487
+ // 预热「要续播的那一块」:重启后 Host 的 TTS 合成缓存与浏览器 HTTP 缓存都已
2488
+ // 清空/失效,续播时该块会重新合成(数秒~数十秒)。此时若不加处理,用户一点
2489
+ // ▶ 播放条就停在恢复位置、无声音、字幕不动——正是「重启后续播卡住」的根因。
2490
+ // 这里在恢复阶段就后台合成并缓存这一块,等用户点 ▶ 时即可秒起(或至少已
2491
+ // 在合成中,点 ▶ 只是等收尾,无需从零开始)。
2492
+ if (from >= 0 && from < total) fetch(bookUrl(book.id, from)).catch(() => {});
2441
2493
  if (from + 1 < total) preloadBook(book.id, from + 1);
2442
2494
  });
2443
2495
  }
@@ -2604,7 +2656,7 @@ window.__ModuleLoader__.load({
2604
2656
  // 播放控制图标(上一首/播放/暂停/下一首/停止):用 SVG 替代 ⏮▶⏸⏭⏹ 文本字形。
2605
2657
  // 这些 Unicode 符号(尤其 ⏸ 常以 emoji 呈现)宽高/基线不一致,点击切换会让按钮
2606
2658
  // 大小与位置偏移;统一用同尺寸 viewBox=24 的 SVG,保证按钮恒定尺寸、图标精确居中。
2607
- const iconSvg = (path, w = 14) => (props) => React.createElement('svg', { className: props.className || '', width: w, height: w, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
2659
+ const iconSvg = (path, w = 16) => (props) => React.createElement('svg', { className: props.className || '', width: w, height: w, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
2608
2660
  React.createElement('path', { d: path }));
2609
2661
  const PlayIcon = iconSvg('M8 5v14l11-7z');
2610
2662
  const PauseIcon = iconSvg('M6 19h4V5H6v14zm8-14v14h4V5h-4z');
@@ -2800,7 +2852,7 @@ window.__ModuleLoader__.load({
2800
2852
  title: faved ? '取消收藏(从「我最喜欢」移除)' : '收藏到「我最喜欢」',
2801
2853
  onClick: toggleFav,
2802
2854
  }, React.createElement('svg', {
2803
- viewBox: '0 0 24 24', width: 14, height: 14,
2855
+ viewBox: '0 0 24 24', width: 16, height: 16,
2804
2856
  fill: faved ? 'currentColor' : 'none', stroke: 'currentColor', strokeWidth: 2, 'aria-hidden': true,
2805
2857
  }, React.createElement('path', { d: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' }))) : null;
2806
2858
  const showBarBtns = () => { if (hoverTimerRef.current !== null) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } setBarHover(true); };
@@ -3494,14 +3546,42 @@ window.__ModuleLoader__.load({
3494
3546
  if (!d || !d.ok) { setLoginStatus((d && d.error) || '查询失败,正在重试…'); schedulePoll(); return; }
3495
3547
  if (d.status === 'success') { clearPoll(); setLoggedIn(true); setUin(d.uin || ''); setNickname(d.nickname || ''); setLayer('main'); setActivePl(null); saveUi('main', '', ''); setLoginStatus('登录成功'); setTimeout(closeLogin, 800); refreshQQFavIds(); }
3496
3548
  else if (d.status === 'scanned') { setLoginStatus('已扫码,请在手机上确认'); schedulePoll(); }
3497
- else if (d.status === 'expired') { clearPoll(); setLoginStatus('二维码已过期,请重新扫码'); }
3549
+ else if (d.status === 'expired') { clearPoll(); setLoginStatus('二维码已过期'); }
3498
3550
  else if (d.status === 'failed') { clearPoll(); setLoginStatus(d.message || '登录失败'); }
3499
- else if (d.status === 'waiting') { setLoginStatus('等待扫码…'); schedulePoll(); }
3551
+ else if (d.status === 'waiting') { schedulePoll(); }
3500
3552
  else { schedulePoll(); }
3501
3553
  } catch (e) { setLoginStatus('获取登录状态超时,正在自动重试…'); schedulePoll(); }
3502
3554
  }
3503
3555
  function closeLogin() { clearPoll(); loginModeRef.current = null; qrKeyRef.current = ''; setLoginMode(null); setLoginStatus(''); setQrImage(''); }
3504
- async function logout() { try { await jsonPost('/dsh-music/qq/login/logout', {}); } catch {} setLoggedIn(false); setUin(''); setNickname(''); }
3556
+ async function logout() {
3557
+ try { await jsonPost('/dsh-music/qq/login/logout', {}); } catch {}
3558
+ setLoggedIn(false); setUin(''); setNickname('');
3559
+ // 彻底清空所有 QQ 音乐浏览数据,避免换账号登录后看到上一个账号的歌单/搜索/浏览数据。
3560
+ setLayer('main'); setActivePl(null); setPlLoading(false); setBrowseTab('mine');
3561
+ setQ(''); setSearched(false); setSearching(false); setResults([]); setPlResults([]);
3562
+ setQError(''); setResultTab('songs'); setSearchPage(1); setSearchLastLen(0);
3563
+ setSearchingMore(false); setPlSearchPage(1); setPlSearchLastLen(0); setPlSearchingMore(false);
3564
+ setHist([]); setHistOpen(false);
3565
+ setMinePlays([]); setMineLoaded(false); setRecommended([]);
3566
+ setCategories([]); setCatPlays([]); setCurCategory(null); setBrowseErr(''); setBrowseLoading(false);
3567
+ setRecPage(1); setRecLoadingMore(false); setRecHasMore(true);
3568
+ setCatPage(1); setCatLoadingMore(false); setCatHasMore(true); setCatExpanded(false);
3569
+ setTopGroups([]); setTopLoaded(false); setTopDetail(null); setTopLoading(false);
3570
+ setTopTotal(0); setTopHasMore(false); setTopLoadingMore(false);
3571
+ setNewSongs([]); setNewLoaded(false);
3572
+ saveUi('main', '', '');
3573
+ // 清空在线播放队列与当前曲目(否则退出后播放条仍可「下一首」见上一账号的歌)。
3574
+ const isQQPlaying = String(store.currentId || '').startsWith('qq:') || store.scope?.kind === 'qq';
3575
+ if (isQQPlaying) {
3576
+ // 当前播的是 QQ 在线曲目 → 真正停止播放(pause + 清 src)并清空在线队列。
3577
+ // 用 stop() 而不是只 set state,否则音频仍在播,播放条仍显示/残留上一账号的曲目。
3578
+ stop();
3579
+ set({ qqQueue: [], qqSource: '', qqFaved: false });
3580
+ } else {
3581
+ // 当前播的是本地/讲书 → 只清空在线队列,保留当前播放。
3582
+ set({ qqQueue: [], qqSource: '', qqFaved: false });
3583
+ }
3584
+ }
3505
3585
 
3506
3586
  // ---- 渲染辅助 ----
3507
3587
  const fmtCount = (n) => { const v = Number(n) || 0; if (v >= 1e8) return (v / 1e8).toFixed(1).replace(/\.0$/, '') + '亿'; if (v >= 1e4) return (v / 1e4).toFixed(1).replace(/\.0$/, '') + '万'; return String(v); };
@@ -3701,13 +3781,15 @@ window.__ModuleLoader__.load({
3701
3781
  loginStatus !== '登录成功' ? React.createElement('div', { className: 'dsh-music-qq-login-actions' },
3702
3782
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => startLogin(loginMode) }, '刷新二维码'),
3703
3783
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: closeLogin }, '取消')) : null,
3704
- React.createElement('p', { className: 'dsh-music-hint' }, '用官方 App 扫码并确认。扫码登录走第三方接口,存在账号风控/合规风险,仅供个人试听。'),
3705
3784
  )))) : null;
3706
3785
 
3707
3786
  // ---- 未登录:只显示居中两个登录按钮(QQ 登录 / 微信登录,分两行)+ 风险提示 ----
3708
3787
  if (!loggedIn) {
3709
3788
  return React.createElement('div', { className: 'dsh-music-qq dsh-music-qq-login' },
3710
3789
  React.createElement('div', { className: 'dsh-music-qq-login-center' },
3790
+ React.createElement('div', { className: 'dsh-music-qq-login-verified' },
3791
+ React.createElement('span', { className: 'dsh-music-qq-login-verified-icon' }, '✓'),
3792
+ React.createElement('span', null, 'QQ/微信扫码登录验证已 OK')),
3711
3793
  React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('qq') }, 'QQ 登录'),
3712
3794
  React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('wx') }, '微信登录'),
3713
3795
  React.createElement('div', { className: 'dsh-music-qq-login-warn' },
@@ -4667,9 +4749,9 @@ window.__ModuleLoader__.load({
4667
4749
  '.dsh-music-bar-lyric { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 14px; animation: dsh-music-lyric-in 0.3s ease 0.3s backwards; }\n' +
4668
4750
  '@keyframes dsh-music-lyric-in { from { opacity: 0; } to { opacity: 1; } }\n' +
4669
4751
  '.dsh-music-bar-warn { background: transparent; border: none; color: var(--dsw-alias-state-warn-primary, #d9a441); font-size: 12px; cursor: pointer; padding: 0; white-space: nowrap; }\n' +
4670
- '.dsh-music-bar-btn { display: inline-flex; align-items: center; justify-content: center; flex: none; height: 20px; background: transparent; border: none; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 4px; border-radius: 4px; }\n' +
4671
- '.dsh-music-bar-btn:hover { color: var(--dsw-alias-brand-primary, #4f8cff); }\n' +
4672
- '.dsh-music-bar-btn.active { color: var(--dsw-alias-brand-primary, #4f8cff); }\n' +
4752
+ '.dsh-music-bar-btn { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 50%; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.05)); color: var(--dsh-music-accent, #2f9e6e); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; }\n' +
4753
+ '.dsh-music-bar-btn:hover { color: var(--dsh-music-accent-fg, #fff); background: var(--dsh-music-accent, #2f9e6e); }\n' +
4754
+ '.dsh-music-bar-btn.active { color: var(--dsh-music-accent, #2f9e6e); }\n' +
4673
4755
  '.dsh-music-bar-vol { position: relative; flex: none; display: inline-flex; align-self: center; }\n' +
4674
4756
  '.dsh-music-bar-vol-pop { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); display: flex; align-items: center; justify-content: center; width: 36px; height: 108px; box-sizing: border-box; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 8px; box-shadow: 0 8px 20px rgba(0,0,0,0.3); z-index: 60; }\n' +
4675
4757
  // 讲书时音量弹层加宽,容纳 AI 声音选择 + 音量条。
@@ -4701,7 +4783,7 @@ window.__ModuleLoader__.load({
4701
4783
  '@keyframes dsh-music-spin { to { transform: rotate(360deg); } }\n' +
4702
4784
  '.dsh-music-bar-berr { margin-left: 8px; color: var(--dsw-alias-state-error-primary, #e5534b); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; display: inline-flex; align-items: center; gap: 4px; }\n' +
4703
4785
  '.dsh-music-bar-berr-text { overflow: hidden; text-overflow: ellipsis; }\n' +
4704
- '.dsh-music-bar-btn.retry { color: var(--dsw-alias-state-error-primary, #e5534b); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); border-radius: 6px; padding: 0 6px; height: 18px; flex: none; }\n' +
4786
+ '.dsh-music-bar-btn.retry { width: auto; color: var(--dsw-alias-state-error-primary, #e5534b); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); border-radius: 6px; padding: 0 6px; height: 18px; flex: none; }\n' +
4705
4787
  '.dsh-music-bar-btn.retry:hover { background: var(--dsw-alias-state-error-primary, #e5534b); color: #fff; }\n' +
4706
4788
  '.dsh-music-bar .dsh-music-mode-trigger { width: 24px; height: 24px; }\n' +
4707
4789
  '.dsh-music-bar .dsh-music-mode-trigger svg { flex: none; }\n' +
@@ -4847,6 +4929,9 @@ window.__ModuleLoader__.load({
4847
4929
  '.dsh-music-qq-toolbar { display: flex; justify-content: space-between; gap: 8px; align-items: center; }\n' +
4848
4930
  '.dsh-music-qq-login { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; }\n' +
4849
4931
  '.dsh-music-qq-login-center { display: flex; flex-direction: column; gap: 12px; align-items: center; max-width: 320px; }\n' +
4932
+ // 醒目提示:QQ 扫码登录已验证 OK。
4933
+ '.dsh-music-qq-login-verified { display: flex; align-items: center; gap: 8px; width: 100%; max-width: 300px; padding: 10px 14px; box-sizing: border-box; font-size: 14px; font-weight: 700; color: #fff; background: linear-gradient(135deg, #16a34a, #22c55e); border-radius: 8px; text-align: center; justify-content: center; box-shadow: 0 2px 8px rgba(22, 163, 74, 0.35); }\n' +
4934
+ '.dsh-music-qq-login-verified-icon { font-size: 18px; line-height: 1; }\n' +
4850
4935
  '.dsh-music-qq-login-btn { width: 200px; padding: 10px 16px; font-size: 15px; }\n' +
4851
4936
  // 免责声明:居中块内的左对齐编号列表,阅读更清晰。
4852
4937
  '.dsh-music-qq-login-warn { display: flex; flex-direction: column; gap: 4px; width: 100%; max-width: 300px; margin-top: 4px; font-size: 12px; color: var(--dsw-alias-state-warn-primary, #d9a441); line-height: 1.5; text-align: left; box-sizing: border-box; max-height: 30vh; overflow-y: auto; }\n' +
@@ -4920,7 +5005,9 @@ window.__ModuleLoader__.load({
4920
5005
  '.dsh-music-panel-toast.err { background: var(--dsw-alias-state-error-primary, #e5534b); }\n' +
4921
5006
  '@keyframes dsh-music-toast-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.94); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }\n' +
4922
5007
  '.dsh-music-bar-btn.fav { color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
4923
- '.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n';
5008
+ '.dsh-music-bar-btn.fav:hover { color: var(--dsh-music-accent-fg, #fff); }\n' +
5009
+ '.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n' +
5010
+ '.dsh-music-bar-btn.fav.on:hover { color: var(--dsh-music-accent-fg, #fff); }\n';
4924
5011
 
4925
5012
  return module.exports;
4926
5013
  },
package/lib/qq.js CHANGED
@@ -31,16 +31,25 @@ async function fetchWithTimeout(url, opts = {}, timeoutMs = 12000) {
31
31
  }
32
32
  }
33
33
 
34
- export function hash33(s) { let h = 0; for (const c of s) h = ((h << 5) + c.charCodeAt(0)) >>> 0; return h & 0x7fffffff }
34
+ // hash33: QQ ptlogin Go 参考实现一致 —— Go 源码为 `h += (h<<5) + c`,等价于 `h = h*33 + c`。
35
+ // 注意:不是 `h*32 + c`(即缺少 +h 会算错 ptqrtoken,导致 ptqrlogin 返回 403)。
36
+ export function hash33(s) { let h = 0; for (const c of s) h = ((h * 33) + c.charCodeAt(0)) >>> 0; return h & 0x7fffffff }
35
37
 
36
38
  function responseCookies(res) {
37
39
  const out = {}
38
40
  const list = res.headers.getSetCookie ? res.headers.getSetCookie() : []
39
- for (const c of list) { const p = c.split(';')[0]; const i = p.indexOf('='); if (i < 0) continue; out[p.slice(0, i).trim()] = p.slice(i + 1).trim() }
41
+ for (const c of list) {
42
+ const p = c.split(';')[0]; const i = p.indexOf('='); if (i < 0) continue
43
+ const k = p.slice(0, i).trim(); const v = p.slice(i + 1).trim()
44
+ // Set-Cookie 里可能同时给「真实值」和「删除标记」(空值 + Expires=1970 / Max-Age=0)。
45
+ // 空值删除标记不应覆盖已有的真实值(否则会把 p_skey / p_uin 等清空)。
46
+ if (v === '' && out[k] != null && out[k] !== '') continue
47
+ out[k] = v
48
+ }
40
49
  return out
41
50
  }
42
51
  export function joinCookieMap(cookies) {
43
- return Object.keys(cookies).filter(k => k.trim() && cookies[k].trim()).sort().map(k => `${k}=${cookies[k]}`).join('; ')
52
+ return Object.keys(cookies).filter(k => k.trim() && (cookies[k] || '').trim()).sort().map(k => `${k}=${cookies[k]}`).join('; ')
44
53
  }
45
54
 
46
55
  // =====================================================================
@@ -88,11 +97,64 @@ export async function checkQRLogin(keyStr) {
88
97
  if (result.status !== 'success') return result
89
98
  let cookies = responseCookies(res)
90
99
  if (redirectURL) { try { cookies = await fetchQQRedirectCookies(redirectURL, cookies) } catch (e) { result.extra.redirect_error = e.message } }
100
+ // 关键:QQ 扫码成功拿到的是 QQ 网页 cookie(uin/superkey…),本身不含 QQ 音乐的 musickey。
101
+ // 参照 GitHub-ZC/wp_MusicApi util/login_qq_scan.js:需再走「graph.qq.com OAuth 换取 code →
102
+ // musicu.fcg QQConnectLogin.LoginServer/QQLogin 换 musickey」两步,才能拿到 qm_keyst/musickey。
103
+ // 否则 getMyPlaylists 等接口因缺 authst 报 code 80030(未登录)。
104
+ try { cookies = await exchangeQQMusicAuthst(cookies, redirectURL, result) } catch (e) { result.extra.authst_error = e.message }
91
105
  result.cookies = normalizeQQMusicCookies(cookies)
92
106
  result.cookie = joinCookieMap(result.cookies)
93
107
  return result
94
108
  }
95
109
 
110
+ // 参考 GitHub-ZC/wp_MusicApi util/login_qq_scan.js:QQ 扫码登录后补齐 QQ 音乐登录态(musickey)。
111
+ // 步骤:① 跟随 pt_auth_key 跳转收集 graph.qq.com cookie;② OAuth authorize 拿 code;
112
+ // ③ musicu.fcg QQConnectLogin.LoginServer/QQLogin 用 code 换 musickey。
113
+ async function exchangeQQMusicAuthst(cookies, redirectURL, result) {
114
+ // 直接从 cookies 对象读取(不经过 joinCookieMap,避免它过滤掉空值/特殊字符的 key)。
115
+ const getC = (k) => { const v = cookies && typeof cookies === 'object' ? cookies[k] : undefined; return v != null ? String(v).trim() : '' }
116
+ const graphCookie = joinCookieMap(cookies)
117
+ const gtkSrc = getC('p_skey') || getC('skey') || getC('qqmusic_key') || getC('p_lskey') || getC('lskey') || getC('qm_keyst') || getC('p_skey_forbid')
118
+ const g_tk = qqGtk(gtkSrc)
119
+ const OAuth = 'https://graph.qq.com/oauth2.0/authorize'
120
+ const OAuthRedirect = 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/'
121
+ const ui = 'DFEC5395-9E69-4D3E-96A6-300BB770874D'
122
+ const oauthParams = new URLSearchParams()
123
+ oauthParams.set('response_type', 'code'); oauthParams.set('client_id', '100497308')
124
+ oauthParams.set('redirect_uri', OAuthRedirect); oauthParams.set('scope', 'all'); oauthParams.set('state', 'state')
125
+ oauthParams.set('switch', ''); oauthParams.set('from_ptlogin', '1'); oauthParams.set('src', '1')
126
+ oauthParams.set('update_auth', '1'); oauthParams.set('openapi', '80901010_1030'); oauthParams.set('g_tk', String(g_tk))
127
+ oauthParams.set('auth_time', String(Date.now())); oauthParams.set('ui', ui)
128
+ const oauthRes = await fetchWithTimeout(OAuth, { method: 'POST', headers: { 'User-Agent': UA, 'Referer': 'https://xui.ptlogin2.qq.com/', 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': graphCookie }, body: oauthParams.toString(), redirect: 'manual' })
129
+ const location = oauthRes.headers.get('location') || ''
130
+ for (const [k, v] of Object.entries(responseCookies(oauthRes))) cookies[k] = v
131
+ let code = (location.match(/[?&]code=([^&]+)/) || [])[1] || (location.match(/[?&]code%3D([^&]+)/) || [])[1] || ''
132
+ if (!code && getC('qm_keyst') && getC('uin')) return cookies
133
+ if (!code) { result.extra.oauth_no_code = location.slice(0, 120); return cookies }
134
+ // ③ musicu.fcg QQLogin 换 musickey
135
+ const payload = JSON.stringify({ comm: { g_tk: 5381, platform: 'yqq', ct: 24, cv: 0 }, req: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param: { code } } })
136
+ const QQLoginCookie = joinCookieMap(cookies)
137
+ const endpoints = ['https://u.y.qq.com/cgi-bin/musicu.fcg', 'https://szu.y.qq.com/cgi-bin/musicu.fcg', 'https://shu.y.qq.com/cgi-bin/musicu.fcg']
138
+ let lastErr
139
+ for (const api of endpoints) {
140
+ const r = await fetchWithTimeout(api, { method: 'POST', headers: { 'User-Agent': UA, 'Referer': 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/', 'Origin': 'https://y.qq.com', 'Accept': '*/*', 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': QQLoginCookie }, body: payload })
141
+ const body = await r.text()
142
+ for (const [k, v] of Object.entries(responseCookies(r))) if (!cookies[k]) cookies[k] = v
143
+ if (r.status !== 200) { lastErr = new Error(`qq login http ${r.status}`); continue }
144
+ let parsed; try { parsed = parseJsonPreserveBigInt(body) } catch { lastErr = new Error('qq login json'); continue }
145
+ if (parsed.code !== 0 || parsed.req?.code !== 0) { lastErr = new Error(`qq login api error: ${parsed.req?.message || parsed.req?.msg || parsed.message || parsed.msg}`); continue }
146
+ // 从响应 data 提取 musickey(与微信类似)
147
+ const data = parsed.req?.data || {}
148
+ const dataCookies = wxLoginDataCookies(data)
149
+ for (const [k, v] of Object.entries(dataCookies)) if (!cookies[k]) cookies[k] = v
150
+ const mu = cookies.musickey || cookies.qqmusic_key || cookies.qm_keyst
151
+ if (mu) { cookies.qqmusic_key = mu; cookies.qm_keyst = mu; if (!cookies.uin) cookies.uin = cookies.musicid || cookies.uin }
152
+ return cookies
153
+ }
154
+ if (lastErr) result.extra.authst_api_error = lastErr.message
155
+ return cookies
156
+ }
157
+
96
158
  function parseQQQRCheck(raw) {
97
159
  const matches = raw.match(/'([^']*)'/g) || []
98
160
  const codes = matches.map(m => m.slice(1, -1))
@@ -304,10 +366,12 @@ function wxLoginDataCookies(data) {
304
366
 
305
367
  export function normalizeQQMusicCookies(cookies) {
306
368
  const r = { ...cookies }
307
- const first = (...xs) => xs.find(x => x && x.trim())
369
+ const first = (...xs) => { for (const x of xs) { if (x && String(x).trim()) return String(x).trim() } return '' }
308
370
  if (!r.uin) r.uin = first(r.ptui_loginuin, r.luin, r.pt2gguin, r.superuin, r.p_uin, r.musicid, r.userid, r.wxuin)
309
371
  if (!r.qqmusic_key) r.qqmusic_key = first(r.p_skey, r.skey, r.musickey)
310
372
  if (!r.qm_keyst) r.qm_keyst = r.qqmusic_key
373
+ // 避免给 cookie map 塞 undefined/null 值(joinCookieMap / 后续字符串处理会崩溃)
374
+ for (const k of Object.keys(r)) if (r[k] == null) delete r[k]
311
375
  return r
312
376
  }
313
377
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",