feat: 성인인증(연령제한) 영상 재생 지원 - yt-dlp 리졸버 경유 http 소스 재생 + 재생실패 자동 재시도
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
venv
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
.git
|
.git
|
||||||
venv/
|
venv
|
||||||
node_modules/
|
**/node_modules
|
||||||
dist/
|
dist
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const idleEndTime = 1000 * 60 * 10;
|
|||||||
// 재생 위치가 진행되지 않는지 감시하는 주기 / 멈춤으로 판단하는 임계값
|
// 재생 위치가 진행되지 않는지 감시하는 주기 / 멈춤으로 판단하는 임계값
|
||||||
const StallCheckIntervalMs = 1000 * 5;
|
const StallCheckIntervalMs = 1000 * 5;
|
||||||
const StallThresholdMs = 1000 * 20;
|
const StallThresholdMs = 1000 * 20;
|
||||||
|
const MaxLoadFailRetry = 3;
|
||||||
|
const LoadFailRetryDelayMs = 2500;
|
||||||
|
|
||||||
type QueueTrack = Track & { userId: string; };
|
type QueueTrack = Track & { userId: string; };
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ export class GuildPlayer {
|
|||||||
private stallWatchdog: NodeJS.Timeout | undefined;
|
private stallWatchdog: NodeJS.Timeout | undefined;
|
||||||
private lastPosition = 0;
|
private lastPosition = 0;
|
||||||
private lastProgressAt = 0;
|
private lastProgressAt = 0;
|
||||||
|
private loadFailCount = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public guild: Guild,
|
public guild: Guild,
|
||||||
@@ -58,6 +61,26 @@ export class GuildPlayer {
|
|||||||
try {
|
try {
|
||||||
if (this.isDead) return;
|
if (this.isDead) return;
|
||||||
if (data.reason === "replaced") 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에 저장
|
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
|
||||||
this.lastPlayedTrack = this.queue.shift();
|
this.lastPlayedTrack = this.queue.shift();
|
||||||
if (this.queue.length > 0) {
|
if (this.queue.length > 0) {
|
||||||
@@ -103,11 +126,9 @@ export class GuildPlayer {
|
|||||||
|
|
||||||
this.player.on("exception", async (data) => {
|
this.player.on("exception", async (data) => {
|
||||||
try {
|
try {
|
||||||
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. 다음 곡으로 넘어갑니다.`);
|
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
|
||||||
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 다음 곡을
|
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
|
||||||
// 진행시킨다. 여기서 stopTrack을 부르면 end가 중복 발생해 곡을 하나 더 건너뛰므로,
|
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
|
||||||
// 안내만 하고 errMsg의 5초 재시도 타이머는 끈다(retry=false).
|
|
||||||
await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다.", false);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
|
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
|
||||||
}
|
}
|
||||||
@@ -169,8 +190,9 @@ export class GuildPlayer {
|
|||||||
|
|
||||||
// 곡이 있었으면 이어서 재생
|
// 곡이 있었으면 이어서 재생
|
||||||
if (currentTrack) {
|
if (currentTrack) {
|
||||||
|
const reEncoded = await this.resolvePlayable(currentTrack);
|
||||||
await this.player.playTrack({
|
await this.player.playTrack({
|
||||||
track: { encoded: currentTrack.encoded },
|
track: { encoded: reEncoded },
|
||||||
position: currentPosition,
|
position: currentPosition,
|
||||||
paused: wasPaused,
|
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() {
|
public async playNext() {
|
||||||
const track = this.queue[0];
|
const track = this.queue[0];
|
||||||
if (!track) return this.end();
|
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();
|
this.setMsg();
|
||||||
}
|
}
|
||||||
|
|||||||
6
page/.dockerignore
Normal file
6
page/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
venv
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
Reference in New Issue
Block a user