Files
music_bot_v2/bot
tkrmagid 5811c16c20 perf(search): 유튜브뮤직 검색과 ytsearch 폴백을 병렬 실행 (실험 브랜치)
측정으로 확인한 낭비: YoutubeMusic.getSearchUrl 적중률이 6/10(평균 548ms)이라,
빗나가는 40% 에서는 YTM 을 끝까지 기다린 뒤 다시 ytsearch(~0.5초)를 도는 순차 대기가
통째로 버려지고 있었다.

변경: resolveQuery 기본 경로에서 ytsearch 폴백 검색을 먼저 띄워두고 YTM 결과를 기다린다.
  - YTM 적중  -> 폴백 결과는 버리고 기존과 동일하게 동작(Lavalink 검색 1회가 헛돌 뿐)
  - YTM 빗나감 -> 이미 진행 중인 폴백 결과를 그대로 사용(추가 대기 0)
  - 버려지는 폴백 promise 는 catch 를 달아 unhandledRejection 방지
  - " Topic" 중복 부가 방지 조건 추가

실측 A/B(YTM 이 빗나가는 3곡, 검색+해석+리졸브 구간):
  기존 1500 / 1688 / 1391ms  ->  신규 1224 / 1180 / 1399ms
  재생은 3곡 모두 성공, 관찰 중 멈춤 0.
남은 병목: 음성접속 핸드셰이크(0.5~1.3초)는 디스코드 왕복이라 단축 여지가 거의 없다.
2026-09-18 20:02:16 +09:00
..
2026-04-08 12:59:45 +09:00
2026-04-08 12:59:45 +09:00
2026-04-08 12:59:45 +09:00
2026-04-08 12:59:45 +09:00
2026-04-08 12:59:45 +09:00

music_bot_v2 — 봇(bot/)

디스코드 음악봇 본체. 검색·재생·대기열·자동재생·성인인증(연령제한) 영상 재생을 지원한다. discord.js + shoukaku(Lavalink 클라이언트) 기반이며, 유튜브 재생은 Lavalink youtube-plugin을 쓰되 연령제한/SABR로 막히는 영상은 yt-dlp 리졸버로 직접 오디오 URL을 뽑아 Lavalink http 소스로 우회 재생한다.

이 문서는 봇 컴포넌트(bot/)와 이를 돕는 리포 루트의 resolver/·scripts/를 다룬다. 웹 대시보드는 ../page/README.md, 리포 전체 개요는 ../README.md 참고.


1. 아키텍처

[Discord] ──▶ 봇 컨테이너(music1/2/3)  ── shoukaku ──▶ Lavalink (.6:2333)
   (.5 호스트, docker)                       │              └ youtube-plugin (일반 영상)
                                             │
                                 재생 직전 리졸브 요청
                                             ▼
                              ytresolver (.5:8779, systemd)
                                   ├ ① 유튜브 내부 API(VISIONOS)  ~0.3초
                                   ├ ② yt-dlp 상주 호출           ~1.7초
                                   └ ③ yt-dlp + 쿠키(연령제한)     ~6~8초
                                             │  → 직접 오디오 URL
                             그 URL을 Lavalink http 소스로 재생
  • 봇: .5 호스트에서 docker compose 로 music1/2/3 3개 인스턴스 구동. 소스는 bot/.
  • Lavalink: .6 호스트(192.168.10.6:2333). 일반 유튜브/스포티파이 미러 재생 담당.
  • ytresolver: .5 호스트에서 systemd 로 구동(:8779). 연령제한/SABR 우회용 오디오 URL 리졸버. 소스는 리포 루트 resolver/.
  • Redis(선택): .7. 대시보드(page/) 제어/상태 연동(아래 6장, RedisClient).
  • .5와 .6은 같은 공인 IP를 쓰므로, .5에서 뽑은 googlevideo URL을 .6 Lavalink가 그대로 재생할 수 있다.

왜 리졸버가 필요한가

유튜브가 스트리밍을 SABR(서버 주도 적응 스트리밍)로 바꾼 뒤, Lavalink youtube-plugin(1.18.2)은 직접 스트림 URL을 받지 못해 실재생이 막힌다. 여기에 한국 19금(본인인증 연령제한) 영상은 OAuth 세션엔 열리지 않고(“This video requires login”) 웹 쿠키 세션엔 열린다. 그래서 재생 직전에 직접 오디오 URL을 뽑아 Lavalink http 소스로 재생하는 우회 경로를 둔다.

