From 9c006578424e3cf4761128a1e833c46b5edab493 Mon Sep 17 00:00:00 2001 From: alger Date: Sun, 5 Jul 2026 16:39:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=A4=B1=E8=B4=A5=E6=AD=BB=E5=BE=AA=E7=8E=AF=EF=BC=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=9D=99=E9=BB=98=E9=87=8D=E8=AF=95=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=90=8E=E7=9B=B4=E6=8E=A5=E8=B7=B3=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E9=A6=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日志实证的故障链:试听歌曲走备用解析 → 命中缓存的失效 URL → Format error → url_expired → 原样重放 → 再次命中同一坏缓存(30 分钟 TTL 内 永远命中)→ 死循环,最终静默停止且不切歌。 修复: - url_expired 恢复前清除该歌曲的解析 URL 缓存(clearMusicCache 此前 只有手动重新解析按钮调用),重试真正拿到新 URL - 恢复为静默重试且仅 1 次(无任何用户提示),仍失败直接切下一首 (单曲列表停止/FM 拉新歌),playTrack 成功时重置计数 - 恢复前校验当前歌曲未变更,避免与 nextPlay 的失败跳歌并发争抢 - nextPlay 播放失败不再原地重试,直接静默跳过下一首 - 修复连续失败上限从未生效的存量 bug:跳歌链递归重置了 consecutiveFailCount,全列表失效时会无限跳歌;现失败链不重置计数, 连续 5 次失败正确停止 - audioService 对 MEDIA_ERR_SRC_NOT_SUPPORTED(4) 不再用同一 URL 重试 (源本身无效,重试纯耗时),网络类错误保留原重试 测试:16 项全过——恢复状态机 8 场景、错误分类 2 项、真实 HTTP 服务 集成 2 项(坏缓存死循环复现 + 清缓存换新 URL 恢复)、跳歌链 4 场景 (直接跳过/全失败恰好 5 次后停止/旧计数重置 bug 复现/主动切歌重置)。 --- src/renderer/services/audioService.ts | 6 +- src/renderer/services/playbackController.ts | 103 +++++++++++++++----- src/renderer/store/modules/playlist.ts | 56 +++++------ 3 files changed, 110 insertions(+), 55 deletions(-) diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index 8ebee75..77d80d1 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -484,7 +484,11 @@ class AudioService { console.error('Audio load error:', error?.code, error?.message); this.emit('loaderror', { track, error }); - if (retryCount < maxRetries) { + // MEDIA_ERR_SRC_NOT_SUPPORTED(4):源本身无效(URL 已失效/返回了 + // 非音频内容),用同一 URL 重试毫无意义,直接走 url_expired 换新 URL + const isSrcNotSupported = error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED; + + if (!isSrcNotSupported && retryCount < maxRetries) { retryCount++; console.log(`Retrying playback (${retryCount}/${maxRetries})...`); setTimeout(tryPlay, 1000 * retryCount); diff --git a/src/renderer/services/playbackController.ts b/src/renderer/services/playbackController.ts index 5739cdc..25720d2 100644 --- a/src/renderer/services/playbackController.ts +++ b/src/renderer/services/playbackController.ts @@ -281,7 +281,8 @@ export const playTrack = async ( if (success) { // 8. 元数据已在 4.5 步与播放并行加载,此处无需再处理 - // 9. 播放成功 + // 9. 播放成功,重置 URL 过期恢复计数 + resetUrlExpiredRetry(); playerCore.playMusic.playLoading = false; playerCore.playMusic.isFirstPlay = false; playbackRequestManager.completeRequest(requestId); @@ -389,9 +390,41 @@ export const reparseCurrentSong = async ( } }; +/** + * URL 过期恢复:同一首歌仅静默重试一次。 + * 重试仍失败说明重新解析也救不回来(音源无资源/文件损坏),继续原地重试 + * 只会死循环,应当直接切到下一首 + */ +const MAX_URL_EXPIRED_RETRIES = 1; +let urlExpiredRetrySongId: string | number | null = null; +let urlExpiredRetryCount = 0; + +/** playTrack 成功后重置恢复计数(导出给 playTrack 内部使用) */ +const resetUrlExpiredRetry = (): void => { + urlExpiredRetrySongId = null; + urlExpiredRetryCount = 0; +}; + +/** + * 清除歌曲的解析 URL 缓存。 + * 播放失败大概率是缓存的解析 URL 已失效或内容损坏(如返回 HTML 触发 + * Format error),不清缓存的话重新解析会再次命中同一个坏 URL 形成死循环 + */ +const clearParsedUrlCache = async (songId: string | number): Promise => { + // 本地歌曲 id 为 hex 字符串,无解析缓存可清 + if (!/^\d+$/.test(String(songId))) return; + try { + const { CacheManager } = await import('@/api/musicParser'); + await CacheManager.clearMusicCache(Number(songId)); + } catch (error) { + console.warn('[playbackController] 清除URL缓存失败:', error); + } +}; + /** * 设置 URL 过期事件处理器 - * 监听 audioService 的 url_expired 事件,自动重新获取 URL 并恢复播放 + * 监听 audioService 的 url_expired 事件,自动重新获取 URL 并恢复播放。 + * 恢复策略:清坏缓存 → 有限次重试 → 仍失败则自动切下一首 */ export const setupUrlExpiredHandler = (): void => { audioService.on('url_expired', async (expiredTrack: SongResult) => { @@ -407,6 +440,42 @@ export const setupUrlExpiredHandler = (): void => { return; } + // 当前歌曲已变更(例如 nextPlay 的失败跳歌已接管),不再恢复旧歌, + // 避免两个恢复驱动并发争抢 generation + if (playerCore.playMusic?.id !== expiredTrack.id) { + console.log('[playbackController] 当前歌曲已变更,跳过URL过期处理'); + return; + } + + // 统计同一首歌的连续恢复次数 + if (urlExpiredRetrySongId === expiredTrack.id) { + urlExpiredRetryCount++; + } else { + urlExpiredRetrySongId = expiredTrack.id; + urlExpiredRetryCount = 1; + } + + // 先清掉可能已损坏的解析 URL 缓存,让重试真正拿到新 URL + await clearParsedUrlCache(expiredTrack.id); + + // 超过重试上限:不再原地循环,静默切下一首 + if (urlExpiredRetryCount > MAX_URL_EXPIRED_RETRIES) { + console.warn(`[playbackController] ${expiredTrack.name} 恢复重试失败,切换下一首`); + resetUrlExpiredRetry(); + try { + const playlistStore = await getPlaylistStore(); + if (playlistStore.playList.length > 1 || playerCore.isFmPlaying) { + playlistStore.nextPlay(); + } else { + playerCore.setIsPlay(false); + } + } catch (error) { + console.error('[playbackController] 切换下一首失败:', error); + playerCore.setIsPlay(false); + } + return; + } + // 保存当前播放位置 const currentSound = audioService.getCurrentSound(); let seekPosition = 0; @@ -430,31 +499,21 @@ export const setupUrlExpiredHandler = (): void => { playMusicUrl: undefined }; + // 静默重试:成功恢复位置,失败交由下一次 url_expired 事件按上限切歌 const success = await playTrack(trackToPlay, true); - if (success) { - // 恢复播放位置 - if (seekPosition > 0) { - // 延迟一小段时间确保音频已就绪 - setTimeout(() => { - try { - audioService.seek(seekPosition); - } catch { - console.warn('[playbackController] 恢复播放位置失败'); - } - }, 300); - } - message.success(i18n.global.t('player.autoResumed')); - } else { - // 检查歌曲是否仍然是当前歌曲 - const currentPlayerCore = await getPlayerCoreStore(); - if (currentPlayerCore.playMusic?.id === expiredTrack.id) { - message.error(i18n.global.t('player.resumeFailed')); - } + if (success && seekPosition > 0) { + // 延迟一小段时间确保音频已就绪 + setTimeout(() => { + try { + audioService.seek(seekPosition); + } catch { + console.warn('[playbackController] 恢复播放位置失败'); + } + }, 300); } } catch (error) { console.error('[playbackController] 处理URL过期事件失败:', error); - message.error(i18n.global.t('player.resumeFailed')); } }); }; diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index 54ed229..a1f9232 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -439,13 +439,19 @@ export const usePlaylistStore = defineStore( } }; - const _nextPlay = async (retryCount: number = 0, autoEnd: boolean = false) => { + /** + * @param autoEnd 是否由歌曲自然播放结束触发 + * @param fromFailover 是否由"播放失败跳歌"链触发。 + * 失败链不能重置 consecutiveFailCount,否则连续失败上限永远不会触发, + * 全列表都无法播放时会无限跳歌 + */ + const _nextPlay = async (autoEnd: boolean = false, fromFailover: boolean = false) => { try { const playerCore = usePlayerCoreStore(); // 私人FM模式:忽略 playMode 与列表长度,直接拉取新的 FM 歌曲 if (playerCore.isFmPlaying) { - if (retryCount === 0) { + if (!fromFailover) { cancelRetryTimer(); consecutiveFailCount.value = 0; } @@ -455,8 +461,8 @@ export const usePlaylistStore = defineStore( if (playList.value.length === 0) return; - // User-initiated (retryCount=0): reset state - if (retryCount === 0) { + // 用户主动切歌:重置失败状态 + if (!fromFailover) { cancelRetryTimer(); consecutiveFailCount.value = 0; } @@ -494,14 +500,8 @@ export const usePlaylistStore = defineStore( const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length; const nextSong = { ...playList.value[nowPlayListIndex] }; - // Force refresh URL on retry - if (retryCount > 0 && !nextSong.playMusicUrl?.startsWith('local://')) { - nextSong.playMusicUrl = undefined; - nextSong.expiredAt = undefined; - } - console.log( - `[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}, 重试: ${retryCount}/1` + `[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}` ); const { playTrack } = await import('@/services/playbackController'); @@ -519,29 +519,21 @@ export const usePlaylistStore = defineStore( console.log(`[nextPlay] 播放成功,索引: ${nowPlayListIndex}`); sleepTimerStore.handleSongChange(); } else { - // Retry once, then skip to next - if (retryCount < 1) { - console.log(`[nextPlay] 播放失败,1秒后重试`); + // 播放失败直接静默跳过到下一首(不原地重试——同曲的一次静默重试 + // 由 url_expired 恢复处理器负责:清坏缓存后换新 URL) + consecutiveFailCount.value++; + console.log( + `[nextPlay] 播放失败,直接跳过,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` + ); + if (playList.value.length > 1) { + playListIndex.value = nowPlayListIndex; nextPlayRetryTimer = setTimeout(() => { nextPlayRetryTimer = null; - _nextPlay(retryCount + 1); - }, 1000); + _nextPlay(false, true); + }, 500); } else { - consecutiveFailCount.value++; - console.log( - `[nextPlay] 重试用尽,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` - ); - if (playList.value.length > 1) { - playListIndex.value = nowPlayListIndex; - getMessage().warning(i18n.global.t('player.parseFailedPlayNext')); - nextPlayRetryTimer = setTimeout(() => { - nextPlayRetryTimer = null; - _nextPlay(0); - }, 500); - } else { - getMessage().error(i18n.global.t('player.playFailed')); - playerCore.setIsPlay(false); - } + getMessage().error(i18n.global.t('player.playFailed')); + playerCore.setIsPlay(false); } } } catch (error) { @@ -553,7 +545,7 @@ export const usePlaylistStore = defineStore( /** 歌曲自然播放结束时调用,顺序模式最后一首会停止 */ const nextPlayOnEnd = () => { - _nextPlay(0, true); + _nextPlay(true); }; const _prevPlay = async () => {