| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 생능출판
- Noam Nisan
- 알고리즘
- (주)책만
- 이득우의 게임수학
- unity6
- 잡생각 정리글
- 입출력과 사칙연산
- 이득우
- 김진홍 옮김
- 밑바닥부터 만드는 컴퓨팅 시스템 2판
- C
- 데이터 통신과 컴퓨터 네트워크
- C++
- JavaScript
- 전공자를 위한 C언어 프로그래밍
- 게임 수학
- Shimon Schocken
- 백준
- 일기
- BOJ
- https://insightbook.co.kr/
- C#
- hanbit.co.kr
- The Elements of Computing Systems 2/E
- 주우석
- HANBIT Academy
- 메타버스
- booksr.co.kr
- 박기현
- Today
- Total
cyphen156
디스코드 봇 - 주크박스 봇 만들기 #5 복호화 → 암호화 재생 → 스트림 안정화 본문
공부하긴 싫고 프로젝트도 조그맣게 하고싶어서 하는 프로젝트
이번 글은 공식 문서를 참고하여 작성됩니다.
Creating slash commands | discord.js Guide
discord.js Guide
Imagine a guide... that explores the many possibilities for your discord.js bot.
discordjs.guide
JukeBox-Bot/
├─ commands/
│ ├─ utility/ (join, leave, ping, test, user, server)
│ ├─ Player/ (play, pause, resume, skip, stop)
│ └─ Queue/ (add, clear, queue, remove, shuffle)
├─ components/
│ ├─ youtube/youtube.js # 검색/URL 해석(play-dl 사용) — 재생 제외
│ ├─ player.js # 길드별 AudioPlayer 런타임
│ ├─ queue.js # 길드별 대기열
│ ├─ state.js # 길드별 FSM
│ └─ test.js
├─ jukebox.js # 퍼사드(Queue/Player/State 단일 API)
├─ bot.js # 클라이언트, 이벤트 바인딩
├─ deploy-commands.js # 슬래시 명령 배포(로컬/테스트)
├─ deploy-global.js # 슬래시 명령 배포(글로벌)
├─ index.js # 엔트리
├─ package.json
└─ config.json # token, clientId, guildId
사전 준비(필수 패키지)
- 디스코드 보이스: @discordjs/voice
- Opus 인코더: @discordjs/opus (권장) 또는 opusscript
- RTP 암호화: sodium-native (권장) 또는 libsodium-wrappers
- 미디어 체인: yt-dlp(바이너리), ffmpeg(바이너리)
- Windows라면 bin/yt-dlp.exe를 리포에 두고, FFmpeg는 ffmpeg-static 사용 가능
# 핵심
yarn add @discordjs/voice
yarn add @discordjs/opus sodium-native # 권장 조합
# 대체: yarn add opusscript libsodium-wrappers
# ffmpeg 바이너리(편의)
yarn add ffmpeg-static
# yt-dlp는 바이너리 권장(윈도우: bin/yt-dlp.exe)
# 또는 PATH에 yt-dlp 설치
재생을 고치기 전에 play의 기능을 확장하자
command play가 지원해야 하는 기능은 다음과 같다.
- 인자가 없을 때 : 큐에서 맨 처음 곡 재생 시도
- 인자가 있을 때 : 큐에 해당 곡이 있는지 확인 시도 후 현재 재생 중인 곡을 멈추고 즉시 재생
- 곡이 있을 때 : 해당 곡을 큐의 처음으로 옮겨서 재생
- 곡이 없을 때 : 유튜브에서 검색 후 해당 곡을 재생
commends/play/play.js
// commands/Play/play.js
const { SlashCommandBuilder } = require('discord.js');
const Jukebox = require('../../jukebox');
module.exports =
{
data: new SlashCommandBuilder()
.setName('play')
.setDescription('노래 재생 (검색어/URL)')
.addStringOption(opt =>
opt.setName('query')
.setDescription('검색어, URL 또는 큐 인덱스')
.setRequired(false)
),
async execute(interaction)
{
const gid = interaction.guildId;
const query = interaction.options.getString('query');
const requestedBy = interaction.user.tag;
await interaction.deferReply();
try
{
const result = await Jukebox.play(gid, query || null, requestedBy);
if (!result.ok)
{
await interaction.editReply(`📭 대기열이 비어있습니다.`);
return;
}
const meta = result.meta;
if (meta)
{
await interaction.editReply(`▶️ **${meta.title}** (by ${requestedBy}) 재생`);
}
else
{
await interaction.editReply(`▶️ 재생 상태: ${result.code}`);
}
}
catch (err)
{
console.error('[play cmd]', err);
await interaction.editReply('❌ 재생 중 오류 발생');
}
}
};
components/player.js-add, play
async function add(gid, input, requestedBy)
{
const meta = await resolveVideo(input);
Queue.push(gid, {
url: meta.url,
title: meta.title,
videoId: meta.videoId,
requestedBy
});
return meta;
}
async function play(gid, input = null, requestedBy = null)
{
if (input === null) {
const track = await Player.playNext(gid);
return track
? { ok: true, code: 'PLAY_FROM_QUEUE', meta: track }
: { ok: false, code: 'QUEUE_EMPTY' };
}
const meta = await resolveVideo(input);
// 큐에서 같은 videoId 있으면 꺼내기
const existingIndex = Queue.snapshot(gid)
.findIndex(t => t.videoId === meta.videoId);
if (existingIndex >= 0)
{
const track = Queue.remove(gid, existingIndex);
Queue.get(gid).unshift(track);
}
else
{
Queue.get(gid).unshift({
url: meta.url,
title: meta.title,
videoId: meta.videoId,
requestedBy
});
}
console.log("before skip:", Queue.snapshot(gid));
const track = await Player.skip(gid); // 반드시 await
console.log("after skip:", track);
return { ok: true, code: 'PLAY_BY_INPUT', meta: track };
}
components/player.js
// components/player.js
const {
createAudioPlayer, createAudioResource,
NoSubscriberBehavior, AudioPlayerStatus,
getVoiceConnection, VoiceConnectionStatus, entersState, StreamType
} = require('@discordjs/voice');
const { spawn } = require('child_process');
const Queue = require('./queue');
const State = require('./state');
const PLAYERS = new Map();
function ensurePlayer(gid)
{
if (PLAYERS.has(gid))
{
return PLAYERS.get(gid);
}
const player = createAudioPlayer({
behaviors: { noSubscriber: NoSubscriberBehavior.Pause }
});
player.on(AudioPlayerStatus.Idle, () =>
{
State.get(gid).apply(State.Event.END);
void playNext(gid);
});
player.on('error', (err) =>
{
console.error(`[player ${gid}] error:`, err);
State.get(gid).apply(State.Event.FAIL);
});
PLAYERS.set(gid, player);
return player;
}
function makeFfmpegStream(url)
{
const ytdlp = spawn('yt-dlp', ['-f', 'bestaudio/best', '-o', '-', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] });
const ffmpeg = spawn('ffmpeg',
['-loglevel', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'],
{ stdio: ['pipe', 'pipe', 'pipe'] });
ytdlp.stdout.pipe(ffmpeg.stdin);
return ffmpeg;
}
// player-play (큐에서 곡 꺼내 실행)
async function playNext(gid)
{
const track = Queue.getNext(gid);
if (!track)
{
State.get(gid).apply(State.Event.END);
return null;
}
const conn = getVoiceConnection(gid);
if (!conn)
{
throw new Error('VOICE_NOT_CONNECTED');
}
await entersState(conn, VoiceConnectionStatus.Ready, 15_000);
State.get(gid).apply(State.Event.LOAD);
const player = ensurePlayer(gid);
// const ffmpeg = makeFfmpegStream(track.url);
// const resource = createAudioResource(ffmpeg.stdout, { inputType: StreamType.Arbitrary });
conn.subscribe(player);
// player.play(resource);
State.get(gid).apply(State.Event.START);
return track;
}
// player-pause
function pause(gid)
{
const player = PLAYERS.get(gid);
if (player?.pause())
{
State.get(gid).apply(State.Event.PAUSE);
}
}
// player-resume
function resume(gid)
{
const player = PLAYERS.get(gid);
if (player?.unpause())
{
State.get(gid).apply(State.Event.RESUME);
}
}
// player-skip
async function skip(gid)
{
const player = PLAYERS.get(gid);
if (player)
{
player.stop(true);
State.get(gid).apply(State.Event.SKIP);
}
return await playNext(gid);
}
// player-stop
function stop(gid)
{
const player = PLAYERS.get(gid);
if (player)
{
player.stop(true);
}
Queue.clear(gid);
State.get(gid).apply(State.Event.STOP);
}
module.exports = { playNext, pause, resume, skip, stop };
이제 playNext에서 음성 재생을 지원하기 위해 player.js를 수정한다.
디스코드 음성 스트림 파이프라인
1. yt-dlp
→ 2.FFmpeg
→ 3.@discordjs/voice
- yt-dlp가 유튜브에서 최적 오디오를 “표준 출력(stdout)”으로 쏜다.
- FFmpeg가 그 바이트 스트림을 받아 디스코드 음성용 PCM(s16le, 48kHz, 스테레오)으로 변환한다.
- @discordjs/voice가 변환된 PCM 스트림을 오디오 리소스로 래핑해서 보이스 채널에 송출한다.
처음 접근: play-dl로 “바로” 스트리밍
처음엔 구현이 제일 간단해 보이는 play-dl을 썼다.
의도는 이거였다:
- /play 들어오면 URL(혹은 검색어→URL)만 확보
- playdl.stream(url)로 오디오 스트림 뽑기
- createAudioResource(stream.stream, { inputType: stream.type })로 리소스를 만든 후, player.play를 통해 재생
async function makeAudioResource(url)
{
if (typeof url !== 'string' || !url.startsWith('http'))
{
throw new Error('INVALID_TRACK_URL');
}
const Stream = await playdl.stream(url, { quality: 2 });
console.log("stream : " + Stream);
return createAudioResource(Stream.stream, {
inputType: Stream.type,
});
}
// playNext()
// player-play (큐에서 곡 꺼내 실행)
async function playNext(gid)
{
const track = Queue.getNext(gid);
if (!track)
{
State.get(gid).apply(State.Event.END);
return null;
}
const conn = getVoiceConnection(gid);
if (!conn)
{
throw new Error('VOICE_NOT_CONNECTED');
}
await entersState(conn, VoiceConnectionStatus.Ready, 15_000);
State.get(gid).apply(State.Event.LOAD);
const player = ensurePlayer(gid);
// const ffmpeg = makeFfmpegStream(track.url);
// const resource = createAudioResource(ffmpeg.stdout, { inputType: StreamType.Arbitrary });
const resource = await makeAudioResource(track.url);
console.log(9999999999999);
conn.subscribe(player);
player.play(resource);
State.get(gid).apply(State.Event.START);
return track;
}
왜 실패했나 (에러/현상 요약)
- ERR_INVALID_URL (input: 'undefined')
→ play-dl 내부가 YouTube의 streamingData 포맷에서 직접 URL이 없는 항목(서명/토큰 필요)을 잡으면, 최종 단계에 new URL(final[0].url)에서 터진다.- 대응 :라이브러리 폴더 까봐서 video_info_url( decipher_info ) 데이터 포맷으로 직접 파싱
=> 403 Forbidden 발생
- 대응 :라이브러리 폴더 까봐서 video_info_url( decipher_info ) 데이터 포맷으로 직접 파싱
- 403 Forbidden (content-length)
→ stream_from_info 경로에선 내부가 googlevideo에 HEAD를 쏘며 길이를 구한다.
지역/쿠키/UA 조합에 따라 403을 쉽게 맞는다.
내 환경에선 decipher_info를 거쳐도 contentLength 획득 단계에서 막혔다.
=> 너무 긴 url길이가 튀어나옴 - 결론 : 서명/쿠키/지역/안티봇 변수에 민감해서, 모든 시도가 막힘
해결 방안_1
스트리밍 데이터를 직접 복호화하기
- video_info → decipher_info로 포맷을 직접 복호화하고,
- audio/webm; codecs=opus (251/250/249) → audio/mp4(140) → muxed mp4(18) 순으로 수동 선택해서
- stream_from_info()에 딱 하나의 포맷만 넣어보는 식으로 우회 시도
1. 프로젝트 루트에 bin/yt-dlp.exe 저장
yt-dlp/yt-dlp: A feature-rich command-line audio/video downloader
GitHub - yt-dlp/yt-dlp: A feature-rich command-line audio/video downloader
A feature-rich command-line audio/video downloader - yt-dlp/yt-dlp
github.com
2. ffmpeg라이브러리 설치
yarn add ffmpeg
그리고 player.js에 많은 변화가 생겻다.
대표적인게 process를 사용해서 병렬처리를 지원해야 햇다는 것이다.
player.js
// components/player.js
const {
createAudioPlayer, createAudioResource,
NoSubscriberBehavior, AudioPlayerStatus,
getVoiceConnection, VoiceConnectionStatus, entersState, StreamType
} = require('@discordjs/voice');
const { spawn } = require('child_process');
const path = require('path');
const ffmpegPath = require('ffmpeg-static')
const playdl = require('play-dl');
const Queue = require('./queue');
const State = require('./state');
const PLAYERS = new Map();
const PROC = new Map();
const ytdlpPath = process.env.YTDLP_PATH
|| path.resolve(__dirname, '../bin/yt-dlp.exe');
function killProc(gid)
{
const p = PROC.get(gid);
if (!p) return;
try { p.ytdlp?.kill('SIGKILL'); } catch {}
try { p.ffmpeg?.kill('SIGKILL'); } catch {}
PROC.delete(gid);
}
function toWatchUrl(input)
{
if (typeof input !== 'string') return '';
const m = /[?&]v=([^&]+)/.exec(input);
return m ? `https://www.youtube.com/watch?v=${m[1]}` : input;
}
function makePipe(url)
{
// yt-dlp: 오디오만 stdout으로
const y = spawn(ytdlpPath,
[
'-f', 'bestaudio[ext=webm]/bestaudio/best',
'--no-playlist',
'-q',
'-o', '-',
url
],
{ stdio: ['ignore', 'pipe', 'pipe'] });
// ffmpeg: Ogg/Opus 로 인코딩 (디스코드가 바로 전송 가능)
const f = spawn(ffmpegPath,
[
'-loglevel', 'error',
'-i', 'pipe:0',
'-vn',
'-acodec', 'libopus',
'-ar', '48000',
'-ac', '2',
'-b:a', '128k',
'-f', 'ogg',
'pipe:1'
],
{ stdio: ['pipe', 'pipe', 'pipe'] });
y.stdout.pipe(f.stdin);
return { ytdlp: y, ffmpeg: f, stdout: f.stdout };
}
function ensurePlayer(gid)
{
if (PLAYERS.has(gid))
{
return PLAYERS.get(gid);
}
const player = createAudioPlayer({
behaviors: { noSubscriber: NoSubscriberBehavior.Pause }
});
player.on(AudioPlayerStatus.Idle, () =>
{
State.get(gid).apply(State.Event.END);
void playNext(gid);
});
player.on('error', (err) =>
{
console.error(`[player ${gid}] error:`, err);
State.get(gid).apply(State.Event.FAIL);
});
PLAYERS.set(gid, player);
return player;
}
// async function makeAudioResource(url)
// {
// if (typeof url !== 'string' || !url.startsWith('http'))
// {
// throw new Error('INVALID_TRACK_URL');
// }
// const Stream = await playdl.stream(url, { quality: 2 });
// console.log("stream : " + Stream);
// return createAudioResource(Stream.stream, {
// inputType: Stream.type,
// });
// }
// function makeFfmpegStream(url)
// {
// const ytdlp = spawn('yt-dlp', ['-f', 'bestaudio/best', '-o', '-', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] });
// const ffmpeg = spawn('ffmpeg',
// ['-loglevel', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'],
// { stdio: ['pipe', 'pipe', 'pipe'] });
// ytdlp.stdout.pipe(ffmpeg.stdin);
// return ffmpeg;
// }
// player-play (큐에서 곡 꺼내 실행)
async function playNext(gid)
{
const track = Queue.getNext(gid);
if (!track)
{
State.get(gid).apply(State.Event.END);
return null;
}
const conn = getVoiceConnection(gid);
if (!conn)
{
throw new Error('VOICE_NOT_CONNECTED');
}
await entersState(conn, VoiceConnectionStatus.Ready, 15_000);
State.get(gid).apply(State.Event.LOAD);
const player = ensurePlayer(gid);
const rawUrl = track.videoId
? `https://www.youtube.com/watch?v=${track.videoId}`
: toWatchUrl(track.url);
if (!rawUrl || !rawUrl.startsWith('http')) throw new Error('INVALID_TRACK_URL');
killProc(gid);
const { ytdlp, ffmpeg, stdout } = makePipe(rawUrl);
PROC.set(gid, { ytdlp, ffmpeg });
const resource = createAudioResource(stdout, {
inputType: StreamType.OggOpus, // ✅ Opus 스트림 그대로
// inlineVolume: true // ❌ 제거 (필요하면 ffmpeg에 -filter:a volume=0.5 식으로)
});
const onClose = () => killProc(gid);
ytdlp.on('close', onClose);
ffmpeg.on('close', onClose);
conn.subscribe(player);
player.play(resource);
State.get(gid).apply(State.Event.START);
return track;
}
// player-pause
function pause(gid)
{
const player = PLAYERS.get(gid);
if (player?.pause())
{
State.get(gid).apply(State.Event.PAUSE);
}
}
// player-resume
function resume(gid)
{
const player = PLAYERS.get(gid);
if (player?.unpause())
{
State.get(gid).apply(State.Event.RESUME);
}
}
// player-skip
async function skip(gid)
{
const player = PLAYERS.get(gid);
if (player)
{
player.stop(true);
State.get(gid).apply(State.Event.SKIP);
}
return await playNext(gid);
}
// player-stop
function stop(gid)
{
const player = PLAYERS.get(gid);
if (player)
{
player.stop(true);
}
Queue.clear(gid);
State.get(gid).apply(State.Event.STOP);
}
module.exports = { playNext, pause, resume, skip, stop };
이제 정상적으로 음원 재생 시도까지는 된다!.
근데 실제로 보이스를 재생시키지는 않고 프로그램이 뻗는다.TT
왜일까 찾아보니 Discord 음성은 Opus 인코딩과 RPT 패킷 암호화가 반드시 지원되어야만 한다는 것이다.
그래서 패키지 하나 설치해줬다.
yarn add libsodium-wrappers
이제 봇이 음성 채널에서 노래를 불러준다.
근데 계속 재생하다 보니까 문제가 생겼다.
중간에 노래가 완주되지 않고 끊어지면서 봇이 죽어버린것이다.
직접 스트림 데이터를 다운받아 복호화 -> 암호화를 진행하다 보니 서브 프로세스에서 다 암호화가 완료되지 않은 상태에서 봇이 음성 재생을 위해 데이터를 가져오는 순간 크래시가 나는것이었다.
그래서 더 빠른 음성 스트림 파이프라인이 필요해졌다.
아니면 모든 데이터가 암호화 완료 될 때 까지 기다리던지... <- 같은 의미로 처음 재생했을 때 바로 음원이 안나오고 몇초간 기다리다가 재생된다.
점검하면서 skip 하면 바로 음원 복호화-암호화를 죽이기 때문에 같은 이유로 크래시가 난다는 것을 짐작했다.
(EPIPE/EOF 방지: “파이프 먼저 끊고 → 플레이어 정지”)
그래서 더 확실하게 보장하기 위해 프로세스 킬 순서를 보장해 주고 비정상 종료, skip 처리를 보완해야 했다.
3. 오디오 파이프라인 만들기
// components/player.js
const {
createAudioPlayer, createAudioResource,
NoSubscriberBehavior, AudioPlayerStatus,
getVoiceConnection, VoiceConnectionStatus, entersState, StreamType
} = require('@discordjs/voice');
const { spawn } = require('child_process');
const path = require('path');
const ffmpegPath = require('ffmpeg-static')
const Queue = require('./queue');
const State = require('./state');
const PLAYERS = new Map();
const PROC = new Map();
const ATTACHED_CONN = new Set();
const ytdlpPath = process.env.YTDLP_PATH
|| path.resolve(__dirname, '../bin/yt-dlp.exe');
async function killProc(gid) {
const p = PROC.get(gid);
if (!p) return;
if (p.killing) return;
p.killing = true;
try {
// 언파이프/프로세스 종료
await p.killAll?.();
} catch {}
try {
p.resource?.playStream?.off?.('error', swallowPipeErr);
p.resource?.playStream?.destroy?.(new Error('SKIP'));
} catch {}
PROC.delete(gid);
}
function toWatchUrl(input)
{
if (typeof input !== 'string') return '';
const m = /[?&]v=([^&]+)/.exec(input);
return m ? `https://www.youtube.com/watch?v=${m[1]}` : input;
}
function swallowPipeErr(e) {
if (!e) return;
const code = e.code || '';
const msg = String(e.message || e);
if (
code === 'EPIPE' ||
code === 'ECONNRESET' ||
code === 'ERR_STREAM_PREMATURE_CLOSE' ||
/EOF|socket hang up|premature|broken pipe/i.test(msg)
) return; // 무시
console.warn('[stream error]', code, msg);
}
function makePipeFast(url) {
const y = spawn(ytdlpPath, [
'-f', 'bestaudio[acodec=opus][ext=webm]/bestaudio[acodec=opus]',
'--no-playlist',
'-q',
'-o', '-',
url
], { stdio: ['ignore', 'pipe', 'pipe'] });
// 첫 바이트를 빨리 받으면 성공으로 간주
const ready = new Promise((resolve) => {
let resolved = false;
const to = setTimeout(() => { if (!resolved) { resolved = true; resolve(false); } }, 300);
y.stdout.once('data', () => { if (!resolved) { resolved = true; clearTimeout(to); resolve(true); } });
y.once('close', (code) => { if (!resolved) { resolved = true; clearTimeout(to); resolve(false); } });
});
// 에러는 조용히
y.stdout.on('error', swallowPipeErr);
const killAll = async () => {
try { y.stdout.unpipe?.(); } catch {}
try { y.kill('SIGTERM'); } catch {}
await new Promise(r => setTimeout(r, 120));
try { y.kill('SIGKILL'); } catch {}
};
return { ytdlp: y, stdout: y.stdout, ready, killAll, type: StreamType.WebmOpus };
}
// --- yt-dlp -> ffmpeg (re-encode to webm/opus) ------------------------------
function makePipeEncode(url) {
const y = spawn(ytdlpPath, [
'-f', 'bestaudio/best',
'--no-playlist',
'-q',
'-o', '-',
url
], { stdio: ['ignore', 'pipe', 'pipe'] });
const f = spawn(ffmpegPath, [
'-loglevel', 'error',
'-i', 'pipe:0',
'-vn',
'-acodec', 'libopus',
'-ar', '48000',
'-ac', '2',
'-b:a', '128k',
'-f', 'webm',
'pipe:1'
], { stdio: ['pipe', 'pipe', 'pipe'] });
y.stdout.pipe(f.stdin);
const swallow = swallowPipeErr;
y.stdout.on('error', swallow);
f.stdin.on('error', swallow);
const safeUnpipe = () => {
try { y.stdout.unpipe(f.stdin); } catch {}
try { f.stdin.end(); } catch {}
};
f.on('close', () => { safeUnpipe(); try { y.kill('SIGKILL'); } catch {} });
y.on('close', () => { safeUnpipe(); });
const killAll = async () => {
safeUnpipe();
try { f.kill('SIGTERM'); } catch {}
await new Promise(r => setTimeout(r, 150));
try { f.kill('SIGKILL'); } catch {}
try { y.kill('SIGTERM'); } catch {}
await new Promise(r => setTimeout(r, 120));
try { y.kill('SIGKILL'); } catch {}
};
return { ytdlp: y, ffmpeg: f, stdout: f.stdout, killAll, type: StreamType.WebmOpus };
}
function ensurePlayer(gid) {
if (PLAYERS.has(gid)) return PLAYERS.get(gid);
const player = createAudioPlayer({
behaviors: { noSubscriber: NoSubscriberBehavior.Pause }
});
player.on(AudioPlayerStatus.Idle, () => {
void killProc(gid);
State.get(gid).apply(State.Event.END);
void playNext(gid);
});
player.on('error', (err) => {
console.error(`[player ${gid}] error:`, err);
void killProc(gid);
State.get(gid).apply(State.Event.FAIL);
});
PLAYERS.set(gid, player);
return player;
}
// async function makeAudioResource(url)
// {
// if (typeof url !== 'string' || !url.startsWith('http'))
// {
// throw new Error('INVALID_TRACK_URL');
// }
// const Stream = await playdl.stream(url, { quality: 2 });
// console.log("stream : " + Stream);
// return createAudioResource(Stream.stream, {
// inputType: Stream.type,
// });
// }
// function makeFfmpegStream(url)
// {
// const ytdlp = spawn('yt-dlp', ['-f', 'bestaudio/best', '-o', '-', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] });
// const ffmpeg = spawn('ffmpeg',
// ['-loglevel', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'],
// { stdio: ['pipe', 'pipe', 'pipe'] });
// ytdlp.stdout.pipe(ffmpeg.stdin);
// return ffmpeg;
// }
// player-play (큐에서 곡 꺼내 실행)
async function playNext(gid)
{
const track = Queue.getNext(gid);
if (!track)
{
State.get(gid).apply(State.Event.END);
return null;
}
const conn = getVoiceConnection(gid);
if (!conn)
{
throw new Error('VOICE_NOT_CONNECTED');
}
if (!ATTACHED_CONN.has(gid))
{
ATTACHED_CONN.add(gid);
conn.on('stateChange', (_o, n) => {
if (n.status === VoiceConnectionStatus.Destroyed || n.status === VoiceConnectionStatus.Disconnected) {
void killProc(gid);
}
});
}
const readyP = entersState(conn, VoiceConnectionStatus.Ready, 15_000);
await killProc(gid);
await readyP;
State.get(gid).apply(State.Event.LOAD);
const player = ensurePlayer(gid);
const url = track.videoId
? `https://www.youtube.com/watch?v=${track.videoId}`
: toWatchUrl(track.url);
if (typeof url !== 'string' || !url.startsWith('http'))
{
throw new Error('INVALID_TRACK_URL: ' + String(url));
}
const fast = makePipeFast(url);
const okFast = await fast.ready;
let pipe, inputType;
if (okFast) {
pipe = { ytdlp: fast.ytdlp, stdout: fast.stdout };
inputType = fast.type;
} else {
// 2) 폴백 (인코딩)
await fast.killAll();
const enc = makePipeEncode(url);
pipe = { ytdlp: enc.ytdlp, ffmpeg: enc.ffmpeg, stdout: enc.stdout };
inputType = enc.type;
}
const resource = createAudioResource(pipe.stdout, { inputType });
resource.playStream?.on('error', swallowPipeErr)
PROC.set(gid, {
...pipe,
resource,
killAll: async () => {
if (pipe.ffmpeg) {
try { pipe.ytdlp?.stdout?.unpipe?.(pipe.ffmpeg?.stdin); } catch {}
try { pipe.ffmpeg?.stdin?.end?.(); } catch {}
try { pipe.ffmpeg?.kill?.('SIGTERM'); } catch {}
await new Promise(r => setTimeout(r, 120));
try { pipe.ffmpeg?.kill?.('SIGKILL'); } catch {}
}
try { pipe.ytdlp?.kill?.('SIGTERM'); } catch {}
await new Promise(r => setTimeout(r, 100));
try { pipe.ytdlp?.kill?.('SIGKILL'); } catch {}
}
});
conn.subscribe(player);
player.play(resource);
State.get(gid).apply(State.Event.START);
return track;
}
// player-pause
function pause(gid)
{
const player = PLAYERS.get(gid);
if (player?.pause())
{
State.get(gid).apply(State.Event.PAUSE);
}
}
// player-resume
function resume(gid)
{
const player = PLAYERS.get(gid);
if (player?.unpause())
{
State.get(gid).apply(State.Event.RESUME);
}
}
// player-skip
async function skip(gid)
{
await killProc(gid);
const player = PLAYERS.get(gid);
if (player)
{
player.stop(true);
State.get(gid).apply(State.Event.SKIP);
}
return await playNext(gid);
}
// player-stop
function stop(gid)
{
killProc(gid);
const player = PLAYERS.get(gid);
if (player)
{
player.stop(true);
}
Queue.clear(gid);
State.get(gid).apply(State.Event.STOP);
}
module.exports = { playNext, pause, resume, skip, stop };
트러블슈팅(요약 표)
| ERR_INVALID_URL (input: 'undefined') | 포맷에 직접 URL 없음 | yt-dlp 파이프 사용으로 회피 |
| 403 Forbidden (content-length) | HEAD 요청이 지역/쿠키/안티봇에 막힘 | yt-dlp 파이프 사용 |
| Cannot play audio as no valid encryption package... | Opus/RTP 암호화 패키지 미설치 | @discordjs/opus+sodium-native 설치 |
| write EPIPE / EOF / premature close | 스킵/정지 시 파이프를 나중에 끊음 | 파이프 먼저 종료 → player.stop 순서 준수 |
| 재생 시작이 느림 | 초기 디코딩/인코딩 지연 | makePipeFast(직통 Opus) 먼저 시도, 실패 시 인코딩 폴백 |
블로그 글 정리할 때 초안 작성해놓고 지피티한테 던지면 표같은건 참 잘만들어주는거 같다. 자주 써먹어야지
이제 더 해야할게 뭘까 생각해보면 3~5일짜리 프로젝트로 기획했으니까 서버별 playList정도만 지원해주면 끝날 것 같다.
아! 그리고 WAS도 지원해야지... 항상 켜져있어야 하잖아 봇은.....
AWS는 너무 비싸고 Azure도 비싼뎀.. 흠....그렇다고 오라클 클라우드 쓰자니 얘네는 서버 자원 얼마 안쓰는거같으면 프로세스 킬해버려서 상시 가동이 안된단 말이지... 일부러 콜백 트래픽쏴서 프로세스 살려놓을까...?
아무튼 고민좀 해봐야겟다.
프로젝트 코드는 다음 레포지토리를 통해 제공됩니다.
cyphen156/JukeBox-Bot: ToyProject : Discord JukeBox-Bot
GitHub - cyphen156/JukeBox-Bot: ToyProject : Discord JukeBox-Bot
ToyProject : Discord JukeBox-Bot. Contribute to cyphen156/JukeBox-Bot development by creating an account on GitHub.
github.com
'토이프로젝트 > 디스코드 주크박스 봇' 카테고리의 다른 글
| 디스코드 봇 - 주크박스 봇 만들기 #7 봇 클라우드 서버에 올리기 With Azure VM / 애플리케이션 systemd서비스하기 (2) | 2025.08.31 |
|---|---|
| 디스코드 봇 - 주크박스 봇 만들기 #6 플레이리스트 저장 && 암호화 (3) | 2025.08.29 |
| 디스코드 봇 - 주크박스 만들기 #4 유튜브 연동하기 (3) | 2025.08.21 |
| 디스코드 봇 - 주크박스 만들기 #3 명령어 배포하기 (1) | 2025.08.19 |
| 디스코드 봇 - 주크박스 만들기 #2 봇 활성화 및 코드 적용하기 (8) | 2025.08.18 |
