feat: 성인인증(연령제한) 영상 재생 지원 - yt-dlp 리졸버 경유 http 소스 재생 + 재생실패 자동 재시도
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user