운영 리졸버 + 내부API 0순위 조합을 테스트 포트(8780)에 올리고, 운영 Lavalink(.6)로
실제 재생을 검증했다. 프로덕션(8779/Lavalink/master)은 변경하지 않았다.
케이스별 결과(첫 소리 / 관찰 중 멈춤):
짧은영상 7초 성공 888ms (재생 후 정상 종료. 관찰창이 곡보다 길어 판정 오탐 1회 발생)
짧은영상 18초 성공 1437ms 0/2
일반곡(K-pop) 성공 2017ms 0/3
해외곡 성공 4209ms 0/3
스포티파이 URL 성공 2753ms 0/3
플레이리스트120 성공 3147ms 0/3
곡 전환 성공 2982ms 0/2 (Ditto -> HEYA, 6.0s)
긴 곡(1시간) 성공 1430ms 0/3
32분 연속재생 성공 888ms 0/383 <- position 5,180 -> 1,920,180ms 정확히 선형 증가
리졸버 경로 통계: 내부API 11회 / 폴백 0회 / 내부API 실패 0회
참고: SABR 직결은 55초에서 403으로 끊겼던 반면, 이 방식은 32분간 무결. 포맷도 itag 251 동일.
남은 과제(운영 적용 전): visitorData 를 봇이 2시간마다 갱신하는 값과 공유하도록 배선.
현재는 고정 파일(/tmp/vd.txt)을 읽는 프로토타입이라 만료 시 폴백으로 떨어진다.
연령제한곡은 샘플 확보 실패로 미검증(폴백 경로가 쿠키로 처리).
yt-dlp 호출 자체를 없애는 경로를 탐색해 실측했다.
[탐색 과정]
- 내부 player API 를 클라이언트별로 호출해 비교:
ANDROID_VR/TVHTML5/WEB_EMBEDDED = LOGIN_REQUIRED·ERROR, WEB = SABR 전용(직접 URL 없음)
IOS = 0.32s 로 직접 URL(itag 140) 획득했으나 뒷부분 range 요청이 403 → 이어받기 불가
- 정상 재생되는 yt-dlp URL 의 c= 파라미터가 VISIONOS 임을 확인하고 동일 클라이언트로 재현.
visitorData 없이 호출하면 LOGIN_REQUIRED, 넣으면 status=OK.
[결과] VISIONOS + visitorData 직접 호출
- 추출 0.30~0.38s (현행 상주 리졸버 1.7~2.3s, 기존 subprocess 4.2~4.7s)
- itag 251 로 yt-dlp 결과와 동일 포맷, Lavalink loadType=track
- range 0 / 1MB / 2MB / 3MB 전부 206 → 이어받기 정상(SABR·IOS 와 다름)
- 실제 재생: 90초 끊김 0/17, 일반곡·곡전환 성공. 첫 소리 2.0~2.3s
- 리졸버에 0순위로 넣고 실패 시 상주 lib → subprocess 로 폴백(경로 통계 innertube 5 / lib 1 / 실패 0)
미해결/주의: visitorData 를 정적 파일에서 읽는 프로토타입이라, 운영 적용 시 봇이 2시간마다
생성하는 visitorData 를 공유받도록 배선해야 한다. 검색어(비 URL) 입력은 영상 ID 가 없어
기존 경로로 폴백한다. 연령제한곡은 미검증(폴백 경로가 쿠키로 처리).
프로덕션(리졸버 8779, Lavalink, master)은 변경하지 않았다.
[1] 재생 중 끊김(~55초) 해결 시도 — 전부 실패
- poToken 정상 주입 + 프로덕션 동일 설정 : 55초 끊김 동일
- 클라이언트 WEB 단독(SABR 경로 강제) : 55초 끊김 동일(decode 오류)
- 프록시(.4:3128) 제거 : 55초 끊김 동일
원인: 첫 청크 재생 후 '이어받기' 요청이 실패(정적 경로 403 / SABR 경로 decode).
feat/sabr-support 는 열린 PR #229, 마지막 커밋 2026-07-27 로 미병합 진행 중 기능.
=> 우리 쪽 설정으로 고칠 수 없음. SABR 직결 보류 확정.
[2] 새 방식: yt-dlp 를 '상주 라이브러리'로 호출하는 리졸버(프로토타입 첨부)
- zipapp(/usr/local/bin/yt-dlp) 을 sys.path 에 넣어 import, YoutubeDL 인스턴스 1회 생성
- 매 요청 프로세스 기동(0.40s) 제거 + player_client=default 단독
- 실패 시 기존 subprocess 경로로 자동 폴백(안전)
리졸브 실측: 1.58~2.30s (기존 subprocess 3곡 평균 4.2~4.7s)
재생 검증(프로덕션 Lavalink .6 + 이 리졸버):
일반곡 2656ms / 해외곡 2968ms / 직접URL 1900ms / 곡전환 성공
90초 장시간: 85초 진행, 끊김 0/17 ← SABR(55초 실패) 대비 안정
프로덕션(리졸버 8779, Lavalink, master)은 변경하지 않음. 테스트는 8780 포트로만 수행.
SelfTest 확장: 관찰시간(SELFTEST_WATCH_SEC), 곡전환(SELFTEST_SECOND_QUERY) 지원.
position 샘플링 주기를 Lavalink 보고 주기(5초)에 맞춰 멈춤 오탐 제거.
격리 Lavalink(.9:2334, SABR 빌드) + ytresolver OFF 결과:
일반곡(K-pop) 성공 첫소리 1455ms 20초 관찰 끊김 0
해외곡 성공 첫소리 1696ms 20초 관찰 끊김 0
직접 URL 성공 첫소리 932ms 20초 관찰 끊김 0
긴 곡(1시간) 성공 첫소리 3865ms 20초 관찰 끊김 0
플레이리스트(120곡) 성공 첫소리 5656ms 20초 관찰 끊김 0
곡 전환 성공 (Ditto → HEYA, 6.0s)
라이브 스트림 실패 ※ 현행 방식(1.18.2+ytresolver)도 동일 실패 → 기존 한계
장시간 90초 실패 50초 재생 후 정지, 2곡 재현
장시간 실패 원인: 재생 중 googlevideo 403
(PersistentHttpStream.connect → "Not success status code: 403")
대조군(현행 방식) 동일 90초 테스트: 85초 진행, 끊김 0 → 현행이 안정적.
결론: SABR 빌드는 첫 재생이 약 3초 빠르지만 곡 길이(3~4분)를 못 버티므로 적용 불가.
프로덕션(Lavalink .6, 리졸버, master)은 변경하지 않았다.
이전 결론(직결 불가)은 youtube-source 1.18.2 '릴리스' 기준이었다. upstream 에
feat/sabr-support 브랜치(커밋 02f536d "SABR stream support")가 있고, 스냅샷
저장소에 빌드가 배포돼 있어 격리 Lavalink(.9:2334)에 올려 재검증했다.
빌드 확인: 1.18.2 에 없던 dev/lavalink/youtube/sabr/* 클래스 14개 추가(SabrStream 등).
(참고: main 최신 스냅샷 f45bbb7 에는 SABR 미포함 — 감지 문자열만 존재)
실측(자가 테스트, 동일 3곡 + 1곡, 프로덕션 무영향):
A) 현재 방식(.6 Lavalink 1.18.2 + ytresolver): 4437~4884ms, 성공
B) SABR 빌드 + ytresolver OFF(직결) : 1155~2816ms, 성공
→ 첫 소리까지 약 3초 단축. yt-dlp 단계 자체가 사라져 구조도 단순해진다.
미검증/주의: 연령제한곡·라이브·장시간 재생 안정성, 비공식 브랜치(미병합) 빌드 사용.
프로덕션 Lavalink(.6)·리졸버·master 는 변경하지 않았다.
POTOKEN_INJECT 플래그 추가(기본 true). 격리 테스트 Lavalink(.9:2334)를 별도로 띄워
클라이언트/OAuth/poToken 조합을 바꿔가며 직결 재생을 검증했다. 프로덕션 무영향.
[직결 재생 — 전부 실패]
- ANDROID_VR 우선 + OAuth + poToken : 실패
- ANDROID_VR 단독 + OAuth off : 실패
- ANDROID_VR 단독 + OAuth/poToken off: 실패
Lavalink 측 실패 사유(클라이언트별):
ANDROID_VR "This video requires login" / TVHTML5 "page needs to be reloaded"
WEB "No supported audio streams available"(응답이 sabr=1 + serverAbrStreamingUrl 뿐)
WEB_EMBEDDED "player configuration error" / MWEB 403
→ youtube-source 1.18.2(최신)는 SABR 미지원. 어떤 조합으로도 직결 불가 → ytresolver 필수.
[리졸버 고속화 — 유효한 개선 발견]
yt-dlp 클라이언트별 실측(동일 영상 5개, --get-url):
현재 default,web_safari,tv : 5/5 성공, 평균 2.95s, itag 251
default 단독 : 5/5 성공, 평균 2.00s, itag 251 ← 약 1.0s 단축
android_vr 단독 : 1.6s로 가장 빠르나 오디오 전용 포맷 없음(항상 itag 18)
→ Lavalink loadtracks 실패, 사용 불가
검증: default 단독 URL을 Lavalink가 loadType=track 로 정상 로드,
실제 재생까지 성공(자가 테스트 position 증가 확인).
4번 안(yt-dlp 단계 제거) 독립 검증용 실험 브랜치. master 에는 병합하지 않는다.
- Config.ytresolverEnabled 플래그 추가(YTRESOLVER_ENABLED, 기본 true=기존 동작).
false 면 resolvePlayable 이 Lavalink youtube-source(poToken) encoded 를 그대로 사용.
- SelfTest 추가: 사람이 음성채널에 없어도 봇이 스스로 입장→재생→position 증가를
확인해 실제 오디오 흐름 여부를 판정(환경변수 미설정 시 비활성).
실측 결과(테스트봇, Redis off, 프로덕션 무영향):
- A) ytresolver ON : 재생 성공, 첫 소리까지 4779ms (오디오 리졸브 3166ms)
- B) ytresolver OFF: 첫 소리까지 1279~1391ms 로 빠르지만 재생 실패.
Lavalink 예외 "All clients failed to load the item." 반복, position 0 고정.
서로 다른 2곡에서 동일 재현 → 곡 특성 아닌 구조적 차단(SABR).
결론: poToken 주입만으로는 스트림 로드가 안 되므로 ytresolver 는 계속 필요.
기존: player_play 가 channelJoin() 완료 후에야 search() 를 시작(직렬)하고,
첫 곡은 playNext 시점에 yt-dlp 리졸브(콜드 3.6~4.2s)를 그제서야 수행했다.
- LavalinkManager.search 를 resolveQuery(해석) + addResolved(큐 반영)로 분리.
resolveQuery 는 플레이어가 필요 없어 음성접속과 동시에 실행 가능.
- resolveQuery 가 해석 직후 prewarmTrack() 으로 첫 곡 오디오 리졸브를 즉시 예열.
GuildPlayer.resolvePlayable 과 동일한 target 계산식을 써 캐시가 확실히 적중하게 함.
- RedisClient player_play/player_playlist: 해석을 먼저 출발시키고 음성접속과 병렬 진행.
- 구간 계측 로그 추가(음성접속/해석대기/오디오 리졸브 대기).
검증: tsc 통과. 실측 A/B(동일 조건, 미캐시 곡):
기존 직렬 접속2.00s+리졸브3.03s = 5.03s → 신규 병렬 2.00s+0.97s = 2.98s (약 2s 단축).
jsdom 30은 require('undici') 시 webidl.util.markAsUncloneable is not a function
(node:20-alpine/undici 비호환)으로 import 단계에서 크래시 → 자식 프로세스 code 1.
jsdom 25.0.1은 undici 6를 사용해 해당 경로를 타지 않으며 node:20-alpine(musl)에서
npm ci 재현 빌드 + 실제 실행으로 poToken 생성·주입(204) 검증 완료.
또한 LavalinkManager 실패 로그가 마지막 줄(=Node.js 버전 푸터)만 남겨 원인을 가리던 문제를
전체 출력 꼬리를 남기도록 수정.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
googlevideo 재생 403(스트림 거절)은 Lavalink youtube-plugin에 유효한
poToken/visitorData가 없어서 발생. 봇이 노드 ready 시점과 2시간 주기로
poToken을 생성해 Lavalink의 POST /youtube 엔드포인트에 주입한다.
- scripts/potoken-refresh.mjs: youtubei.js+bgutils-js+jsdom로 생성 후 주입 (독립 ESM 프로세스)
- LavalinkManager: ready 이벤트 + 2h 인터벌로 자식 프로세스 실행
- deps: bgutils-js 3.2.0, jsdom, youtubei.js 추가 / Docker base node:22-alpine
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내 end 핸들러가 다음 곡을
진행시킨다. 기존 exception 핸들러의 errMsg는 5초 뒤 queue[0]을 다시 재생해, 이미
진행된 다음 곡을 재시작시키는 부작용이 있었음.
- errMsg에 retry 파라미터 추가(기본 true). exception 경로는 retry=false로 호출.
- exception이 end를 유발하므로 stopTrack은 부르지 않음(중복 end로 곡 하나 더 스킵 방지).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
노래가 2/3 지점 등에서 버퍼링/멈춘 뒤 다음 곡으로 넘어가지 않고 프리징되던 문제 해결.
- stuck 이벤트: 기존 errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수
있었음. stopTrack으로 end 이벤트를 유도해 실제 다음 곡으로 넘김.
- 재생 위치 멈춤 감시(watchdog) 추가: TrackStuckEvent가 안 뜨는 느린 버퍼링
상황에서도 position이 20초간 진행 없으면 자동 스킵. start에서 시작, clearAllTimers에서 정리.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
기존 player가 있어 channelJoin을 건너뛰는 경로에서 search()가 노드 미연결로
reject될 때 handleMessage는 이미 종료돼 .catch가 잡지 못하던 문제 수정.
- handleMessage 내부 search() await 처리
- prefix 명령어 경로의 messageRun() await 처리 (try/catch가 비동기 오류도 포착)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
노드가 CONNECTED 상태가 아닐 때 joinVoiceChannel이 throw 되어
fire-and-forget handleMessage에서 처리되지 않은 Promise 거부로 번지던 문제 수정.
- LavalinkManager.hasReadyNode() 추가
- channelJoin에서 노드 미연결 시 안내 임베드 반환 + join try/catch
- messageCreate의 handleMessage 호출에 .catch 추가
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
폴백 시 lastPlayed.identifier(Spotify ID)와 RD 믹스 첫 곡(YouTube ID)
비교가 안 맞아서, 방금 끝난 곡의 YouTube 버전이 자동재생 큐에 그대로
들어가던 버그 수정.
- youtubeMixFromSpotifyTrack: { result, seedYtId } 반환
- autoPlay: 폴백일 때 dedup 기준 ID를 seedYtId로 교체
- sprec이 EMPTY/ERROR/빈 PLAYLIST면 곡 메타데이터(artist, title)로
ytsearch 후 첫 결과의 videoId로 RD<id> 믹스를 가져와 자동재생 큐 채움
- 리뷰어 확인: ytmsearch는 현재 Lavalink에서 EMPTY를 반환해서 ytsearch 사용
- sprec / 검색 / 믹스 resolve 각 단계에 try/catch 추가 (한 단계 실패 시
다음 단계로 진행하지 않고 폴백 또는 end로 안전하게 빠짐)
- 진단 로깅: sprec loadType, 폴백 사유, 검색 매칭 videoId, 최종 후보 곡수
- closed 이벤트에서 code/reason/byRemote 로깅 (4006/4014/4015 등 원인 식별)
- 5초 후 봇이 보이스 채널에 남아있으면 player를 새로 만들어 현재 곡을
position 그대로 이어서 재생 (일시정지/볼륨 상태도 복원)
- 재접속 실패 시에만 기존처럼 player 정리
- 라우터/네트워크 일시 끊김(4006 세션 만료 등) 시 사용자 체감 끊김 최소화
- 이전 커밋에서 제거한 axios가 lock 파일에 남아있던 것 정리
- npm audit fix로 transitive 의존성 보안 패치 적용 완료
- 보안 취약점 0건 (이전: high 2, moderate 1)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- GuildPlayer: 타이머 레이스 컨디션 수정, 모든 타이머 정리 로직 통합 (clearAllTimers)
- GuildPlayer: 이벤트 핸들러에 try-catch 추가 (end, exception, stuck)
- GuildPlayer: start 이벤트에서 endTimer 정리, autoPlay tracks 길이 검증 추가
- RedisClient: player_seek, player_volume에 누락된 return ���가
- RedisClient: queue_remove 인덱스 검증 주석 명확화
- Handler: runCommand에 try-catch 추가하여 에러 시 사용자에게 응답
- Channel: getGuildById에 누락된 await 추가, getMemberById/getVoiceChannelById 안전한 에러 처리
- Command.d.ts: 잘못된 타입 ChatInputChatInputCommandInteraction → ChatInputCommandInteraction 수정
- join.ts: 채널 멘션 닫는 괄호 누락 수정
- shuffle.ts: 제네릭 타입 적용, 불필요한 5회 반복 제거
- import 경로 대소문자 수정 (Shuffle → shuffle) - Linux 호환
- YoutubeMusic/Spotify: 하드코딩된 IP를 환경변수로 분리
- console.log/error → Logger 통일 (YoutubeMusic, Button, channel)
- interactionCreate: 전체 try-catch 추가, silent catch에 로깅 추가
- Database: schema 경로 __dirname 기반으로 수정, 컬럼 화이트리스트 추가
- 사용하지 않는 코드 정리 (axios 의존성, 주석처리된 user 관련 코드)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>