mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-08-04 22:45:48 +08:00
fix(player): 修复播放失败死循环,失败静默重试一次后直接跳下一首
日志实证的故障链:试听歌曲走备用解析 → 命中缓存的失效 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 复现/主动切歌重置)。
This commit is contained in:
@@ -484,7 +484,11 @@ class AudioService {
|
|||||||
console.error('Audio load error:', error?.code, error?.message);
|
console.error('Audio load error:', error?.code, error?.message);
|
||||||
this.emit('loaderror', { track, error });
|
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++;
|
retryCount++;
|
||||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||||
setTimeout(tryPlay, 1000 * retryCount);
|
setTimeout(tryPlay, 1000 * retryCount);
|
||||||
|
|||||||
@@ -281,7 +281,8 @@ export const playTrack = async (
|
|||||||
if (success) {
|
if (success) {
|
||||||
// 8. 元数据已在 4.5 步与播放并行加载,此处无需再处理
|
// 8. 元数据已在 4.5 步与播放并行加载,此处无需再处理
|
||||||
|
|
||||||
// 9. 播放成功
|
// 9. 播放成功,重置 URL 过期恢复计数
|
||||||
|
resetUrlExpiredRetry();
|
||||||
playerCore.playMusic.playLoading = false;
|
playerCore.playMusic.playLoading = false;
|
||||||
playerCore.playMusic.isFirstPlay = false;
|
playerCore.playMusic.isFirstPlay = false;
|
||||||
playbackRequestManager.completeRequest(requestId);
|
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<void> => {
|
||||||
|
// 本地歌曲 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 过期事件处理器
|
* 设置 URL 过期事件处理器
|
||||||
* 监听 audioService 的 url_expired 事件,自动重新获取 URL 并恢复播放
|
* 监听 audioService 的 url_expired 事件,自动重新获取 URL 并恢复播放。
|
||||||
|
* 恢复策略:清坏缓存 → 有限次重试 → 仍失败则自动切下一首
|
||||||
*/
|
*/
|
||||||
export const setupUrlExpiredHandler = (): void => {
|
export const setupUrlExpiredHandler = (): void => {
|
||||||
audioService.on('url_expired', async (expiredTrack: SongResult) => {
|
audioService.on('url_expired', async (expiredTrack: SongResult) => {
|
||||||
@@ -407,6 +440,42 @@ export const setupUrlExpiredHandler = (): void => {
|
|||||||
return;
|
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();
|
const currentSound = audioService.getCurrentSound();
|
||||||
let seekPosition = 0;
|
let seekPosition = 0;
|
||||||
@@ -430,31 +499,21 @@ export const setupUrlExpiredHandler = (): void => {
|
|||||||
playMusicUrl: undefined
|
playMusicUrl: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 静默重试:成功恢复位置,失败交由下一次 url_expired 事件按上限切歌
|
||||||
const success = await playTrack(trackToPlay, true);
|
const success = await playTrack(trackToPlay, true);
|
||||||
|
|
||||||
if (success) {
|
if (success && seekPosition > 0) {
|
||||||
// 恢复播放位置
|
// 延迟一小段时间确保音频已就绪
|
||||||
if (seekPosition > 0) {
|
setTimeout(() => {
|
||||||
// 延迟一小段时间确保音频已就绪
|
try {
|
||||||
setTimeout(() => {
|
audioService.seek(seekPosition);
|
||||||
try {
|
} catch {
|
||||||
audioService.seek(seekPosition);
|
console.warn('[playbackController] 恢复播放位置失败');
|
||||||
} catch {
|
}
|
||||||
console.warn('[playbackController] 恢复播放位置失败');
|
}, 300);
|
||||||
}
|
|
||||||
}, 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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[playbackController] 处理URL过期事件失败:', error);
|
console.error('[playbackController] 处理URL过期事件失败:', error);
|
||||||
message.error(i18n.global.t('player.resumeFailed'));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const playerCore = usePlayerCoreStore();
|
const playerCore = usePlayerCoreStore();
|
||||||
|
|
||||||
// 私人FM模式:忽略 playMode 与列表长度,直接拉取新的 FM 歌曲
|
// 私人FM模式:忽略 playMode 与列表长度,直接拉取新的 FM 歌曲
|
||||||
if (playerCore.isFmPlaying) {
|
if (playerCore.isFmPlaying) {
|
||||||
if (retryCount === 0) {
|
if (!fromFailover) {
|
||||||
cancelRetryTimer();
|
cancelRetryTimer();
|
||||||
consecutiveFailCount.value = 0;
|
consecutiveFailCount.value = 0;
|
||||||
}
|
}
|
||||||
@@ -455,8 +461,8 @@ export const usePlaylistStore = defineStore(
|
|||||||
|
|
||||||
if (playList.value.length === 0) return;
|
if (playList.value.length === 0) return;
|
||||||
|
|
||||||
// User-initiated (retryCount=0): reset state
|
// 用户主动切歌:重置失败状态
|
||||||
if (retryCount === 0) {
|
if (!fromFailover) {
|
||||||
cancelRetryTimer();
|
cancelRetryTimer();
|
||||||
consecutiveFailCount.value = 0;
|
consecutiveFailCount.value = 0;
|
||||||
}
|
}
|
||||||
@@ -494,14 +500,8 @@ export const usePlaylistStore = defineStore(
|
|||||||
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
|
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
|
||||||
const nextSong = { ...playList.value[nowPlayListIndex] };
|
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(
|
console.log(
|
||||||
`[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}, 重试: ${retryCount}/1`
|
`[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const { playTrack } = await import('@/services/playbackController');
|
const { playTrack } = await import('@/services/playbackController');
|
||||||
@@ -519,29 +519,21 @@ export const usePlaylistStore = defineStore(
|
|||||||
console.log(`[nextPlay] 播放成功,索引: ${nowPlayListIndex}`);
|
console.log(`[nextPlay] 播放成功,索引: ${nowPlayListIndex}`);
|
||||||
sleepTimerStore.handleSongChange();
|
sleepTimerStore.handleSongChange();
|
||||||
} else {
|
} else {
|
||||||
// Retry once, then skip to next
|
// 播放失败直接静默跳过到下一首(不原地重试——同曲的一次静默重试
|
||||||
if (retryCount < 1) {
|
// 由 url_expired 恢复处理器负责:清坏缓存后换新 URL)
|
||||||
console.log(`[nextPlay] 播放失败,1秒后重试`);
|
consecutiveFailCount.value++;
|
||||||
|
console.log(
|
||||||
|
`[nextPlay] 播放失败,直接跳过,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}`
|
||||||
|
);
|
||||||
|
if (playList.value.length > 1) {
|
||||||
|
playListIndex.value = nowPlayListIndex;
|
||||||
nextPlayRetryTimer = setTimeout(() => {
|
nextPlayRetryTimer = setTimeout(() => {
|
||||||
nextPlayRetryTimer = null;
|
nextPlayRetryTimer = null;
|
||||||
_nextPlay(retryCount + 1);
|
_nextPlay(false, true);
|
||||||
}, 1000);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
consecutiveFailCount.value++;
|
getMessage().error(i18n.global.t('player.playFailed'));
|
||||||
console.log(
|
playerCore.setIsPlay(false);
|
||||||
`[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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -553,7 +545,7 @@ export const usePlaylistStore = defineStore(
|
|||||||
|
|
||||||
/** 歌曲自然播放结束时调用,顺序模式最后一首会停止 */
|
/** 歌曲自然播放结束时调用,顺序模式最后一首会停止 */
|
||||||
const nextPlayOnEnd = () => {
|
const nextPlayOnEnd = () => {
|
||||||
_nextPlay(0, true);
|
_nextPlay(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _prevPlay = async () => {
|
const _prevPlay = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user