From e198c5244a11a6b675341d1d9c127679a1025e11 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Fri, 11 Sep 2026 02:24:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B1=EC=9D=B8=EC=9D=B8=EC=A6=9D(?= =?UTF-8?q?=EC=97=B0=EB=A0=B9=EC=A0=9C=ED=95=9C)=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=20=EC=A7=80=EC=9B=90=20-=20yt-dlp=20?= =?UTF-8?q?=EB=A6=AC=EC=A1=B8=EB=B2=84=20=EA=B2=BD=EC=9C=A0=20http=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=9E=AC=EC=83=9D=20+=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=8B=A4=ED=8C=A8=20=EC=9E=90=EB=8F=99=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 ++++ bot/.dockerignore | 6 ++-- bot/src/classes/GuildPlayer.ts | 62 ++++++++++++++++++++++++++++++---- page/.dockerignore | 6 ++++ 4 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 page/.dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..00e9e7e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +.git +venv +**/node_modules +dist diff --git a/bot/.dockerignore b/bot/.dockerignore index 8da1acd..00e9e7e 100644 --- a/bot/.dockerignore +++ b/bot/.dockerignore @@ -1,6 +1,6 @@ __pycache__ *.pyc .git -venv/ -node_modules/ -dist/ +venv +**/node_modules +dist diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index e65bfc6..a2496fb 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -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 { + 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(); } diff --git a/page/.dockerignore b/page/.dockerignore new file mode 100644 index 0000000..00e9e7e --- /dev/null +++ b/page/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +.git +venv +**/node_modules +dist