참고: upstream youtube-source의 SABR 지원은 미병합 실험 브랜치(feat/sabr-support) 상태다. 실제로 올려 시험해 보면 첫 재생은 되지만 약 55초 뒤 403으로 끊긴다. 정식 릴리스에 SABR이 들어오면 그때 재검증할 것.

리졸버 3단계 폴백

  1. 유튜브 내부 API(VISIONOS 클라이언트) — player API 한 번으로 오디오 직링크(itag 251)를 받는다(~0.3초). visitorData가 있어야 LOGIN_REQUIRED가 안 뜨며, 리졸버가 스스로 발급·캐시(6시간)하고 만료 시 재발급한다.
  2. yt-dlp 상주 호출 — zipapp(/usr/local/bin/yt-dlp)을 sys.path에 넣어 import하고 YoutubeDL 인스턴스를 재사용한다. 요청마다 프로세스를 띄우는 비용(실측 0.40초)을 없앤다(~1.7초).
  3. yt-dlp + 쿠키 — 1·2가 실패하는 연령제한(로그인 필요) 영상 전용(68초). 계정을 불필요하게 태우지 않도록 익명 먼저 → 실패 시에만 쿠키 재시도 순서를 유지한다.

실측(2026-09-18, 운영 적용 후): 일반 유튜브 URL 리졸브 274~559ms(적용 전 1.7~2.3초), 연령제한 4건은 ③으로 폴백해 전부 재생 성공, 32분 연속 재생에서 멈춤 0/383회.


2. 설치 / 배포 (.5 호스트)

전제:

  • .5에 Docker, /root/bot/music_bot_v2-compose.yml(3개 인스턴스 정의), /root/bot/db(sqlite + 쿠키) 존재.
  • 리졸버 실행 호스트(.5)에 yt-dlp 와 node v22 이상, python3 필요.
    • yt-dlp: curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && chmod a+rx /usr/local/bin/yt-dlp
    • node 22: 연령제한 영상의 yt-dlp EJS(시그니처/n-sig) 해독에 필요. node 20 이하에서는 web 클라이언트가 “Requested format is not available”로 실패한다. 예: /opt/node22/bin/node 를 /usr/local/bin/node로 심볼릭.
# 1) 소스 최신화
cd /root/bot/music_bot_v2 && git pull

# 2) 봇 3개 빌드 + 재기동
cd /root/bot && docker compose -f music_bot_v2-compose.yml up -d --build

# 3) ytresolver(리졸버) 설치/갱신  (최초 1회 또는 리졸버 코드 변경 시)
cp /root/bot/music_bot_v2/resolver/ytresolver.py /opt/ytresolver.py
cp /root/bot/music_bot_v2/resolver/ytresolver.service /etc/systemd/system/ytresolver.service
systemctl daemon-reload && systemctl enable --now ytresolver
systemctl restart ytresolver     # 코드 갱신 시

빌드는 bot/Dockerfile(node:20-alpine, npm run build=tsc)로 이루어진다.

인스턴스 정의(/root/bot/music_bot_v2-compose.yml)

music1/2/3 세 서비스가 각자 다른 TOKEN/APPID/PREFIX/DBPATH로 뜬다. 셋 다 build: ./music_bot_v2/bot (같은 소스)이라 코드는 항상 동일하게 빌드되고, env만 다르다. /root/bot/db:/db 볼륨을 공유하므로 sqlite DB(music1.db 등)와 쿠키(ytcookie.txt)가 컨테이너에서 /db/...로 보인다.


3. 환경변수 (bot/.env)

전체 목록/설명은 .env.example 참고. 핵심만:

