feat: 성인인증(연령제한) 영상 재생 지원 - yt-dlp 리졸버 경유 http 소스 재생 + 재생실패 자동 재시도

This commit is contained in:
tkrmagid
2026-09-11 02:24:10 +09:00
parent adb3c59a85
commit e198c5244a
4 changed files with 70 additions and 10 deletions

View File

@@ -14,6 +14,8 @@ const idleEndTime = 1000 * 60 * 10;
// 재생 위치가 진행되지 않는지 감시하는 주기 / 멈춤으로 판단하는 임계값
const StallCheckIntervalMs = 1000 * 5;
const StallThresholdMs = 1000 * 20;
const MaxLoadFailRetry = 3;
const LoadFailRetryDelayMs = 2500;
type QueueTrack = Track & { userId: string; };
@@ -29,6 +31,7 @@ export class GuildPlayer {
private stallWatchdog: NodeJS.Timeout | undefined;
private lastPosition = 0;
private lastProgressAt = 0;
private loadFailCount = 0;
constructor(
public guild: Guild,
@@ -58,6 +61,26 @@ export class GuildPlayer {
try {
if (this.isDead) return;
if (data.reason === "replaced") return;
// 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
if (data.reason === "loadFailed" && this.queue[0]) {
this.loadFailCount += 1;
if (this.loadFailCount <= MaxLoadFailRetry) {
const cur = this.queue[0];
Logger.warn(`[GuildPlayer] 재생 실패, 같은 곡 재시도 ${this.loadFailCount}/${MaxLoadFailRetry}: "${cur.info.title}"`);
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
if (this.isDead) return;
if (this.queue[0]?.encoded === cur.encoded) {
const reEncoded = await this.resolvePlayable(cur);
await this.player.playTrack({ track: { encoded: reEncoded } });
return;
}
} else {
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
}
}
this.loadFailCount = 0;
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
this.lastPlayedTrack = this.queue.shift();
if (this.queue.length > 0) {
@@ -103,11 +126,9 @@ export class GuildPlayer {
this.player.on("exception", async (data) => {
try {
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. 다음 곡으로 넘어갑니다.`);
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 다음 곡을
// 진행시킨다. 여기서 stopTrack을 부르면 end가 중복 발생해 곡을 하나 더 건너뛰므로,
// 안내만 하고 errMsg의 5초 재시도 타이머는 끈다(retry=false).
await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다.", false);
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
} catch (err) {
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
}
@@ -169,8 +190,9 @@ export class GuildPlayer {
// 곡이 있었으면 이어서 재생
if (currentTrack) {
const reEncoded = await this.resolvePlayable(currentTrack);
await this.player.playTrack({
track: { encoded: currentTrack.encoded },
track: { encoded: reEncoded },
position: currentPosition,
paused: wasPaused,
});
@@ -227,10 +249,36 @@ export class GuildPlayer {
}
}
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
private async resolvePlayable(track: QueueTrack): Promise<string> {
try {
const info: any = (track as any).info ?? {};
const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return track.encoded;
const host = process.env.LAVALINK_HOST || "192.168.10.6";
const r = await fetch(`http://${host}:8779/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
if (!r.ok) return track.encoded;
const data = (await r.json()) as { url?: string };
if (!data.url) return track.encoded;
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
if (!node) return track.encoded;
const res = await node.rest.resolve(data.url);
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
return track.encoded;
} catch (e) {
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
return track.encoded;
}
}
public async playNext() {
const track = this.queue[0];
if (!track) return this.end();
await this.player.playTrack({ track: { encoded: track.encoded } });
const encoded = await this.resolvePlayable(track);
await this.player.playTrack({ track: { encoded } });
// 재생관련
this.setMsg();
}