fix: 재생 멈춤(stall) 시 다음 곡으로 자동 진행

노래가 2/3 지점 등에서 버퍼링/멈춘 뒤 다음 곡으로 넘어가지 않고 프리징되던 문제 해결.
- stuck 이벤트: 기존 errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수
  있었음. stopTrack으로 end 이벤트를 유도해 실제 다음 곡으로 넘김.
- 재생 위치 멈춤 감시(watchdog) 추가: TrackStuckEvent가 안 뜨는 느린 버퍼링
  상황에서도 position이 20초간 진행 없으면 자동 스킵. start에서 시작, clearAllTimers에서 정리.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
tkrmagid
2026-08-17 21:55:05 +09:00
parent 5a81ce7416
commit 6d79655181

View File

@@ -11,6 +11,9 @@ import { Logger } from "../utils/Logger";
const DelayAfterErrMs = 1000 * 5; const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10; const idleEndTime = 1000 * 60 * 10;
// 재생 위치가 진행되지 않는지 감시하는 주기 / 멈춤으로 판단하는 임계값
const StallCheckIntervalMs = 1000 * 5;
const StallThresholdMs = 1000 * 20;
type QueueTrack = Track & { userId: string; }; type QueueTrack = Track & { userId: string; };
@@ -23,6 +26,9 @@ export class GuildPlayer {
private errorTimer: NodeJS.Timeout | undefined; private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined; private endTimer: NodeJS.Timeout | undefined;
private closedTimer: NodeJS.Timeout | undefined; private closedTimer: NodeJS.Timeout | undefined;
private stallWatchdog: NodeJS.Timeout | undefined;
private lastPosition = 0;
private lastProgressAt = 0;
constructor( constructor(
public guild: Guild, public guild: Guild,
@@ -42,6 +48,8 @@ export class GuildPlayer {
clearTimeout(this.endTimer); clearTimeout(this.endTimer);
this.endTimer = undefined; this.endTimer = undefined;
} }
// 새 곡이 시작되면 재생 위치 멈춤 감시 시작
this.startStallWatchdog();
Redis?.publishState("player_update", { Redis?.publishState("player_update", {
guildId: this.guild.id, guildId: this.guild.id,
}); });
@@ -103,8 +111,10 @@ export class GuildPlayer {
}); });
this.player.on("stuck", async (data) => { this.player.on("stuck", async (data) => {
try { try {
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`); Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과. 다음 곡으로 넘어갑니다.`);
await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다."); // errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수 있으므로,
// stopTrack으로 end 이벤트를 유도해 실제로 다음 곡으로 넘긴다.
await this.player.stopTrack();
} catch (err) { } catch (err) {
Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`); Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`);
} }
@@ -375,6 +385,52 @@ export class GuildPlayer {
} }
} }
/**
* 재생 위치가 일정 시간 동안 전혀 진행되지 않으면(=버퍼링/멈춤) 다음 곡으로 넘긴다.
* Lavalink가 TrackStuckEvent를 못 던지는 트리클 스트리밍(느린 버퍼링) 상황까지 커버하는 백스탑.
*/
private startStallWatchdog() {
this.stopStallWatchdog();
this.lastPosition = this.position;
this.lastProgressAt = Date.now();
this.stallWatchdog = setInterval(() => {
if (this.isDead) {
this.stopStallWatchdog();
return;
}
// 재생 중이 아니거나 일시정지면 정상 상태 → 진행시각 리셋
if (!this.isPlaying || this.isPaused) {
this.lastPosition = this.player.position;
this.lastProgressAt = Date.now();
return;
}
const pos = this.player.position;
if (pos > this.lastPosition + 500) {
// 정상적으로 재생 위치가 진행됨
this.lastPosition = pos;
this.lastProgressAt = Date.now();
return;
}
// 재생 위치가 StallThresholdMs 동안 전혀 진행되지 않음 → 멈춤으로 판단하고 스킵
if (Date.now() - this.lastProgressAt >= StallThresholdMs) {
Logger.error(`[GuildPlayer] 재생 위치가 ${StallThresholdMs}ms 동안 진행되지 않음(멈춤 감지). 다음 곡으로 넘어갑니다. (position=${pos}ms)`);
this.lastPosition = pos;
this.lastProgressAt = Date.now();
// stopTrack → end 이벤트 → 다음 곡 진행
this.player.stopTrack().catch((err) => {
Logger.error(`[GuildPlayer] 멈춤 감지 후 stopTrack 에러: ${String(err)}`);
});
}
}, StallCheckIntervalMs);
}
private stopStallWatchdog() {
if (this.stallWatchdog !== undefined) {
clearInterval(this.stallWatchdog);
this.stallWatchdog = undefined;
}
}
private clearAllTimers() { private clearAllTimers() {
if (this.errorTimer !== undefined) { if (this.errorTimer !== undefined) {
clearTimeout(this.errorTimer); clearTimeout(this.errorTimer);
@@ -388,6 +444,7 @@ export class GuildPlayer {
clearTimeout(this.closedTimer); clearTimeout(this.closedTimer);
this.closedTimer = undefined; this.closedTimer = undefined;
} }
this.stopStallWatchdog();
} }
public end() { public end() {