변수 설명
TOKEN / APPID 디스코드 봇 토큰 / 앱 ID
PREFIX 명령어 접두사(예: m1;)
DBPATH sqlite 경로(컨테이너 기준, 예 /db/music1.db)
LAVALINK_HOST/PORT/PW Lavalink(.6:2333) 접속 정보
SPOTIFY_CLIENTID/SECRET 스포티파이 검색/미러
YOUTUBE_COOKIE_PATH Netscape 쿠키 파일 경로(기본 /db/ytcookie.txt). 리졸버·검색 공용 쿠키 소스
YOUTUBE_COOKIE (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선
YTRESOLVER_URL 리졸버 주소(기본 http://192.168.10.5:8779)
REDIS/REDIS_HOST/REDIS_PORT 대시보드 제어/상태 연동(선택)

bot/.gitignore가 *.env, *.db를 제외하므로 실제 .env와 DB는 커밋되지 않는다.


4. 유튜브 쿠키 (ytcookie.txt) — 성인인증/연령제한 재생의 핵심

  • 무엇: 유튜브에 로그인된 계정의 세션 쿠키(Netscape cookies.txt 형식). yt-dlp가 연령제한 영상의 스트림을 받으려면 이 쿠키가 필요하다. 봇의 유튜브뮤직 검색도 이 쿠키를 헤더 문자열로 변환해 쓴다.
  • 위치: .5 호스트 /root/bot/db/ytcookie.txt (= 컨테이너 /db/ytcookie.txt, 리졸버도 같은 파일을 읽음).
  • 계정: 전용(버리는) 구글 계정을 쓰고 성인인증(본인확인)을 해둔 것. 본계정 사용 금지(밴 리스크).
  • 형식(Netscape) 예:
    # Netscape HTTP Cookie File
    .youtube.com	TRUE	/	TRUE	2000000000	SID	g.a000...
    .youtube.com	TRUE	/	TRUE	2000000000	LOGIN_INFO	AFm...
    
    최소한 SID, LOGIN_INFO, __Secure-1PSID/3PSID, SAPISID 등 로그인 쿠키가 있어야 한다.

쿠키 만료 시 재발급 (증상: 19금만 안 되고 일반 영상은 정상)

방법 1 — 스크립트(Chromium 자동/수동 로그인, 소스는 리포 루트 scripts/refresh-cookies.mjs):

cd /root/bot/music_bot_v2
npm i -D playwright && npx playwright install chromium   # 최초 1회
GOOGLE_EMAIL=<계정> GOOGLE_PASSWORD=<비번> \
  OUT=/root/bot/db/ytcookie.txt node scripts/refresh-cookies.mjs
# 구글이 자동 로그인을 막으면 뜬 창에서 직접 로그인 → 자동 저장됨(디스플레이 있는 환경에서 실행)

방법 2 — 브라우저 확장(수동): PC 크롬에 Get cookies.txt LOCALLY 설치 → 전용 계정으로 youtube 로그인 → Export → 받은 cookies.txt를 /root/bot/db/ytcookie.txt로 복사.

재발급 후: systemctl restart ytresolver 하면 즉시 반영(봇 재시작 불필요, 리졸버는 매 요청마다 파일을 읽음). 계정/비밀번호는 별도 안전한 곳(비밀번호 관리자/서버 secrets)에 보관한다.


5. 사용법 (디스코드)

  • 지정 채널에 PREFIX + 검색어/URL → 검색 후 재생, 대기열 추가.
  • 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글.
  • 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트.
  • 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백).

텍스트 검색 순서 & 플래그 (검색어 뒤에 -x 형태로 붙임)

  • 기본(플래그 없음): 유튜브뮤직 우선(YoutubeMusic.getSearchUrl) → 결과가 없거나 애매(커버/MR/노래방/방송/재생목록/검색어 무관)하면 자동으로 ytsearch:… Topic(공식 오디오 우선) 폴백.
    • YTM API가 이 계정에선 음악 카탈로그 '노래'를 잘 못 줘서, 애매하면 버리고 폴백하도록 JUNK 필터 + 검색어-제목 관련성 검사를 둔다(YoutubeMusic.JUNK_RE, getSearchUrl).
  • -p: 스포티파이 우선 검색. -o: ytsearch에 "Topic" 미부가. -s: 플레이리스트 셔플 추가. -y: (구) 유튜브뮤직 강제 — 기본이 이미 YTM이라 사실상 동일.

6. 웹 대시보드 연동 (Redis)

대시보드(page/)는 Redis Pub/Sub + Key로 봇과 통신한다. 봇 쪽 구현은 src/classes/RedisClient.ts.

  • site → bot (명령): 사이트가 site-bot 채널에 {action, serverId, userId, requestId, …}를 publish. 봇은 처리 후 결과를 "<action-key>:<requestId>" 키에 setex(예: player:play:<reqId>, queue:list:<reqId>). 사이트는 그 키를 짧게 폴링해 결과를 받는다.
    • action 예: search, player_play, player_playlist, player_now, player_pause, player_skip, player_seek, player_volume, queue_list, queue_set, queue_remove.
  • bot → site (실시간 상태): 상태가 바뀌면 봇이 bot-site 채널에 {event, guildId}를 publish (player_update, queue_update). 사이트는 SSE로 브라우저에 흘려보낸다.
  • 활성화: bot/.env의 REDIS=true, REDIS_HOST, REDIS_PORT.

7. 성능(재생 시작 지연) 메모

  • 다음 곡 프리페치(prefetch): 현재 곡 재생 중 큐의 다음 곡을 미리 리졸브해 곡 전환 대기를 없앤다(GuildPlayer).
  • 음성접속 ↔ 곡 해석 병렬화: player_play가 channelJoin()을 기다렸다 검색하던 직렬 구조를 분리했다. LavalinkManager.resolveQuery()(해석, 플레이어 불필요) + addResolved()(큐 반영)로 나눠, 해석을 먼저 출발시키고 음성접속과 동시에 진행한다.
  • 첫 곡 프리워밍(prewarm) + 리졸버 요청 합치기(coalescing): 곡이 정해지는 즉시 prewarmTrack()이 리졸브를 걸어두고, 리졸버는 같은 영상의 진행 중 요청을 하나로 합친다(ytresolver INFLIGHT). 실제 재생 요청이 prewarm 진행분에 붙으므로 음성접속 시간 뒤로 리졸브가 숨는다.
    • 캐시 키를 맞추려고 prewarm은 resolvePlayable과 동일한 대상 문자열을 쓴다(youtube면 info.uri).
  • 리졸버는 리졸브된 URL을 30분 캐시한다(같은 곡 재생/재시도 시 즉시).
  • 리졸브 자체 단축: 내부 API 경로 도입으로 274559ms(기존 yt-dlp subprocess 4.24.7초, 상주 호출 1.7~2.3초).

실측 추이(같은 곡 기준 첫 소리까지): 약 7초 → 4.9초(병렬화·예열) → 2.6초(상주 호출) → 1.6~2.3초(내부 API). 남은 지연은 음성 첫 접속 핸드셰이크(0.51.3초)와 유튜브뮤직 검색(0.50.9초)이다.


8. 트러블슈팅

  • 19금만 재생 안 됨: 쿠키 만료 → 4장 참고해 ytcookie.txt 재발급 후 systemctl restart ytresolver.
  • 전부 재생 안 됨: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. systemctl status ytresolver, curl http://192.168.10.5:8779/health, Lavalink :2333.
  • 유튜브뮤직 검색 품질: YTM API가 이 계정/엔드포인트에선 음악 카탈로그 '노래'를 거의 안 주고 일반 유튜브 영상(토크쇼·재생목록·MV 등)을 반환한다. 그래서 애매한 결과는 버리고 ytsearch:… Topic(공식 오디오)로 폴백하도록 해둠. 더 깨끗한 공식 오디오가 필요하면 검색에 -p(스포티파이 우선)를 쓴다.
  • yt-dlp EJS(시그니처) 오류: 리졸버는 연령제한 영상에 --js-runtimes node가 필요하다(node 설치 필수).

9. 소스 구조 (bot/src)

  • index.ts — 부팅(클라이언트/LavalinkManager/Handler/Redis 초기화).
  • classes/BotClient.ts — discord.js 클라이언트 래퍼. LavalinkManager.ts — shoukaku 노드/검색/프리워밍. GuildPlayer.ts — 길드별 큐·재생·프리페치·리졸브(resolvePlayable)·스톨 감시. RedisClient.ts — 대시보드 RPC/이벤트. Handler.ts — 명령 로딩.
  • commands/ — join, seek, channel, help, ping 등. events/ — messageCreate(접두사 재생), interactionCreate(버튼), voiceStateUpdate 등.
  • utils/ — Config.ts(env), api/Spotify.ts·api/YoutubeMusic.ts(검색), music/*(URL 파싱·임베드·채널), Database.ts(sqlite).