Compare commits

...

51 Commits

Author SHA1 Message Date
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
fed948ca02 Merge branch 'master' into experiment/potoken-direct 2026-09-18 19:54:46 +09:00
8ab5b84fa7 docs(readme): 리졸버 3단계 폴백 구조 반영 + 운영본을 resolver/ 로 일원화
- resolver/ytresolver.py 를 현재 운영본(.5 /opt/ytresolver.py)과 동일하게 갱신.
  (직전 커밋에서 scripts/ 로 잘못 중복 저장했던 사본은 제거)
- 루트 README / bot README:
  - 아키텍처 다이어그램에 3단계 폴백(내부API 0.3초 / yt-dlp 상주 1.7초 / 쿠키 6~8초) 표기
  - "왜 리졸버가 필요한가"를 SABR 기준으로 갱신, upstream feat/sabr-support 가 55초에서
    403으로 끊긴다는 검증 결과와 재검증 조건을 주석으로 남김
  - 성능 메모를 현재 구조(병렬화·예열·내부API)와 실측치로 갱신
    첫 소리 약 7초 -> 4.9초 -> 2.6초 -> 1.6~2.3초, 남은 병목은 음성접속·유튜브뮤직 검색
2026-09-18 19:54:27 +09:00
79f6201f85 perf(resolver): 유튜브 내부 API(VISIONOS) 직접 호출을 0순위로 적용 — 리졸브 0.3초대
기존 상주 yt-dlp(1.7~2.3초) 앞에 내부 player API 직접 호출을 두고, 실패 시 기존 경로로
자동 폴백한다. visitorData 는 리졸버가 스스로 발급/캐시(6시간)하고 만료 시 재발급하므로
봇과의 별도 배선이 필요 없다.

경로 우선순위: 내부API(0.3초) -> 상주 yt-dlp -> yt-dlp+쿠키(연령제한)

실측(운영 적용 후):
  일반 유튜브 URL 리졸브 274~559ms (적용 전 1.7~2.3초)
  연령제한 영상은 내부API가 LOGIN_REQUIRED 를 주어 쿠키 경로로 폴백(6~8초, 정상 재생)

재생 검증(운영 리졸버 + 운영 Lavalink, 전 케이스 성공/끊김 0):
  짧은영상 18초 824ms | 일반곡 1646ms | 해외곡 2098ms | 스포티파이 2522ms
  플레이리스트 2722ms | 곡전환 2230ms(전환 6.0초)
  연령제한 4건(사용자 제공): 1453 / 1522 / 7653 / 8209ms 전부 재생 성공
  32분 연속재생: position 5,180 -> 1,920,160ms, 멈춤 0/383
리졸버 경로 통계(테스트 1시간): 내부API 9 / 상주lib 폴백 0 / 쿠키 폴백 3(연령제한)

롤백: /opt/ytresolver.py.bak-20260918-153001 로 교체 후 systemctl restart ytresolver
2026-09-18 16:32:09 +09:00
ef16b67839 experiment: 내부 API(VISIONOS) 리졸버 상세 검증 — 전 케이스 통과, 32분 연속재생 무결
운영 리졸버 + 내부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)을 읽는 프로토타입이라 만료 시 폴백으로 떨어진다.
연령제한곡은 샘플 확보 실패로 미검증(폴백 경로가 쿠키로 처리).
2026-09-17 23:33:11 +09:00
3c6dbc9631 experiment: 내부 API 리졸버 후보 구현 채움(직전 커밋에 빈 파일이 올라감) 2026-09-17 19:07:16 +09:00
e7283ae862 experiment: 더 빠른 경로 발견 — 유튜브 내부 API(VISIONOS) 직접 호출로 리졸브 0.3초
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)은 변경하지 않았다.
2026-09-17 19:06:47 +09:00
2e6c956265 ops(resolver): 상주 yt-dlp 리졸버 적용본을 저장소에 기록
운영(.5 /opt/ytresolver.py)에 적용한 내용을 형상관리한다. 기존에는 저장소 밖에만
존재해 변경 이력이 남지 않았다.

변경 핵심:
- yt-dlp zipapp 을 sys.path 에 넣어 import, YoutubeDL 인스턴스를 프로세스에 상주.
  요청마다 발생하던 프로세스 기동 비용(실측 0.40s)을 제거.
- player_client=default 단독 사용(기존 default,web_safari,tv 는 첫 번째에서 이미
  성공해 나머지 2개 시도 시간이 낭비됐음).
- 라이브러리 호출 실패 시 기존 subprocess 경로(익명 -> 쿠키)로 자동 폴백해 안전망 유지.

실측: 리졸브 4.2~4.7s -> 1.7~2.3s / 첫 소리 4.9s -> 2.6s
검증: 운영 리졸버+운영 Lavalink 로 90초 재생, 끊김 0/17. itag 251 동일, loadType=track.
롤백: /opt/ytresolver.py.bak-20260917-154054 로 교체 후 systemctl restart ytresolver
2026-09-17 15:44:43 +09:00
5d5bf9b3f3 experiment: SABR 끊김은 업스트림 미완성으로 해결 불가 / 대안 '상주 리졸버' 검증 성공
[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 포트로만 수행.
2026-09-17 15:29:21 +09:00
7f057d818c experiment: SABR 빌드 확대 검증 — 첫 재생은 빠르나 ~55초 뒤 403으로 끊김(적용 불가)
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)은 변경하지 않았다.
2026-09-17 15:08:47 +09:00
4fbf8d9326 experiment: SABR 지원 빌드로 직결 재생 성공 — ytresolver 제거 가능성 확인
이전 결론(직결 불가)은 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 는 변경하지 않았다.
2026-09-17 14:48:35 +09:00
7994e97631 experiment: 4번 추가 검증 — Lavalink 직결 전면 불가 확인 + 리졸버 고속화 후보 발견
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 증가 확인).
2026-09-17 14:40:42 +09:00
6762b8faba experiment: ytresolver 제거(poToken 직결) 검증 — 결론 '불가'
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 는 계속 필요.
2026-09-17 13:16:44 +09:00
6de959be43 perf(play): 음성접속과 곡 해석 병렬화 + 첫 곡 yt-dlp 예열로 첫 재생 단축
기존: 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 단축).
2026-09-17 13:11:11 +09:00
1bb1d10b25 fix(play): 스포티파이 트랙은 유튜브뮤직 오디오로 재생(뮤비 폴백 방지)
문제: 대시보드에서 스포티파이 곡을 고르면 track.url(open.spotify.com/track/..)을
Lavalink가 그대로 resolve → 유튜브 '영상(뮤비)'으로 폴백 재생됨.

수정: LavalinkManager.search에서 스포티파이 트랙 URL을 감지하면 메타데이터
(제목+아티스트)를 조회해 YoutubeMusic.getSearchUrl ?? `ytsearch:.. Topic` 오디오
검색으로 대체. 직접 입력/대시보드/`-p` 모든 경로에 적용. youtube 등 타 URL은 무영향.

- Spotify.parseTrackId / Spotify.getTrackById 추가.
- 검증: tsc 통과, Spotify /tracks/{id} 메타데이터 조회 정상, 파싱 정상.
2026-09-13 02:21:20 +09:00
f5778a7763 Merge page-improvements: 대시보드 개선 + BRPOP RPC + 로그인 연동 2026-09-13 01:59:48 +09:00
8e3791fb37 fix(page): PlayerBar useRef 초기값의 Date.now() purity lint 에러 해소
react-hooks/purity 가 useRef 초기화의 Date.now() 호출을 에러로 잡는데,
초기값은 최초 렌더 1회만 평가되어 안전하므로 해당 라인만 eslint-disable 처리.
동작 변경 없음. npm run lint 0 errors.
2026-09-13 01:59:25 +09:00
31b64b73c5 refactor(rpc): site↔bot 명령 응답을 SETEX+폴링에서 LPUSH+BRPOP 블로킹으로 전환
- bot: reply() 헬퍼(LPUSH+EXPIRE) 추가, 67개 setex 응답 지점을 일괄 교체.
  결과키를 리스트에 push해 사이트가 BRPOP으로 블로킹 수신하도록 함.
- page(api.ts botRpc): short-polling 제거, 요청마다 전용 커넥션(duplicate)으로
  BRPOP 대기 → 응답 즉시 수신(폴링 지연/헛 GET 제거), 전용 커넥션은 finally에서 정리.
- search route: 사라진 pollIntervalMs 옵션 제거.

검증: 테스트봇(.9)+.7 Redis 실측 BRPOP 왕복 10ms, 고아 키 없음. bot tsc/page next build 통과.
2026-09-12 23:58:52 +09:00
tkrmagid
221c31bfa1 chore(page): 대시보드 배포 스캐폴딩 추가(Dockerfile + compose 예시, OAuth redirect 안내) 2026-09-12 22:56:58 +09:00
tkrmagid
1efa55c3ec fix(page+bot): 리뷰 지적사항 반영 - 서버 멤버십·같은음성채널 인가(1,2), SSE 공유구독 fan-out(3), botRpc 즉시폴링(4), 큐삭제 encoded 검증(6), 진행바 anchor 타이머(7), 캐시 파싱 가드(8), key 안정화(9), 볼륨 롤백(10), SSE 자동재연결(11), body userId 정리(12) 2026-09-12 22:49:56 +09:00
tkrmagid
ff391cc1c3 docs: README 3단 구성 - 루트(리포 개요/아키텍처), bot/(봇 상세), page/(대시보드 상세) 2026-09-12 22:17:59 +09:00
tkrmagid
cb224cad88 docs(README): 첫 재생 프리워밍+coalescing, 검색 순서(YTM우선→ytsearch Topic)·플래그 문서화 2026-09-12 20:31:58 +09:00
tkrmagid
3a4849acab perf(재생지연): 유튜브 URL 음성접속 전 프리워밍 + 리졸버 진행요청 합치기(coalescing)로 첫 재생 지연 ~2초 단축 2026-09-11 23:02:35 +09:00
tkrmagid
e73f23582c feat(search): 기본 검색을 유튜브뮤직 우선 → 없/이상하면 ytsearch+Topic 폴백으로 변경(-p로 스포티파이 우선) 2026-09-11 20:43:36 +09:00
tkrmagid
3301913e7c fix(YoutubeMusic): 컴필레이션/방송/플레이리스트 패턴도 후순위·배제(원곡 우선, 폴백 활용) 2026-09-11 15:38:36 +09:00
tkrmagid
ddf8801db7 fix(YoutubeMusic): 검색어-제목 관련성 검사 추가 - 무관한 일반영상(토크쇼/재생목록)이면 ytsearch Topic 폴백 2026-09-11 15:37:23 +09:00
tkrmagid
fd92995255 fix(YoutubeMusic): 최상위 결과가 커버/MR류면 null 반환하여 ytsearch Topic 폴백 사용(재생 품질 보호) 2026-09-11 15:35:14 +09:00
tkrmagid
83f7310d53 fix(YoutubeMusic): 검색 0건 버그 수정 - 만료된 고정 params 제거 + itemSectionRenderer 결과 파싱 + 커버/MR/노래방 후순위 정렬 2026-09-11 15:33:28 +09:00
tkrmagid
cf75d047bc docs: 리졸버 호스트 prereq(node22/yt-dlp) 명시 2026-09-11 02:43:46 +09:00
tkrmagid
56bea22e4a feat: 쿠키 파일 기반 설정(YOUTUBE_COOKIE_PATH)+리졸버 URL 설정화, 다음곡 프리페치로 재생지연 단축, ytresolver 리포 포함, 쿠키 재발급 스크립트, README 상세화 2026-09-11 02:34:55 +09:00
tkrmagid
e198c5244a feat: 성인인증(연령제한) 영상 재생 지원 - yt-dlp 리졸버 경유 http 소스 재생 + 재생실패 자동 재시도 2026-09-11 02:24:10 +09:00
adb3c59a85 fix: poToken 스크립트 node:20-alpine 크래시 수정 (jsdom 25로 다운그레이드)
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>
2026-08-28 23:32:34 +09:00
a090e853c2 fix: Docker base를 검증된 node:20-alpine로 유지 (better-sqlite3 musl prebuild 호환)
jsdom 30은 node>=20.19만 요구하므로 node:20-alpine로 충분.
node:22-alpine는 better-sqlite3 musl prebuild 미제공 시 소스 컴파일이 필요해 회귀 위험.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-28 23:26:39 +09:00
1263ca4308 feat: 재생 403 우회용 poToken 자동 생성·주입 (youtube-plugin 런타임 엔드포인트)
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>
2026-08-28 23:25:38 +09:00
tkrmagid
5003c044c5 fix: 재생 exception 시 errMsg 재시도 타이머로 인한 다음 곡 중복 진행 방지
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>
2026-08-17 22:01:53 +09:00
tkrmagid
6d79655181 fix: 재생 멈춤(stall) 시 다음 곡으로 자동 진행
노래가 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>
2026-08-17 21:55:05 +09:00
tkrmagid
5a81ce7416 fix: messageCreate의 미await 비동기 호출로 인한 unhandledRejection 추가 차단
기존 player가 있어 channelJoin을 건너뛰는 경로에서 search()가 노드 미연결로
reject될 때 handleMessage는 이미 종료돼 .catch가 잡지 못하던 문제 수정.
- handleMessage 내부 search() await 처리
- prefix 명령어 경로의 messageRun() await 처리 (try/catch가 비동기 오류도 포착)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 01:35:45 +09:00
tkrmagid
d335287e7e fix: Lavalink 노드 미연결 시 unhandledRejection 방지
노드가 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>
2026-06-11 01:32:32 +09:00
tkrmagid
67ff52227d Merge dev into master: 폴백 dedup ID 수정 2026-05-27 20:37:27 +09:00
tkrmagid
09684b2070 bot: Spotify→YouTube 폴백 dedup ID를 YouTube seed로 사용
폴백 시 lastPlayed.identifier(Spotify ID)와 RD 믹스 첫 곡(YouTube ID)
비교가 안 맞아서, 방금 끝난 곡의 YouTube 버전이 자동재생 큐에 그대로
들어가던 버그 수정.

- youtubeMixFromSpotifyTrack: { result, seedYtId } 반환
- autoPlay: 폴백일 때 dedup 기준 ID를 seedYtId로 교체
2026-05-27 20:37:22 +09:00
tkrmagid
e8c63a2118 Merge dev into master: Spotify 자동재생 폴백 2026-05-27 20:33:54 +09:00
tkrmagid
5756f4e10a bot: Spotify 자동재생 폴백 (sprec EMPTY → YouTube RD 믹스)
- sprec이 EMPTY/ERROR/빈 PLAYLIST면 곡 메타데이터(artist, title)로
  ytsearch 후 첫 결과의 videoId로 RD<id> 믹스를 가져와 자동재생 큐 채움
- 리뷰어 확인: ytmsearch는 현재 Lavalink에서 EMPTY를 반환해서 ytsearch 사용
- sprec / 검색 / 믹스 resolve 각 단계에 try/catch 추가 (한 단계 실패 시
  다음 단계로 진행하지 않고 폴백 또는 end로 안전하게 빠짐)
- 진단 로깅: sprec loadType, 폴백 사유, 검색 매칭 videoId, 최종 후보 곡수
2026-05-27 20:33:28 +09:00
tkrmagid
083b54e14e Merge dev into master: 코드 품질 개선 + 음성 WS 자가회복 2026-05-27 20:26:55 +09:00
tkrmagid
e476e23d37 Merge master into dev: 대소문자 수정 통합
origin/master의 c86bc25 (bot/src/utils/shuffle.ts → Shuffle.ts 대소문자 수정)을
dev에 머지. dev에는 이미 26393fe에서 같은 rename + import 경로 갱신이
들어가 있어서 파일명/경로 충돌은 그대로 dev쪽 유지.

Shuffle.ts 내용 충돌은 dev쪽 (d0dcdb1 코드 품질 개선의 Fisher-Yates 1-pass
구현) 유지. master는 d0dcdb1 이전이라 fshuffle 다중 셔플 구버전이었음.
2026-05-16 02:51:04 +09:00
c86bc252f3 대소문자 수정 2026-05-16 02:48:30 +09:00
tkrmagid
a4b3d40efa bot: 음성 WS 끊김 진단 로깅 + 자가 회복
- closed 이벤트에서 code/reason/byRemote 로깅 (4006/4014/4015 등 원인 식별)
- 5초 후 봇이 보이스 채널에 남아있으면 player를 새로 만들어 현재 곡을
  position 그대로 이어서 재생 (일시정지/볼륨 상태도 복원)
- 재접속 실패 시에만 기존처럼 player 정리
- 라우터/네트워크 일시 끊김(4006 세션 만료 등) 시 사용자 체감 끊김 최소화
2026-05-14 11:27:56 +09:00
26393fec2f bot/utils/shuffle.ts → Shuffle.ts 대문자 통일
리눅스 빌드 환경에서 대소문자 잔존 파일과 충돌(TS1261)을 막기 위해
파일명을 PascalCase 로 통일하고 import 경로도 동일하게 수정.
2026-04-28 16:09:29 +09:00
b670a61192 page 전체 코드 품질/보안 개선 및 봇 RPC 검증 정합
[보안/인증]
- 모든 player/queue API 라우트에 세션 가드 추가 (이전: /api/servers 만 보호)
- NextAuth 환경변수 부팅 시점 검증, NEXTAUTH_SECRET 명시
- next.config.ts CSP/보안 헤더 추가, 잘못된 allowedDevOrigins 제거
- Redis 호스트 하드코딩 IP 제거(필수 env 로 강제)

[안정성]
- 봇 RPC 패턴(@/lib/api) 공용화: crypto.randomUUID requestId, JSON.parse 안전, EXPIRE 자동, 폴링 백오프
- SSE(@/lib/sse) 공용화: subscriber error 처리, JSON.parse 안전, 30초 keep-alive, abort/에러 정리
- pause API 양 끝(boolean) 정상화: 프론트 String() 캐스트 + 백엔드 .trim().toLowerCase() 비교 제거
- 봇 RedisClient: isPaused/index/seek/volume falsy 거부 → typeof 검사로 교체(0/false 정상 허용)

[타입/품질]
- next-auth 모듈 보강 → session.user.id, session.accessToken 타입 안전
- DiscordServer/Track/SearchTrack 공용 타입 도입, 컴포넌트 any 제거
- BigInt permissions 안전 검증(타입 가드)
- Logger: NODE_ENV 게이트, error → stderr, ISO 기반 안전 timestamp
- tsconfig target → ES2020 (BigInt 리터럴)

[취약점]
- next 16.2.2 → 16.2.4 (DoS/postcss XSS 패치)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:56:55 +09:00
e5f3b87b1d package-lock.json 정리 (axios 제거 및 npm audit fix)
- 이전 커밋에서 제거한 axios가 lock 파일에 남아있던 것 정리
- npm audit fix로 transitive 의존성 보안 패치 적용 완료
- 보안 취약점 0건 (이전: high 2, moderate 1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 02:01:14 +09:00
d0dcdb1563 bot 전체 코드 품질 개선 및 버그 수정
- 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>
2026-04-26 23:13:16 +09:00
fefdf1fcdb README에 claude code 개발중 항목 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 22:52:48 +09:00
71 changed files with 3690 additions and 1205 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
__pycache__
*.pyc
.git
venv
**/node_modules
dist

View File

@@ -1,12 +1,56 @@
# music bot v2
## 디스코드 음악봇 v2
# music_bot_v2
### 마지막으로 만들었던 내용
- 검색하고 재생 누르면 재생됨
- 플레이리스트 재생 기능(주소 전달해서 재생하는 방식)
- 아래 재생바 연동
- 오른쪽 재생목록 연동
디스코드 음악봇 + 웹 대시보드 모노레포. 디스코드에서 명령으로 음악을 틀 수도 있고, 웹에서 로그인해
검색·재생·대기열을 제어할 수도 있다. 성인인증(연령제한) 영상 재생까지 지원한다.
### 이제 만들어야되는 내용
- 왼쪽 플레이리스트 가져오기 제작
- 플레이리스트 재생 추가
## 구성
| 디렉터리 | 내용 | 문서 |
|----------|------|------|
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) |
| [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) |
| [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 |
| [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
## 전체 아키텍처
```
┌─────────────── 디스코드 제어 평면 ───────────────┐
[Discord 유저] ── 명령 ──▶ 봇(music1/2/3, .5 docker) ── shoukaku ──▶ Lavalink(.6:2333)
│ ▲ └ youtube-plugin(일반)
│ │ 재생 직전 리졸브
▼ │
ytresolver(.5:8779) ─┬─ ① 유튜브 내부 API(VISIONOS) ~0.3초
│ ├─ ② yt-dlp 상주 호출 ~1.7초
│ └─ ③ yt-dlp + 쿠키(연령제한) ~6~8초
│ ──▶ 직접 오디오 URL
│ (Lavalink http 소스로 재생)
│
┌───────── Redis(.7) Pub/Sub + Key ─────────┐ ← 제어/상태 버스
│ │
[브라우저] ──▶ 웹 대시보드 page/(Next.js, NextAuth) ──┘
· site→bot: "site-bot" 명령 + 결과키 폴링(botRpc)
· bot→site: "bot-site" 이벤트 → SSE 실시간 표시
```
- **봇**과 **대시보드**는 직접 연결되지 않고 **Redis**로 느슨하게 연동된다(대시보드는 Redis만 있으면 봇과 통신).
- 유튜브가 스트리밍을 SABR 방식으로 바꾼 뒤 Lavalink의 youtube-plugin(1.18.2)만으로는 실재생이 막힌다.
그래서 `resolver/`가 재생 직전에 **직접 오디오 URL**을 뽑아 주고, 봇은 그 URL을 Lavalink http 소스로 재생한다.
- 리졸버는 3단계로 폴백한다. ①이 대부분을 처리하고, 연령제한처럼 로그인이 필요한 영상만 ③으로 내려간다.
1. **유튜브 내부 API(VISIONOS 클라이언트)** — player API 한 번으로 오디오 직링크 획득(~0.3초).
`visitorData`는 리졸버가 스스로 발급·캐시(6시간)하고 만료 시 자동 재발급한다.
2. **yt-dlp 상주 호출** — zipapp을 import해 `YoutubeDL` 인스턴스를 재사용(프로세스 기동 비용 제거, ~1.7초).
3. **yt-dlp + 쿠키** — 연령제한(로그인 필요) 영상 전용 경로(~6~8초).
- 봇 서버(.5)와 Lavalink(.6)가 같은 공인 IP라, .5에서 뽑은 스트림 URL을 .6이 그대로 재생한다.
- 첫 곡 재생 지연은 음성채널 접속과 곡 해석을 **병렬**로 돌리고, 곡이 정해지는 즉시 리졸브를 **예열**해 줄였다
(`bot/src/classes/LavalinkManager.ts`의 `resolveQuery`/`prewarmTrack`).
## 빠르게 보기
- 봇 배포/쿠키/검색·재생 동작·성능·트러블슈팅 → [bot/README.md](bot/README.md)
- 대시보드 개발/환경변수/Redis 통신 구조/API·UI → [page/README.md](page/README.md)
## 리포 규칙
- `bot/.gitignore`가 `*.env`, `*.db`를 제외 → 실제 토큰/DB는 커밋되지 않는다. 예시는 [`bot/.env.example`](bot/.env.example).
- 원격: Gitea `git.tkrmagid.kr/tkrmagid/music_bot_v2` (`master`).

View File

@@ -1,6 +1,6 @@
__pycache__
*.pyc
.git
venv/
node_modules/
dist/
venv
**/node_modules
dist

40
bot/.env.example Normal file
View File

@@ -0,0 +1,40 @@
# ===== music_bot_v2 봇 환경변수 예시 =====
# 실제 값은 bot/.env 에 넣는다. (bot/.gitignore 의 *.env 로 커밋되지 않음)
# --- 디스코드 봇 ---
TOKEN=디스코드_봇_토큰
APPID=디스코드_애플리케이션_ID
PREFIX=m1; # 명령어 접두사 (예: m1; m2; m3;)
DEBUG=false # true 면 상세 로그
EMBEDCOLOR=Blue # discord.js Colors 이름 (Blue/Orange/Purple 등)
# --- 데이터베이스 (컨테이너 기준 경로, /db 는 호스트 /root/bot/db 마운트) ---
DBPATH=/db/music1.db
# --- 개발/길드 ---
DEV=false
GUILDID=길드_ID
# --- Lavalink (오디오 재생 노드, .6 호스트) ---
LAVALINK_HOST=192.168.10.6
LAVALINK_PORT=2333
LAVALINK_PW=Lavalink_비밀번호
# --- Spotify (검색/미러) ---
SPOTIFY_CLIENTID=스포티파이_클라이언트ID
SPOTIFY_SECRET=스포티파이_시크릿
# --- 유튜브 쿠키 (성인인증/연령제한 재생 + 유튜브뮤직 검색용) ---
# 방식 A(권장): Netscape 쿠키 파일 경로. 리졸버(yt-dlp)와 봇이 공유하는 단일 소스.
# 호스트 /root/bot/db/ytcookie.txt == 컨테이너 /db/ytcookie.txt
YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
# 방식 B(하위호환): 쿠키 헤더 문자열을 인라인으로. 설정되면 이 값이 우선한다.
# YOUTUBE_COOKIE="VISITOR_INFO1_LIVE=...; SID=...; ..."
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
YTRESOLVER_URL=http://192.168.10.5:8779
# --- Redis (대시보드 상태 연동, 선택) ---
REDIS=true
REDIS_HOST=192.168.10.7
REDIS_PORT=6379

204
bot/README.md Normal file
View File

@@ -0,0 +1,204 @@
# music_bot_v2 — 봇(`bot/`)
디스코드 음악봇 본체. 검색·재생·대기열·자동재생·성인인증(연령제한) 영상 재생을 지원한다.
`discord.js` + `shoukaku`(Lavalink 클라이언트) 기반이며, 유튜브 재생은 Lavalink `youtube-plugin`을
쓰되 **연령제한/SABR로 막히는 영상은 yt-dlp 리졸버로 직접 오디오 URL을 뽑아 Lavalink `http` 소스로 우회 재생**한다.
> 이 문서는 봇 컴포넌트(`bot/`)와 이를 돕는 리포 루트의 `resolver/`·`scripts/`를 다룬다.
> 웹 대시보드는 [`../page/README.md`](../page/README.md), 리포 전체 개요는 [`../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/`](../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가 실패하는 연령제한(로그인 필요) 영상 전용(**~6~8초**). 계정을 불필요하게
태우지 않도록 **익명 먼저 → 실패 시에만 쿠키 재시도** 순서를 유지한다.
실측(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`로 심볼릭.
```bash
# 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`](.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`](../scripts/refresh-cookies.mjs)):
```bash
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 경로 도입으로 274~559ms(기존 yt-dlp subprocess 4.2~4.7초, 상주 호출 1.7~2.3초).
실측 추이(같은 곡 기준 첫 소리까지): **약 7초 → 4.9초(병렬화·예열) → 2.6초(상주 호출) → 1.6~2.3초(내부 API)**.
남은 지연은 음성 첫 접속 핸드셰이크(0.5~1.3초)와 유튜브뮤직 검색(0.5~0.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).

6
bot/db/db.d.ts vendored
View File

@@ -8,9 +8,3 @@ export interface GuildType {
};
}
export type GuildRow = Omit<GuildType, "options"> & { options: string };
// export interface UserType {
// guild_id: string;
// id: string;
// name: string;
// }

580
bot/package-lock.json generated
View File

@@ -9,14 +9,16 @@
"version": "0.0.1",
"license": "ISC",
"dependencies": {
"axios": "^1.14.0",
"better-sqlite3": "^12.8.0",
"bgutils-js": "3.2.0",
"colors": "^1.4.0",
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"ioredis": "^5.10.1",
"jsdom": "25.0.1",
"shoukaku": "^4.3.0",
"undici": "^7.24.7"
"undici": "^7.24.7",
"youtubei.js": "^18.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
@@ -26,6 +28,23 @@
"typescript": "^6.0.2"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.0.tgz",
"integrity": "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -39,10 +58,115 @@
"node": ">=12"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
}
},
"node_modules/@discordjs/builders": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.0.tgz",
"integrity": "sha512-7pVKxVWkeLUtrTo9nTYkjRcJk0Hlms6lYervXAD7E7+K5lil9ms2JrEB1TalMiHvQMh7h1HJZ4fCJa0/vHpl4w==",
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.2",
@@ -354,6 +478,14 @@
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"engines": {
"node": ">= 14"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -364,19 +496,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -412,6 +532,14 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bgutils-js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-3.2.0.tgz",
"integrity": "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg==",
"funding": [
"https://github.com/sponsors/LuanRT"
]
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -460,7 +588,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -497,7 +624,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -512,6 +638,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cssstyle/node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -529,6 +684,11 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -557,7 +717,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
@@ -600,24 +759,24 @@
]
},
"node_modules/discord.js": {
"version": "14.25.1",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"version": "14.26.3",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz",
"integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.13.0",
"@discordjs/builders": "^1.14.1",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
"@discordjs/rest": "^2.6.1",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.33",
"discord-api-types": "^0.38.40",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
"undici": "6.24.1"
},
"engines": {
"node": ">=18"
@@ -627,9 +786,9 @@
}
},
"node_modules/discord.js/node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -651,7 +810,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -670,11 +828,21 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
@@ -683,16 +851,14 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -704,7 +870,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -730,43 +895,27 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -782,7 +931,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -791,7 +939,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@@ -815,7 +962,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -834,7 +980,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -846,7 +991,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -858,7 +1002,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
@@ -870,10 +1013,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -881,6 +1023,52 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -937,10 +1125,54 @@
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
"node_modules/jsdom": {
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
"decimal.js": "^10.4.3",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.0.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^2.11.2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
@@ -961,6 +1193,11 @@
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/magic-bytes.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
@@ -978,16 +1215,22 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/meriyah": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-7.3.2.tgz",
"integrity": "sha512-uFPXbsoxaEd7PafMz3K/n/Z3kXz7iZBJtYU0VQB/kcV7i33+JghLTKweabLir480FUsyLz6gUIj4LW7kCQ35TA==",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -996,7 +1239,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
@@ -1055,6 +1297,11 @@
"node": ">=10"
}
},
"node_modules/nwsapi": {
"version": "2.2.25",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.25.tgz",
"integrity": "sha512-kktSgNDIbyEIs/LrE6dtEMnfzxSDpXViFl6c4gFjz/CgtnL4GDCR3wyZXzjvAXsNB8dXU4dAfvIOgLMH2/vwLg=="
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1064,6 +1311,17 @@
"wrappy": "1"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1091,15 +1349,6 @@
"node": ">=10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -1110,6 +1359,14 @@
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"engines": {
"node": ">=6"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -1160,6 +1417,11 @@
"node": ">=4"
}
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1180,6 +1442,22 @@
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -1274,6 +1552,11 @@
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -1302,6 +1585,44 @@
"node": ">=6"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
@@ -1412,6 +1733,57 @@
"dev": true,
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1439,6 +1811,19 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
@@ -1448,6 +1833,19 @@
"engines": {
"node": ">=6"
}
},
"node_modules/youtubei.js": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-18.0.0.tgz",
"integrity": "sha512-7Tztl5QzNL4nIhiAxa4P/0C8cDDsUe0j4iaUeNiaA+ZfSe/l+HkHxXyoWlQXNW7IPi2n6AW23ysVMJLDh+AwWQ==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"fflate": "^0.8.2",
"meriyah": "^7.3.1"
}
}
}
}

View File

@@ -20,13 +20,15 @@
"typescript": "^6.0.2"
},
"dependencies": {
"axios": "^1.14.0",
"better-sqlite3": "^12.8.0",
"bgutils-js": "3.2.0",
"colors": "^1.4.0",
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"ioredis": "^5.10.1",
"jsdom": "25.0.1",
"shoukaku": "^4.3.0",
"undici": "^7.24.7"
"undici": "^7.24.7",
"youtubei.js": "^18.0.0"
}
}

View File

@@ -0,0 +1,84 @@
// YouTube poToken/visitorData 생성 후 Lavalink youtube-plugin 런타임 엔드포인트(POST /youtube)에 주입.
// googlevideo 재생 403(스트림 거절) 우회용. 런타임 주입은 휘발성(Lavalink 재시작 시 소멸, 토큰도 수 시간 뒤 만료)이라
// 봇이 노드 ready 시점과 일정 주기로 이 스크립트를 자식 프로세스로 실행한다.
// 독립 ESM 프로세스로 돌려 BotGuard 생성 시의 메모리 스파이크를 봇 본체와 격리한다.
//
// 필요 env: LAVALINK_HOST, LAVALINK_PORT, LAVALINK_PW
import { Innertube } from "youtubei.js";
import { BG } from "bgutils-js";
import { JSDOM } from "jsdom";
const HOST = process.env.LAVALINK_HOST?.trim();
const PORT = process.env.LAVALINK_PORT?.trim();
const PW = process.env.LAVALINK_PW?.trim();
const REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
function log(msg) {
process.stdout.write(`[potoken] ${msg}\n`);
}
if (!HOST || !PORT || !PW) {
log("FAIL: LAVALINK_HOST/PORT/PW env 누락");
process.exit(1);
}
async function generate() {
const innertube = await Innertube.create({ retrieve_player: false });
const visitorData = innertube.session.context.client.visitorData;
if (!visitorData) throw new Error("visitorData 획득 실패");
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", { url: "https://www.youtube.com/" });
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
location: dom.window.location,
origin: dom.window.origin,
});
if (!Reflect.has(globalThis, "navigator"))
Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator });
const bgConfig = {
fetch: (url, options) => fetch(url, options),
globalObj: globalThis,
identifier: visitorData,
requestKey: REQUEST_KEY,
};
const challenge = await BG.Challenge.create(bgConfig);
if (!challenge) throw new Error("BotGuard challenge 생성 실패");
const js = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (js) new Function(js)();
else throw new Error("BotGuard interpreter 로드 실패");
const pot = await BG.PoToken.generate({
program: challenge.program,
globalName: challenge.globalName,
bgConfig,
});
if (!pot?.poToken) throw new Error("poToken 생성 실패");
return { visitorData, poToken: pot.poToken };
}
async function inject({ visitorData, poToken }) {
const res = await fetch(`http://${HOST}:${PORT}/youtube`, {
method: "POST",
headers: { Authorization: PW, "Content-Type": "application/json" },
body: JSON.stringify({ poToken, visitorData }),
});
return res.status;
}
try {
const pair = await generate();
const status = await inject(pair);
if (status === 204) {
log(`OK poToken 주입 완료 (visitorData ${pair.visitorData.length}자, poToken ${pair.poToken.length}자)`);
process.exit(0);
}
log(`WARN 주입 응답 http ${status}`);
process.exit(2);
} catch (e) {
log(`FAIL ${e && e.stack ? e.stack : e}`);
process.exit(1);
}

View File

@@ -0,0 +1,72 @@
"""
[실험 후보] 유튜브 내부 API(VISIONOS 클라이언트) 직접 호출로 오디오 URL을 얻는 리졸버 조각.
운영 리졸버(scripts/ytresolver.py)의 _do_resolve 맨 앞에 0순위로 끼워 넣어 쓴다.
yt-dlp 를 아예 거치지 않으므로 리졸브가 0.3초대로 떨어진다(상주 lib 1.7~2.3초).
동작 조건
- 대상이 유튜브 영상 URL 이어야 한다(영상 ID 필요). 검색어면 None 을 돌려 기존 경로로 폴백.
- visitorData 가 있어야 한다. 없으면 playabilityStatus=LOGIN_REQUIRED 로 실패한다.
운영 적용 시에는 봇이 2시간마다 생성하는 visitorData 를 공유받도록 배선해야 한다.
실측(2026-09-17)
추출 0.30~0.38s / itag 251(yt-dlp 와 동일 포맷) / Lavalink loadType=track
range 0·1MB·2MB·3MB 전부 206 (이어받기 정상) / 실제 재생 90초 끊김 0
"""
import json
import os
import re
import urllib.request
from urllib.parse import unquote
# 운영 적용 시에는 파일 대신 봇이 갱신하는 값을 받아오도록 교체할 것.
VISITOR_DATA_PATH = os.environ.get("YT_VISITOR_DATA_PATH", "/tmp/vd.txt")
_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/26.0 Safari/605.1.15")
_VID_RE = re.compile(r"(?:v=|youtu\.be/|/shorts/|/embed/|/live/)([A-Za-z0-9_-]{11})")
def _visitor_data():
if not os.path.exists(VISITOR_DATA_PATH):
return ""
return unquote(open(VISITOR_DATA_PATH).read().strip())
def innertube_resolve(target):
"""유튜브 URL -> 최고 비트레이트 오디오 직링크. 불가하면 None(호출측이 폴백)."""
m = _VID_RE.search(target)
visitor = _visitor_data()
if not m or not visitor:
return None
ctx = {
"clientName": "VISIONOS", "clientVersion": "1.02",
"deviceMake": "Apple", "deviceModel": "RealityDevice17,1",
"osName": "visionOS", "osVersion": "26.5.23O471",
"hl": "ko", "gl": "KR", "visitorData": visitor,
}
body = {"videoId": m.group(1), "context": {"client": ctx},
"contentCheckOk": True, "racyCheckOk": True}
req = urllib.request.Request(
"https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"User-Agent": _UA,
"X-Goog-Visitor-Id": visitor,
"X-Youtube-Client-Name": "101",
"X-Youtube-Client-Version": "1.02",
},
)
data = json.loads(urllib.request.urlopen(req, timeout=15).read())
if data.get("playabilityStatus", {}).get("status") != "OK":
return None
formats = (data.get("streamingData") or {}).get("adaptiveFormats") or []
audio = [f for f in formats
if str(f.get("mimeType", "")).startswith("audio") and f.get("url")]
if not audio:
return None
audio.sort(key=lambda f: f.get("bitrate", 0), reverse=True)
return audio[0]["url"]

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
ytresolver - yt-dlp 오디오 URL 리졸버 (HTTP 서비스)
봇(GuildPlayer.resolvePlayable)이 유튜브/스포티파이-미러 트랙을 재생하기 직전에 호출한다.
Lavalink의 youtube-plugin은 집 IP의 SABR 차단/성인인증(연령제한) 때문에 실재생이 막히므로,
여기서 yt-dlp로 "직접 오디오 URL"을 뽑아 봇에 돌려주고 봇은 그 URL을 Lavalink http 소스로 재생한다.
동작:
1) 먼저 익명(쿠키 없이)으로 시도 -> 일반 영상은 계정을 태우지 않는다(밴 리스크 최소화).
2) 실패하면 쿠키 + node(EJS 시그니처 해독) 로 재시도 -> 성인인증(연령제한) 영상 우회.
환경변수:
YTRESOLVER_COOKIE : Netscape 쿠키 파일 경로 (기본 /root/bot/db/ytcookie.txt)
YTRESOLVER_PORT : 리슨 포트 (기본 8779)
YTDLP : yt-dlp 실행 경로 (기본 /usr/local/bin/yt-dlp)
HTTP API:
GET /resolve?url=<유튜브 URL | ytsearch: | 검색어> -> {"url": "<직접 오디오 URL>"}
GET /health -> {"ok": true}
"""
import json, subprocess, time, threading, os, sys
sys.path.insert(0, os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")) # zipapp 내부 모듈 임포트
import yt_dlp
_YDL_OPTS = {"format": "bestaudio/best", "noplaylist": True, "quiet": True,
"no_warnings": True, "nocheckcertificate": True, "socket_timeout": 15,
"extractor_args": {"youtube": {"player_client": ["default"]}}}
_ydl = yt_dlp.YoutubeDL(_YDL_OPTS) # 프로세스 상주(임포트/초기화 1회)
_ydl_lock = threading.Lock()
def lib_resolve(target):
print("[lib] enter " + target[:40], file=sys.stderr, flush=True)
# yt-dlp 를 라이브러리로 직접 호출: 매 요청 프로세스 기동(약 0.4s) 을 없앤다.
q = target if (target.startswith("http") or target.startswith("ytsearch")) else "ytsearch1:" + target
with _ydl_lock:
info = _ydl.extract_info(q, download=False)
print("[lib] got info keys=" + str(list(info.keys())[:6]), file=sys.stderr, flush=True)
if info.get("entries"):
info = info["entries"][0]
u = info.get("url")
if not u:
for f in (info.get("requested_formats") or []):
if f.get("url"): u = f["url"]; break
return u
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")
COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt")
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
CACHE = {}
INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing)
LOCK = threading.Lock()
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
# 속도/안정성 공통 플래그
COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings",
"--no-check-certificate", "--socket-timeout", "15"]
def run_ytdlp(target, use_cookies=False):
if use_cookies and os.path.exists(COOKIE_FILE):
# 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요
cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE,
*COMMON, "--extractor-args", "youtube:player_client=web"]
else:
# 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합.
cmd = [YTDLP, *COMMON,
"--extractor-args", "youtube:player_client=default,web_safari,tv"]
cmd += ["--get-url", target]
return subprocess.run(cmd, capture_output=True, text=True, timeout=45)
def first_url(out):
lines = (out.stdout or "").strip().splitlines()
return lines[0] if lines else None
def _do_resolve(target):
# 1순위: 라이브러리 상주 호출(가장 빠름). 실패하면 기존 subprocess 경로로 폴백.
try:
u = lib_resolve(target)
if u and u.startswith("http"):
print("[result] OK(lib) " + u[:50], file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (u, time.time() + TTL)
return u
except Exception as e:
print("[lib-fail] " + str(e)[:150], file=sys.stderr, flush=True)
try:
out = run_ytdlp(target, use_cookies=False)
url = first_url(out)
if not (url and url.startswith("http")) and os.path.exists(COOKIE_FILE):
print(f"[retry-cookies] {(out.stderr or out.stdout or '')[:180]}", file=sys.stderr, flush=True)
out = run_ytdlp(target, use_cookies=True)
url = first_url(out)
except subprocess.TimeoutExpired:
print("[result] TIMEOUT", file=sys.stderr, flush=True)
return None
if not (url and url.startswith("http")):
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
return None
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (url, time.time() + TTL)
return url
def resolve(target):
now = time.time()
with LOCK:
hit = CACHE.get(target)
if hit and hit[1] > now:
return hit[0]
# 같은 target이 이미 리졸브 중이면, 새 yt-dlp를 띄우지 않고 그 결과를 기다린다.
# (봇이 음성 접속과 동시에 미리 리졸브를 걸어두면, 실제 재생 요청이 이 진행분에 합쳐져
# 음성 접속 시간과 리졸브 시간이 겹쳐 첫 재생 지연이 크게 줄어든다.)
entry = INFLIGHT.get(target)
owner = entry is None
if owner:
entry = {"event": threading.Event(), "url": None}
INFLIGHT[target] = entry
if not owner:
entry["event"].wait(50)
with LOCK:
hit = CACHE.get(target)
return hit[0] if hit and hit[1] > time.time() else entry.get("url")
# owner: 실제 리졸브 수행
url = None
try:
url = _do_resolve(target)
except Exception as e:
print(f"[result] ERR {str(e)[:120]}", file=sys.stderr, flush=True)
finally:
entry["url"] = url
with LOCK:
INFLIGHT.pop(target, None)
entry["event"].set()
return url
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
p = urlparse(self.path)
q = parse_qs(p.query)
if p.path == "/health":
self._send(200, {"ok": True, "cookie": os.path.exists(COOKIE_FILE)})
return
if p.path != "/resolve":
self._send(404, {"error": "not found"})
return
target = (q.get("url") or q.get("q") or [None])[0]
print(f"[resolve] {target[:80] if target else None}", file=sys.stderr, flush=True)
if not target:
self._send(400, {"error": "missing url/q"})
return
if not (target.startswith("http") or target.startswith("ytsearch")):
target = "ytsearch1:" + target
url = resolve(target)
if url:
self._send(200, {"url": url})
else:
self._send(502, {"error": "resolve failed"})
def _send(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
if __name__ == "__main__":
print(f"ytresolver listening on :{PORT} (cookie={COOKIE_FILE}, exists={os.path.exists(COOKIE_FILE)})",
file=sys.stderr, flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()

View File

@@ -1,5 +1,6 @@
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
export class BotClient extends Client {
public prefix = Config.prefix;
@@ -69,8 +70,10 @@ export class BotClient extends Client {
setTimeout(async () => {
try {
const msg = await message.fetch(true).catch(() => undefined);
if (msg?.deletable) msg.delete().catch(() => {});
} catch {};
if (msg?.deletable) msg.delete().catch((err) => {
Logger.warn(`[BotClient] 메세지 삭제 실패: ${String(err)}`);
});
} catch {}
}, Math.max(100, time * (customTime ? 1 : 6000)));
}
}

View File

@@ -1,5 +1,5 @@
import { Guild, Message, TextChannel } from "discord.js";
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent } from "shoukaku";
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent, type WebSocketClosedEvent } from "shoukaku";
import { client, lavalinkManager, Redis } from "../index";
import { timeFormat } from "../utils/music/Utils";
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
@@ -8,11 +8,19 @@ import { DB } from "../utils/Database";
import { shuffle } from "../utils/Shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger";
import { Config } from "../utils/Config";
const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10;
// 재생 위치가 진행되지 않는지 감시하는 주기 / 멈춤으로 판단하는 임계값
const StallCheckIntervalMs = 1000 * 5;
const StallThresholdMs = 1000 * 20;
const MaxLoadFailRetry = 3;
const LoadFailRetryDelayMs = 2500;
type QueueTrack = Track & { userId: string; };
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; };
export class GuildPlayer {
private isDead = false;
@@ -22,6 +30,11 @@ export class GuildPlayer {
public queue: QueueTrack[] = [];
private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined;
private closedTimer: NodeJS.Timeout | undefined;
private stallWatchdog: NodeJS.Timeout | undefined;
private lastPosition = 0;
private lastProgressAt = 0;
private loadFailCount = 0;
constructor(
public guild: Guild,
@@ -31,14 +44,48 @@ export class GuildPlayer {
public msg: Message,
) {
this.player.setGlobalVolume(50);
this.attachPlayerListeners();
}
private attachPlayerListeners() {
this.player.on("start", (_data: TrackStartEvent) => {
// endTimer가 남아있으면 제거 (새 곡 재생 시작)
if (this.endTimer !== undefined) {
clearTimeout(this.endTimer);
this.endTimer = undefined;
}
// 새 곡이 시작되면 재생 위치 멈춤 감시 시작
this.startStallWatchdog();
// 현재 곡이 재생되는 동안 다음 곡을 미리 리졸브(곡 전환 지연 제거)
this.prefetch(this.queue[1]);
Redis?.publishState("player_update", {
guildId: this.guild.id,
});
});
this.player.on("end", async (data: TrackEndEvent) => {
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) {
@@ -48,46 +95,118 @@ export class GuildPlayer {
} else {
this.end();
}
} catch (err) {
Logger.error(`[GuildPlayer] end 이벤트 처리 중 에러: ${String(err)}`);
}
});
this.player.on("closed", () => {
this.player.on("closed", (data: WebSocketClosedEvent) => {
if (this.isDead) return;
Logger.info(`[GuildPlayer] 음성 연결이 끊어졌습니다. 재접속을 대기합니다...`);
setTimeout(() => {
Logger.warn(
`[GuildPlayer] 음성 WS 끊김 (code=${data.code}, reason="${data.reason || "(none)"}", byRemote=${data.byRemote}). 5초 후 복구 시도...`,
);
// 이전 closed 타이머가 있으면 제거
if (this.closedTimer !== undefined) {
clearTimeout(this.closedTimer);
}
this.closedTimer = setTimeout(async () => {
this.closedTimer = undefined;
if (this.isDead) return;
// 5초가 지났는데도 연결이 복구되지 않았을 때만 방을 나갑니다.
// 디스코드 방에 내 봇(me)이 없으면 봇을 삭제(delete)한다!
if (!this.guild.members.me?.voice?.channelId) {
const meChannelId = this.guild.members.me?.voice?.channelId;
if (!meChannelId) {
Logger.warn(`[GuildPlayer] 음성채널에 봇이 없습니다. player를 초기화합니다.`);
return this.delete();
}
/**
* declare enum State {
* CONNECTING = 0,
* CONNECTED = 1,
* DISCONNECTING = 2,
* DISCONNECTED = 3
* }
*/
// (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으니 안전하게 확인)
if (this.player && this.player.node.state !== 1) {
Logger.warn(`[GuildPlayer] 연결 복구 실패. 봇을 퇴장시킵니다.`);
return this.delete();
// 봇이 아직 보이스 채널에 남아있으면 자가 회복 시도
try {
await this.reconnect(meChannelId);
} catch (err) {
Logger.error(`[GuildPlayer] 음성 재접속 실패: ${String(err)}. player를 초기화합니다.`);
this.delete();
}
}, 5000);
});
this.player.on("exception", async (data) => {
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`);
await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다.");
try {
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
} catch (err) {
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
}
});
this.player.on("stuck", async (data) => {
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`);
await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다.");
try {
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과. 다음 곡으로 넘어갑니다.`);
// errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수 있으므로,
// stopTrack으로 end 이벤트를 유도해 실제로 다음 곡으로 넘긴다.
await this.player.stopTrack();
} catch (err) {
Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`);
}
});
}
/** 음성 WS가 끊겼지만 봇이 아직 보이스 채널에 남아있을 때, player를 새로 만들어 현재 곡을 이어서 재생. */
private async reconnect(targetChannelId: string) {
const currentTrack = this.nowTrack;
const currentPosition = this.position;
const wasPaused = this.isPaused;
const savedVolume = this.player.volume;
Logger.info(
`[GuildPlayer] 음성 재접속 시도 (channel=${targetChannelId}, track="${currentTrack?.info.title ?? "(none)"}", position=${currentPosition}ms, paused=${wasPaused})`,
);
// 기존 player listener 해제 + 정리
this.player.removeAllListeners();
try {
await this.player.destroy();
} catch (err) {
Logger.warn(`[GuildPlayer] 기존 player.destroy 중 에러(무시): ${String(err)}`);
}
try {
await lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
} catch (err) {
Logger.warn(`[GuildPlayer] 기존 leaveVoiceChannel 중 에러(무시): ${String(err)}`);
}
if (this.isDead) return;
// 게이트웨이 측 정리 대기
await new Promise((r) => setTimeout(r, 500));
if (this.isDead) return;
// 재접속 (봇이 옮겨졌을 수도 있으니 me의 현재 채널로)
const newPlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
guildId: this.guild.id,
channelId: targetChannelId,
shardId: this.guild.shardId,
deaf: true,
mute: false,
});
this.player = newPlayer;
this.voiceChannelId = targetChannelId;
this.attachPlayerListeners();
await this.player.setGlobalVolume(savedVolume || 50);
// 곡이 있었으면 이어서 재생
if (currentTrack) {
const reEncoded = await this.resolvePlayable(currentTrack);
await this.player.playTrack({
track: { encoded: reEncoded },
position: currentPosition,
paused: wasPaused,
});
Logger.info(`[GuildPlayer] 음성 재접속 성공. 곡을 이어서 재생합니다.`);
} else {
Logger.info(`[GuildPlayer] 음성 재접속 성공 (재생 중인 곡 없음).`);
}
}
private get GDB() {
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
return this._GDB;
@@ -118,7 +237,8 @@ export class GuildPlayer {
// 큐가 비어있으면 바로 재생
await this.playNext();
} else {
// 재생목록 추가되었을때
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true });
}
}
@@ -127,18 +247,67 @@ export class GuildPlayer {
const first = this.queue.length === 0;
for (const track of tracks) this.queue.push({ ...track, userId });
if (first) {
// 큐가 비어있으면 바로 재생
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
this.prefetch(this.queue[1]);
await this.playNext();
} else {
// 재생목록 추가되었을때
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true });
}
}
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
private async resolvePlayable(track: QueueTrack): Promise<string> {
try {
// 실험: ytresolver 비활성화 시 Lavalink youtube-source(poToken)로 직접 재생 — yt-dlp 단계 생략.
if (!Config.ytresolverEnabled) return track.encoded;
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 r = await fetch(`${Config.ytresolverUrl}/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;
}
}
/** 트랙의 재생용 encoded를 미리(백그라운드) 리졸브해 캐시한다. 이미 진행 중이면 무시. */
private prefetch(track?: QueueTrack) {
if (!track || track._resolved) return;
track._resolved = this.resolvePlayable(track).catch((e) => {
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
return track.encoded;
});
}
/** 재생용 encoded를 가져온다. 프리페치되어 있으면 즉시, 아니면 지금 리졸브한다. */
private getResolved(track: QueueTrack): Promise<string> {
if (!track._resolved) this.prefetch(track);
return track._resolved!;
}
public async playNext() {
const track = this.queue[0];
if (!track) return this.end();
await this.player.playTrack({ track: { encoded: track.encoded } });
const tResolve = Date.now();
const encoded = await this.getResolved(track);
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
this.prefetch(this.queue[1]);
await this.player.playTrack({ track: { encoded } });
// 재생관련
this.setMsg();
}
@@ -196,12 +365,50 @@ export class GuildPlayer {
if (!this.lastPlayedTrack) return;
const source = this.lastPlayedTrack.info.sourceName;
const trackId = this.lastPlayedTrack.info.identifier;
const title = this.lastPlayedTrack.info.title;
const author = this.lastPlayedTrack.info.author;
let result;
let usedYtFallback = false;
// 중복 제거 기준 ID. 기본은 lastPlayed의 identifier, 다만 Spotify→YouTube 폴백 시에는
// RD 믹스의 시드(YouTube videoId)와 비교해야 첫 곡(=방금 끝난 곡의 YouTube 버전)을 제거할 수 있음.
let dedupId = trackId;
if (source === "spotify") {
result = await this.player.node.rest.resolve(`sprec:seed_tracks=${trackId}&limit=11`);
} else {
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
const sprecUri = `sprec:seed_tracks=${trackId}&limit=11`;
try {
result = await this.player.node.rest.resolve(sprecUri);
Logger.info(`[autoPlay] sprec loadType=${result?.loadType ?? "(null)"} for "${author} - ${title}"`);
} catch (err) {
Logger.warn(`[autoPlay] sprec resolve throw: ${String(err)}`);
result = undefined;
}
// 2024-11-27 이후 신규/development 모드 앱은 Spotify Recommendations API 접근이 막혀서
// sprec이 EMPTY로 떨어지는 경우가 있음. extended 모드 토큰이면 동작함.
// 결과 없으면 YouTube로 폴백.
if (!result
|| result.loadType === LoadType.EMPTY
|| result.loadType === LoadType.ERROR
|| (result.loadType === LoadType.PLAYLIST && result.data.tracks.length === 0)
) {
if (result?.loadType === LoadType.ERROR) {
Logger.warn(`[autoPlay] sprec ERROR: ${result.data?.message ?? "unknown"}. YouTube으로 폴백합니다.`);
} else {
Logger.warn(`[autoPlay] sprec 결과 없음. YouTube으로 폴백합니다.`);
}
const fallback = await this.youtubeMixFromSpotifyTrack(author, title);
result = fallback?.result;
if (fallback?.seedYtId) dedupId = fallback.seedYtId;
usedYtFallback = true;
}
} else {
try {
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
} catch (err) {
Logger.warn(`[autoPlay] YouTube mix resolve throw: ${String(err)}`);
result = undefined;
}
}
let tracks: Track[] = [];
if (result?.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks;
@@ -210,20 +417,118 @@ export class GuildPlayer {
} else if (result?.loadType === LoadType.TRACK) {
tracks = [ result.data ];
}
Logger.info(`[autoPlay] 자동재생 후보 ${tracks.length}곡${usedYtFallback ? " (YouTube 폴백)" : ""}`);
if (tracks.length === 0) {
this.end();
return;
}
if (tracks.length > 0 && tracks[0].info.identifier === dedupId) tracks = tracks.slice(1);
if (tracks.length === 0) {
this.end();
return;
}
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
this.addTracks(tracks, "자동재생");
}
public end() {
/**
* Spotify 트랙 메타데이터로 YouTube 검색 후 매칭된 곡의 RD 믹스를 가져옴.
* (현재 Lavalink에서 `ytmsearch:`는 EMPTY를 자주 반환하므로 `ytsearch:` 사용)
* 각 단계가 실패하면 다음 단계로 진행하지 않고 undefined 반환.
* 호출 측에서 RD 믹스 첫 곡(=시드 자체)을 중복 제거할 수 있도록 seed videoId도 같이 반환.
*/
private async youtubeMixFromSpotifyTrack(author: string, title: string) {
const cleanAuthor = author.replace(" - Topic", "");
const query = `ytsearch:${cleanAuthor} ${title}`;
let searchResult;
try {
searchResult = await this.player.node.rest.resolve(query);
} catch (err) {
Logger.warn(`[autoPlay] YouTube 폴백 검색 throw: ${String(err)}`);
return undefined;
}
if (
searchResult?.loadType !== LoadType.SEARCH
|| !Array.isArray(searchResult.data)
|| searchResult.data.length === 0
) {
Logger.warn(`[autoPlay] YouTube 폴백 검색 결과 없음 (query="${cleanAuthor} ${title}", loadType=${searchResult?.loadType ?? "(null)"})`);
return undefined;
}
const seedYtId = searchResult.data[0].info.identifier;
Logger.info(`[autoPlay] YouTube 폴백 매칭: videoId=${seedYtId}`);
try {
const result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${seedYtId}&list=RD${seedYtId}`);
return { result, seedYtId };
} catch (err) {
Logger.warn(`[autoPlay] YouTube 폴백 RD 믹스 resolve throw: ${String(err)}`);
return undefined;
}
}
/**
* 재생 위치가 일정 시간 동안 전혀 진행되지 않으면(=버퍼링/멈춤) 다음 곡으로 넘긴다.
* Lavalink가 TrackStuckEvent를 못 던지는 트리클 스트리밍(느린 버퍼링) 상황까지 커버하는 백스탑.
*/
private startStallWatchdog() {
this.stopStallWatchdog();
this.lastPosition = this.position;
this.lastProgressAt = Date.now();
this.stallWatchdog = setInterval(() => {
if (this.isDead) {
this.stopStallWatchdog();
return;
}
// 재생 중이 아니거나 일시정지면 정상 상태 → 진행시각 리셋
if (!this.isPlaying || this.isPaused) {
this.lastPosition = this.player.position;
this.lastProgressAt = Date.now();
return;
}
const pos = this.player.position;
if (pos > this.lastPosition + 500) {
// 정상적으로 재생 위치가 진행됨
this.lastPosition = pos;
this.lastProgressAt = Date.now();
return;
}
// 재생 위치가 StallThresholdMs 동안 전혀 진행되지 않음 → 멈춤으로 판단하고 스킵
if (Date.now() - this.lastProgressAt >= StallThresholdMs) {
Logger.error(`[GuildPlayer] 재생 위치가 ${StallThresholdMs}ms 동안 진행되지 않음(멈춤 감지). 다음 곡으로 넘어갑니다. (position=${pos}ms)`);
this.lastPosition = pos;
this.lastProgressAt = Date.now();
// stopTrack → end 이벤트 → 다음 곡 진행
this.player.stopTrack().catch((err) => {
Logger.error(`[GuildPlayer] 멈춤 감지 후 stopTrack 에러: ${String(err)}`);
});
}
}, StallCheckIntervalMs);
}
private stopStallWatchdog() {
if (this.stallWatchdog !== undefined) {
clearInterval(this.stallWatchdog);
this.stallWatchdog = undefined;
}
}
private clearAllTimers() {
if (this.errorTimer !== undefined) {
clearTimeout(this.errorTimer);
this.errorTimer = undefined;
}
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
if (this.endTimer !== undefined) {
clearTimeout(this.endTimer);
this.endTimer = undefined;
}
if (this.closedTimer !== undefined) {
clearTimeout(this.closedTimer);
this.closedTimer = undefined;
}
this.stopStallWatchdog();
}
public end() {
this.clearAllTimers();
this.endTimer = setTimeout(() => {
this.endTimer = undefined;
this.delete(true);
@@ -238,7 +543,10 @@ export class GuildPlayer {
if (this.isDead) return;
if (!afterEnd) this.end();
this.isDead = true;
this.player.destroy().catch(() => {});
this.clearAllTimers();
this.player.destroy().catch((err) => {
Logger.error(`[GuildPlayer] player.destroy 에러: ${String(err)}`);
});
lavalinkManager.delPlayer(this.guild.id);
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
}
@@ -309,7 +617,7 @@ export class GuildPlayer {
});
}
public async errMsg(reason: string) {
public async errMsg(reason: string, retry: boolean = true) {
if (!this.msg) return null;
await this.msg.edit({
content: this.msg.content,
@@ -325,10 +633,14 @@ export class GuildPlayer {
this.queue.length > 1,
) ]
});
// retry=true일 때만 5초 뒤 현재 queue[0]을 재생. 재생 실패(exception)처럼 Lavalink가
// 이미 end 이벤트로 다음 곡을 진행시키는 경우엔 retry=false로 호출해 중복 진행을 막는다.
if (retry) {
this.errorTimer = setTimeout(() => {
this.errorTimer = undefined;
this.playNext();
}, DelayAfterErrMs);
}
return null;
}
}

View File

@@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, Collection } from "discord.js";
import { readdirSync } from "node:fs";
import { Command } from "../types/Command";
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
import { Logger } from "../utils/Logger";
export class Handler {
public commands: Collection<string, Command> = new Collection();
@@ -15,11 +16,16 @@ export class Handler {
}
}
public runCommand(interaction: ChatInputCommandInteraction) {
public async runCommand(interaction: ChatInputCommandInteraction) {
const commandName = interaction.commandName;
const command = this.commands.get(commandName);
if (!command) return;
if (command.slashRun) command.slashRun(interaction);
try {
if (command.slashRun) await command.slashRun(interaction);
} catch (err) {
Logger.error(`[Handler] 명령어 '${commandName}' 실행 중 에러: ${String(err)}`);
await interaction.editReply({ content: "명령어 실행 중 오류가 발생했습니다." }).catch(() => {});
}
}
}

View File

@@ -1,5 +1,7 @@
import { Connectors, LoadType, Shoukaku, Track } from "shoukaku";
import { Client } from "discord.js";
import { spawn } from "node:child_process";
import { join } from "node:path";
import { GuildPlayer } from "./GuildPlayer";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
@@ -25,10 +27,74 @@ export class LavalinkManager {
this.shoukaku.on("ready", (name) => {
Logger.ready(`[LavalinkManager] 노드 [${name}] 연결 완료!`);
// 노드가 (재)연결될 때마다 poToken 주입 — Lavalink 재시작 후에도 자동 복구
this.refreshPoToken();
});
this.shoukaku.on("error", (name, err) => {
Logger.error(`[LavalinkManager] 노드 [${name}] 에러: ${String(err)}`);
});
// poToken은 수 시간 뒤 만료되므로 주기적으로 재생성/재주입 (2시간)
setInterval(() => this.refreshPoToken(), 2 * 60 * 60 * 1000).unref();
}
/**
* googlevideo 재생 403 우회용 poToken/visitorData를 생성해 Lavalink youtube-plugin에 주입한다.
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
*/
private refreshPoToken(): void {
// [실험] poToken 주입을 끄고 비교하기 위한 플래그. 미설정 시 기존 동작(주입).
if (process.env.POTOKEN_INJECT?.trim()?.toLocaleLowerCase() === "false") {
Logger.warn(`[LavalinkManager] [실험] POTOKEN_INJECT=false — poToken 주입 생략`);
return;
}
const script = join(__dirname, "..", "..", "scripts", "potoken-refresh.mjs");
const child = spawn(process.execPath, [script], {
env: {
...process.env,
LAVALINK_HOST: Config.lavalink.host,
LAVALINK_PORT: String(Config.lavalink.port),
LAVALINK_PW: Config.lavalink.pw,
},
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
child.stdout?.on("data", (d) => { out += String(d); });
child.stderr?.on("data", (d) => { out += String(d); });
child.on("error", (err) => {
Logger.error(`[LavalinkManager] poToken 갱신 실행 실패: ${String(err)}`);
});
child.on("close", (code) => {
const trimmed = out.trim();
if (code === 0) {
const last = trimmed.split("\n").pop() || "poToken 주입 완료";
Logger.ready(`[LavalinkManager] ${last}`);
} else {
// import/로드 단계 크래시는 마지막 줄이 "Node.js vXX"뿐이므로 전체 출력 꼬리를 남긴다
Logger.error(`[LavalinkManager] poToken 갱신 실패(code ${code}):\n${trimmed.slice(-1500)}`);
}
});
}
/** 연결 가능한(CONNECTED 상태) Lavalink 노드가 하나라도 있는지 확인 */
hasReadyNode(): boolean {
return !!this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
}
/**
* 유튜브 URL을 음성 접속 전에 미리 ytresolver에 걸어둔다(fire-and-forget).
* 리졸버가 진행 중 요청을 합쳐주므로(coalescing), 음성 접속(~2초) 동안 리졸브(~2.5초)가
* 겹쳐 진행돼 실제 재생 시점엔 캐시 적중 → 첫 재생 지연이 크게 줄어든다.
* resolvePlayable이 쓰는 캐시 키(info.uri = https://www.youtube.com/watch?v=ID)와 맞추기 위해
* 표준 watch URL로 변환해 요청한다.
*/
public prewarmYoutubeUrl(raw: string) {
try {
const m = raw.match(/(?:youtu\.be\/|[?&]v=|\/shorts\/|\/embed\/|\/live\/)([A-Za-z0-9_-]{11})/);
if (!m) return;
const canonical = `https://www.youtube.com/watch?v=${m[1]}`;
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(canonical)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});
} catch { /* prewarm 실패는 무시 (실제 재생 시 정상 경로로 리졸브) */ }
}
getPlayer(guildId: string) {
@@ -41,6 +107,101 @@ export class LavalinkManager {
this.players.delete(guildId);
}
/**
* 쿼리를 Lavalink 트랙으로 '해석'만 한다. 플레이어(=음성접속)가 필요 없으므로
* 음성채널 접속과 동시에 돌릴 수 있다(첫 재생 지연 단축).
* 해석이 끝나면 첫 곡의 yt-dlp 리졸브를 즉시 예열(prewarm)해, 음성접속이 끝날 즈음엔
* 오디오 URL이 이미 준비되도록 한다.
* 못 찾으면 null.
*/
public async resolveQuery(query: string): Promise<{ tracks: Track[]; playlist: boolean; flags: Set<string> } | null> {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const { isUrl, text, flags } = parseLink(query.trim());
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
// 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
// (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
let searchText: string;
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
if (isUrl) searchText = text;
else if (flags.has('p')) searchText = (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
else {
const fallbackText = `ytsearch:${text}` + (flags.has('o') ? "" : " Topic");
pendingFallback = node.rest.resolve(fallbackText);
pendingFallback.catch(() => {}); // 적중해서 버려질 때 unhandledRejection 방지
const ytm = await YoutubeMusic.getSearchUrl(text).catch(() => null);
searchText = ytm ?? fallbackText;
if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음
}
// 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
// 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
const spotifyTrackId = Spotify.parseTrackId(searchText);
if (spotifyTrackId) {
const meta = await Spotify.getTrackById(spotifyTrackId);
if (meta) {
const q = `${meta.title} ${meta.artist}`.trim();
searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
}
}
if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
// 위에서 미리 띄워둔 폴백 검색이 있으면 그 결과를 그대로 쓴다(중복 요청 방지).
const result = pendingFallback ? await pendingFallback : await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
return null;
}
let tracks: Track[] = [];
let playlist = false;
if (result.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks;
playlist = true;
} else if (result.loadType === LoadType.TRACK) {
tracks = [result.data];
} else if (result.loadType === LoadType.SEARCH) {
tracks = result.data.length > 0 ? [result.data[0]] : [];
}
if (tracks.length === 0) return null;
// 첫 곡 오디오(yt-dlp) 리졸브를 지금 바로 걸어둔다 — 음성접속과 겹쳐 진행된다.
this.prewarmTrack(tracks[0]);
return { tracks, playlist, flags };
}
/**
* resolveQuery 결과를 플레이어 큐에 반영한다(기존 search 후반부와 동일 동작).
*/
public async addResolved(
player: GuildPlayer,
resolved: { tracks: Track[]; playlist: boolean; flags: Set<string> },
userId: string,
) {
if (resolved.playlist) {
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId);
return;
}
await player.addTrack(resolved.tracks[0], userId);
}
/**
* 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget).
* GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.
*/
public prewarmTrack(track: Track) {
try {
if (!Config.ytresolverEnabled) return; // yt-dlp 미사용 실험 모드에서는 예열 자체가 불필요
const info: any = (track as any).info ?? {};
const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return;
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});
} catch { /* prewarm 실패는 무시 (실제 재생 시 정상 경로로 리졸브) */ }
}
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) {
@@ -49,36 +210,13 @@ export class LavalinkManager {
}
player = player ?? this.getPlayer(guildId);
if (!player) return;
const { isUrl, text, flags } = parseLink(query.trim());
let searchText = isUrl ? text : (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (flags.has('y')) searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
const result = await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
const resolved = await this.resolveQuery(query);
if (!resolved) {
// 노래 못찾았을때
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
if (result.loadType === LoadType.PLAYLIST) {
player.addTracks(
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
userId
);
return;
}
if (result.loadType === LoadType.TRACK) {
player.addTrack(result.data, userId);
return;
}
if (result.loadType === LoadType.SEARCH) {
if (result.data.length === 0) {
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
player.addTrack(result.data[0], userId);
return;
}
await this.addResolved(player, resolved, userId);
}
public async youtubeSearch(query: string): Promise<Track[]> {

View File

@@ -63,48 +63,78 @@ class RedisClientClass {
thumbnail: video.info.artworkUrl ?? "",
duration: video.info.length,
}));
await this.pub.setex(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
await this.reply(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
Logger.log(`[Redis Pub] [reply] 결과 저장: (${resultKey})`);
}
if (data.action === "player_play") {
const resultKey = `player:play:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 곡 해석(검색+Lavalink+yt-dlp 예열)은 음성접속과 무관하므로 먼저 출발시켜 둘을 겹친다.
const t0 = Date.now();
const resolving = lavalinkManager.resolveQuery(data.track.url)
.catch((e) => { Logger.warn(`[player_play] 곡 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
if (!voiceChannel) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolving;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
const tJoined = Date.now();
if (!player) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolved = await resolving;
const tResolved = Date.now();
if (!resolved) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolved, data.userId);
Logger.log(`[player_play] 음성접속 ${tJoined - t0}ms / 곡해석 대기 ${tResolved - tJoined}ms / 큐반영까지 ${Date.now() - t0}ms`);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
}
if (data.action === "player_playlist") {
const resultKey = `player:playlist:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 재생목록 해석도 음성접속과 병렬로 진행한다.
const resolvingPl = lavalinkManager.resolveQuery(data.playlistUrl)
.catch((e) => { Logger.warn(`[player_playlist] 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
if (!voiceChannel) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolvingPl;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolvedPl = await resolvingPl;
if (!resolvedPl) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolvedPl, data.userId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
}
if (data.action === "player_now") {
const resultKey = `player:now:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const nowGuild = await getGuildById(data.serverId);
if (!nowGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(nowGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.pub.setex(resultKey, 60, JSON.stringify({
await this.reply(resultKey, 60, JSON.stringify({
success: true,
botPlayer: !!player,
isPlaying: player?.isPlaying,
@@ -116,17 +146,19 @@ class RedisClientClass {
}
if (data.action === "queue_list") {
const resultKey = `queue:list:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const qlGuild = await getGuildById(data.serverId);
if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
}
if (data.action === "queue_set") {
const resultKey = `queue:set:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.newQueue) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.newQueue) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
@@ -135,73 +167,85 @@ class RedisClientClass {
for (const rawTrack of data.newQueue) {
if (rawTrack.encoded) context.player.queue.push(rawTrack);
}
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg();
}
if (data.action === "queue_remove") {
const resultKey = `queue:remove:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// index 는 number(0 도 유효) — typeof 검증으로 변경.
if (typeof data.index !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const numIndex = Number(data.index);
if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
if (numIndex >= context.player.queue.length-1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 queue.length보다 클수 없습니다." }));
const [removedTrack] = context.player.queue.splice(numIndex+1, 1);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
if (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
if (numIndex >= context.player.queue.length - 1) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
// 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인.
// SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지.
const removeTarget = context.player.queue[numIndex + 1];
if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." }));
}
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg();
}
if (data.action === "player_paused") {
const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
if (typeof data.isPaused !== "boolean") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
await context.player.setPause();
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
}
if (data.action === "player_skip") {
const resultKey = `player:skip:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (context.player.isPlaying) context.player.skip();
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_seek") {
const resultKey = `player:seek:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
if (typeof data.seek !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const duration = context.player.nowTrack?.info.length || 0;
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const duration = context.player.nowTrack.info.length || 0;
const numSeek = Number(data.seek);
if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
context.player.seek(numSeek);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_volume") {
const resultKey = `player:volume:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
if (typeof data.volume !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const numVolume = Number(data.volume);
if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
context.player.setVolume(numVolume);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
} catch (err) {
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
@@ -216,6 +260,17 @@ class RedisClientClass {
});
}
/**
* 사이트로 보내는 RPC 응답 쓰기.
* 기존 SET+폴링 대신, 사이트가 BRPOP 으로 블로킹 대기하도록 리스트에 LPUSH 한다.
* ttlSec 는 사이트가 이미 타임아웃된 뒤 남는 고아 응답을 자동 정리하기 위한 보험.
* (시그니처를 기존 setex(key, ttl, value) 와 동일하게 맞춰 호출부를 기계적으로 교체)
*/
private async reply(key: string, ttlSec: number, value: string): Promise<void> {
await this.pub.lpush(key, value);
await this.pub.expire(key, ttlSec);
}
public publishState(event: string, data: any) {
const payload = JSON.stringify({
event,
@@ -226,6 +281,16 @@ class RedisClientClass {
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
}
/**
* 요청한 userId가 해당 guild의 멤버인지 확인(캐시 우선, 없으면 단건 fetch).
* 대시보드가 보낸 serverId를 그대로 신뢰하지 않기 위한 인가 검증.
*/
private async isMember(guild: Guild, userId: string): Promise<boolean> {
if (guild.members.cache.has(userId)) return true;
const fetched = await guild.members.fetch(userId).catch(() => null);
return !!fetched;
}
private async getContext(guildId: string, resultKey: string, userId: string): Promise<{
ok: true;
guild: Guild;
@@ -234,20 +299,29 @@ class RedisClientClass {
} | { ok: false; }> {
const guild = await getGuildById(guildId);
if (!guild) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
return { ok: false };
}
// 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단)
if (!(await this.isMember(guild, userId))) {
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
return { ok: false };
}
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, userId);
if (!player) {
if (!voiceChannel) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
return { ok: false };
}
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
// 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
return { ok: false };
}
if (!player) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
return { ok: false };
}
return {

View File

@@ -4,6 +4,7 @@ import { Command } from "../types/Command";
import { clearAllMsg } from "../utils/music/Utils";
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
import { DB } from "../utils/Database";
import { Logger } from "../utils/Logger";
/** channel 명령어 */
export default class implements Command {
@@ -100,7 +101,7 @@ export async function channelRegister(guild: Guild | null, channelId: string | n
components: [ getButtons() ],
files: [ default_image ],
}).catch((err) => {
console.error(err);
Logger.error(`[Channel] 메세지 생성 실패: ${String(err)}`);
return null;
});
if (!msg) return client.mkembed({

View File

@@ -3,6 +3,7 @@ import { client, lavalinkManager } from "../index";
import { Command } from "../types/Command";
import { GuildPlayer } from "../classes/GuildPlayer";
import { getTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger";
/** join 명령어 */
export default class implements Command {
@@ -72,16 +73,39 @@ export async function channelJoin(guild: Guild | null, voiceChannelId: string |
}) };
let player = lavalinkManager.getPlayer(guild.id);
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player };
player = new GuildPlayer(
guild,
await lavalinkManager.shoukaku.joinVoiceChannel({
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player };
// 연결 가능한 Lavalink 노드가 없으면 joinVoiceChannel이 throw 되어 unhandledRejection으로 번짐.
// 노드 미연결 시 사용자에게 안내만 하고 종료.
if (!lavalinkManager.hasReadyNode()) {
return { embed: client.mkembed({
title: "음악 서버에 연결할 수 없습니다.",
description: "잠시 후 다시 시도해주세요.",
color: "DarkRed",
}) };
}
let voicePlayer;
try {
voicePlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
guildId: guild.id,
channelId: voiceChannel.id,
shardId: guild.shardId,
deaf: true,
mute: false,
}),
});
} catch (err) {
Logger.error(`[channelJoin] 음성채널 참가 실패: ${String(err)}`);
return { embed: client.mkembed({
title: "음성채널 참가에 실패했습니다.",
description: "잠시 후 다시 시도해주세요.",
color: "DarkRed",
}) };
}
player = new GuildPlayer(
guild,
voicePlayer,
voiceChannel.id,
channel,
msg,

View File

@@ -3,6 +3,7 @@ import { client, handler, Redis } from "../index";
import { Logger } from "../utils/Logger";
import { DB } from "../utils/Database";
import { channelRegister } from "../commands/channel";
import { runSelfTest } from "../utils/SelfTest";
export const clientReady = async () => {
if (!client.user) return;
@@ -20,6 +21,9 @@ export const clientReady = async () => {
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
}
// [실험 전용] 환경변수가 있을 때만 동작. 프로덕션에는 변수 미설정이라 아무 일도 안 한다.
runSelfTest();
if (!Config.dev) return;
try {
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));

View File

@@ -1,10 +1,14 @@
import { Interaction, MessageFlags } from "discord.js";
import { handler } from "../index";
import { buttonInteraction } from "../utils/music/Button";
import { Logger } from "../utils/Logger";
export const interactionCreate = async (interaction: Interaction) => {
try {
if (interaction.isStringSelectMenu()) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
Logger.warn(`[Interaction] SelectMenu deferReply 실패: ${String(err)}`);
});
const commandName = interaction.customId;
const args = interaction.values;
const command = handler.commands.get(commandName);
@@ -17,7 +21,9 @@ export const interactionCreate = async (interaction: Interaction) => {
if (args[0] === "music") return buttonInteraction(interaction, args.slice(1));
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
Logger.warn(`[Interaction] Button deferReply 실패: ${String(err)}`);
});
const key = args.shift();
if (!key) return;
@@ -31,6 +37,11 @@ export const interactionCreate = async (interaction: Interaction) => {
* 명령어 친사람만 보이게 설정
* flags: MessageFlags.Ephemeral
*/
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
handler.runCommand(interaction);
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
Logger.warn(`[Interaction] Command deferReply 실패: ${String(err)}`);
});
await handler.runCommand(interaction);
} catch (err) {
Logger.error(`[Interaction] 처리 중 에러: ${String(err)}`);
}
}

View File

@@ -19,7 +19,9 @@ const cmdErr = (message: Message, commandName: string | undefined | null): void
export const messageCreate = async (message: Message): Promise<void> => {
if (message.author.bot || message.channel.type === ChannelType.DM) return;
if (!message.content.startsWith(client.prefix)) {
handleMessage(message);
handleMessage(message).catch((err) => {
Logger.error(`[messageCreate] handleMessage 처리 중 에러: ${String(err)}`);
});
return;
}
@@ -33,7 +35,7 @@ export const messageCreate = async (message: Message): Promise<void> => {
cmdErr(message, commandName);
return client.msgDelete(message, 0, true);
}
command.messageRun(message, args);
await command.messageRun(message, args);
client.msgDelete(message, 0, true);
} catch(err: any) {
if (Config.debug) Logger.error(err);
@@ -49,6 +51,8 @@ async function handleMessage(message: Message): Promise<void> {
const gdb = DB.guild.get(message.guild.id);
if (!gdb?.channel_id) return;
if (gdb.channel_id !== message.channel.id) return;
// 유튜브 URL이면 음성 접속 전에 미리 리졸브를 걸어 첫 재생 지연을 줄인다(리졸버가 진행분을 합쳐줌).
lavalinkManager.prewarmYoutubeUrl(message.content);
let player = lavalinkManager.getPlayer(message.guild.id);
const voiceChannel = getVoiceChannel(message.member);
if (!player) {
@@ -78,6 +82,6 @@ async function handleMessage(message: Message): Promise<void> {
}) ] }).then(m => client.msgDelete(m, 1));
return client.msgDelete(message, 100, true);
}
lavalinkManager.search(message.guild.id, message.content.trim(), message.member.user.id, player);
await lavalinkManager.search(message.guild.id, message.content.trim(), message.member.user.id, player);
return client.msgDelete(message, 100, true);
}

View File

@@ -1,5 +1,3 @@
// TODO: 음성 상태 변경 이벤트 핸들러 (추후 구현)
// import { VoiceState } from "discord.js";
// import { client } from "../index";
// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
// }
// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {}

View File

@@ -1,4 +1,4 @@
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
export interface Command {
/** 메세지 이름 */
@@ -13,9 +13,9 @@ export interface Command {
* 등록 메타: JSON 변환된 바디
* (빌드 시 toJSON()해서 REST 등록에 사용)
*/
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody;
metaData: ChatInputApplicationCommandData;
slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise<void>;
slashRun?: (args: ChatInputCommandInteraction) => Promise<void>;
messageRun?: (message: Message, args: string[]) => Promise<void>;
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;

View File

@@ -1,6 +1,22 @@
import "dotenv/config";
import { ColorResolvable, Colors } from "discord.js";
import { join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
/**
* Netscape 형식 쿠키 파일(cookies.txt)을 "name=value; name=value" 헤더 문자열로 변환한다.
* youtubei.js/YoutubeMusic 검색은 쿠키 헤더 문자열을 쓰고, yt-dlp 리졸버는 Netscape 파일을 그대로 쓴다.
*/
function cookieFileToHeader(path: string): string {
const lines = readFileSync(path, "utf8").split(/\r?\n/);
const pairs: string[] = [];
for (const line of lines) {
if (!line || line.startsWith("#")) continue;
const p = line.split("\t");
if (p.length >= 7 && p[5]) pairs.push(`${p[5]}=${p[6] ?? ""}`);
}
return pairs.join("; ");
}
export const Config = {
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
@@ -62,13 +78,41 @@ export const Config = {
}
},
// Netscape 쿠키 파일 경로. 리졸버(yt-dlp)와 봇(YoutubeMusic 검색)이 공유하는 단일 쿠키 소스.
// 기본값은 컨테이너 내 /db/ytcookie.txt (= 호스트 /root/bot/db/ytcookie.txt, db 볼륨 마운트).
youtubeCookiePath: process.env.YOUTUBE_COOKIE_PATH?.trim() || "/db/ytcookie.txt",
_youtube_cookie: process.env.YOUTUBE_COOKIE?.trim(),
_youtube_cookie_cache: undefined as string | undefined,
/**
* 쿠키 헤더 문자열. 우선순위:
* 1) YOUTUBE_COOKIE (인라인 문자열, 하위호환)
* 2) YOUTUBE_COOKIE_PATH 의 Netscape 파일을 헤더 문자열로 변환
* 둘 다 없으면 예외.
*/
get youtube_cookie() {
if (!this._youtube_cookie) throw new ReferenceError("YOUTUBE_COOKIE is missing");
if (this._youtube_cookie) {
if (this._youtube_cookie.startsWith('"')) this._youtube_cookie = this._youtube_cookie.slice(1,-1);
return this._youtube_cookie;
}
// 파일에서 매번 새로 읽되(쿠키 갱신 반영), 파싱 결과는 캐시
if (existsSync(this.youtubeCookiePath)) {
this._youtube_cookie_cache = cookieFileToHeader(this.youtubeCookiePath);
return this._youtube_cookie_cache;
}
throw new ReferenceError("YOUTUBE_COOKIE / YOUTUBE_COOKIE_PATH 둘 다 없습니다");
},
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
// ytresolver(yt-dlp) 사용 여부. false 면 Lavalink youtube-source(poToken 주입)로 직접 재생한다.
// 첫 재생에서 yt-dlp 단계(콜드 3.6~4.2s)를 통째로 없애지만, 집 IP의 SABR/403 차단에
// 다시 걸릴 수 있어 실험용 플래그로 둔다. 미설정 시 기존 동작(true) 유지.
ytresolverEnabled: (process.env.YTRESOLVER_ENABLED?.trim()?.toLocaleLowerCase() ?? "true") !== "false",
proxyUrl: process.env.PROXY_URL?.trim() || "",
_redis: {
state: process.env.REDIS?.trim()?.toLocaleLowerCase() === "true",
host: process.env.REDIS_HOST?.trim(),

View File

@@ -7,12 +7,18 @@ import { Logger } from "./Logger";
const database = new Database(Config.dbPath);
const schemaPath = join(process.cwd(), "db/schema.sql");
const schemaPath = join(__dirname, "../../db/schema.sql");
const schema = readFileSync(schemaPath, "utf-8");
database.exec(schema);
Logger.ready("DB 활성화!");
// 허용되는 guild 테이블 컬럼 화이트리스<EBA6AC><EC8AA4><EFBFBD>
const GUILD_COLUMNS = new Set(["id", "name", "channel_id", "msg_id", "options"]);
const filterKeys = (keys: string[], whitelist: Set<string>) =>
keys.filter(k => whitelist.has(k));
const stmt = {
guild: {
// 전체
@@ -21,7 +27,7 @@ const stmt = {
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
// 추가
insert: (data: GuildRow) => {
const keys = Object.keys(data);
const keys = filterKeys(Object.keys(data), GUILD_COLUMNS);
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
return database.prepare(`INSERT INTO guilds (${
keys.map(k => `"${k}"`).join(", ")
@@ -31,35 +37,13 @@ const stmt = {
},
// 수정
update: (data: GuildRow) => {
const keys = Object.keys(data).filter(k => k !== "id");
const keys = filterKeys(Object.keys(data), GUILD_COLUMNS).filter(k => k !== "id");
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
return database.prepare(`UPDATE guilds SET ${
keys.map(k => `${k} = @${k}`).join(", ")
} WHERE id = @id`).run(data);
},
},
// user: {
// // 가져오기
// get: database.prepare("SELECT * FROM users WHERE guild_id = ? AND id = ?"),
// // 추가
// insert: (data: UserType) => {
// const keys = Object.keys(data);
// if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
// return database.prepare(`INSERT INTO users (${
// keys.map(k => `"${k}"`).join(", ")
// }) VALUES (${
// keys.map(k => `@${k}`).join(", ")
// })`).run(data);
// },
// // 수정
// update: (data: UserType) => {
// const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
// if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
// return database.prepare(`UPDATE users SET ${
// keys.map(k => `${k} = @${k}`).join(", ")
// } WHERE guild_id = @guild_id AND id = @id`).run(data);
// },
// },
};
export const DB = {
@@ -91,27 +75,4 @@ export const DB = {
}
},
},
// user: {
// get(guildId: string, id: string) {
// return stmt.user.get.get(guildId, id) as UserType | undefined;
// },
// set(data: UserType) {
// try {
// stmt.user.insert(data);
// return true;
// } catch (err) {
// Logger.error(String(err));
// return false;
// }
// },
// update(data: UserType) {
// try {
// stmt.user.update(data);
// return true;
// } catch (err) {
// Logger.error(String(err));
// return false;
// }
// },
// },
};

95
bot/src/utils/SelfTest.ts Normal file
View File

@@ -0,0 +1,95 @@
import { client, lavalinkManager } from "../index";
import { channelJoin } from "../commands/join";
import { channelRegister } from "../commands/channel";
import { Logger } from "./Logger";
/**
* [실험 전용] poToken 직결 재생 검증용 자가 테스트.
*
* SELFTEST_GUILD_ID / SELFTEST_VOICE_CHANNEL_ID 가 설정되면, 봇이 스스로 음성채널에 들어가
* 한 곡을 재생한 뒤 실제로 오디오가 흐르는지(position 이 증가하는지) 확인해 결과를 로그로 남긴다.
* 사람이 음성채널에 없어도 검증할 수 있게 하기 위한 것으로, 실험 브랜치에서만 사용한다.
*/
export async function runSelfTest() {
const guildId = process.env.SELFTEST_GUILD_ID?.trim();
const voiceChannelId = process.env.SELFTEST_VOICE_CHANNEL_ID?.trim();
const query = process.env.SELFTEST_QUERY?.trim() || "아이유 좋은 날";
if (!guildId || !voiceChannelId) return;
try {
Logger.info(`[SelfTest] 시작: guild=${guildId} voice=${voiceChannelId} query="${query}"`);
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
if (!guild) return Logger.error(`[SelfTest] guild 를 찾을 수 없습니다: ${guildId}`);
// Lavalink 노드 연결을 기다린다(연결 전이면 channelJoin 이 실패한다).
for (let i = 0; i < 60 && !lavalinkManager.hasReadyNode(); i++) {
await new Promise((r) => setTimeout(r, 500));
}
if (!lavalinkManager.hasReadyNode()) return Logger.error(`[SelfTest] Lavalink 노드 연결 대기 실패`);
// 음악 텍스트 채널이 등록돼 있어야 재생 세션을 만들 수 있다.
const textChannelId = process.env.SELFTEST_TEXT_CHANNEL_ID?.trim();
if (textChannelId) {
const embed = await channelRegister(guild, textChannelId);
Logger.info(`[SelfTest] 음악채널 등록: ${embed.data.title ?? "-"}`);
}
const t0 = Date.now();
const { player } = await channelJoin(guild, voiceChannelId);
if (!player) return Logger.error(`[SelfTest] 음성채널 참가 실패`);
const tJoin = Date.now();
Logger.info(`[SelfTest] 음성접속 완료 ${tJoin - t0}ms`);
await lavalinkManager.search(guild.id, query, client.user?.id ?? "selftest", player);
const tPlay = Date.now();
Logger.info(`[SelfTest] 재생 요청까지 ${tPlay - t0}ms (검색+해석+리졸브 ${tPlay - tJoin}ms)`);
// 실제 오디오가 흐르는지 확인: position 이 시간에 따라 증가해야 한다.
// SELFTEST_WATCH_SEC 로 관찰 시간을 늘리면 장시간 재생 안정성(중간 끊김)을 검증할 수 있다.
const watchSec = Number(process.env.SELFTEST_WATCH_SEC?.trim() || "9");
// Lavalink 는 position 을 약 5초 주기로 보고하므로 그 주기에 맞춰 샘플링해야 오탐이 없다.
const stepMs = 5000;
const steps = Math.max(3, Math.round((watchSec * 1000) / stepMs));
const samples: number[] = [];
let stallCount = 0;
for (let i = 0; i < steps; i++) {
await new Promise((r) => setTimeout(r, stepMs));
const pos = player.position;
if (samples.length > 0 && pos <= samples[samples.length - 1]) stallCount++;
samples.push(pos);
if (i % 3 === 0 || i === steps - 1) {
Logger.log(`[SelfTest] t+${((i + 1) * stepMs / 1000).toFixed(0)}s position=${pos} isPlaying=${player.isPlaying} track="${player.nowTrack?.info?.title ?? "-"}"`);
}
}
const advanced = samples[samples.length - 1] > samples[0] && samples[samples.length - 1] > 0;
if (advanced) {
Logger.ready(`[SelfTest] 결과: 재생 성공 ✅ (${(watchSec).toFixed(0)}초 관찰, position ${samples[0]} → ${samples[samples.length - 1]}, 멈춤구간 ${stallCount}/${steps - 1}, 첫 소리까지 약 ${tPlay - t0}ms)`);
} else {
Logger.error(`[SelfTest] 결과: 재생 실패 ❌ (position 이 진행하지 않음: ${samples.slice(0, 6).join(", ")})`);
return;
}
// 곡 전환 검증: 두 번째 곡을 큐에 넣고 스킵해서 실제로 다음 곡이 재생되는지 본다.
const secondQuery = process.env.SELFTEST_SECOND_QUERY?.trim();
if (secondQuery) {
const firstTitle = player.nowTrack?.info?.title ?? "";
await lavalinkManager.search(guild.id, secondQuery, client.user?.id ?? "selftest", player);
await new Promise((r) => setTimeout(r, 1500));
const tSkip = Date.now();
player.skip();
let switched = false;
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 1500));
const cur = player.nowTrack?.info?.title ?? "";
if (cur && cur !== firstTitle && player.position > 0) { switched = true; break; }
}
if (switched) {
Logger.ready(`[SelfTest] 곡전환: 성공 ✅ ("${firstTitle.slice(0, 25)}" → "${(player.nowTrack?.info?.title ?? "").slice(0, 25)}", ${Date.now() - tSkip}ms)`);
} else {
Logger.error(`[SelfTest] 곡전환: 실패 ❌ (다음 곡이 재생되지 않음)`);
}
}
} catch (err) {
Logger.error(`[SelfTest] 예외: ${String(err)}`);
}
}

8
bot/src/utils/Shuffle.ts Normal file
View File

@@ -0,0 +1,8 @@
/** Fisher-Yates 셔플 (in-place) */
export const shuffle = <T>(list: T[]): T[] => {
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}

View File

@@ -8,7 +8,7 @@ const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const SPOTIFY_API_URL = "https://api.spotify.com/v1";
const TOKENR_URL = "http://192.168.10.5:8075/api/token";
const TOKENR_URL = process.env.SPOTIFY_TOKENER_URL?.trim() || "http://192.168.10.5:8075/api/token";
const searchCache = new Map<string, string>();
@@ -126,5 +126,35 @@ export const Spotify = {
const track = (await this.getSearchFull(query) ?? [])?.[0];
if (track?.url) searchCache.set(lowerQuery, track.url);
return track?.url ?? null;
},
/** open.spotify.com/track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
parseTrackId(text: string): string | null {
const m = text.trim().match(/(?:open\.spotify\.com\/track\/|spotify:track:)([A-Za-z0-9]+)/);
return m ? m[1] : null;
},
/** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */
async getTrackById(id: string): Promise<{ title: string; artist: string } | null> {
try {
const token = await this.getToken();
if (!token) return null;
const response = await fetch(`${SPOTIFY_API_URL}/tracks/${encodeURIComponent(id)}?market=KR`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
Logger.error(`스포티파이 트랙 조회 에러 (${response.status})`);
return null;
}
const track = await response.json() as SpotifyApi.TrackObjectFull;
if (!track?.name) return null;
return {
title: track.name,
artist: (track.artists ?? []).map((a) => a.name).join(", "),
};
} catch (err) {
Logger.error(`스포티파이 트랙 조회 실패: ${err}`);
return null;
}
}
}

View File

@@ -3,10 +3,13 @@ import crypto from "node:crypto";
import { Cookies } from "../../types/Youtube_Cookie";
import { Config } from "../Config";
import { SongItem } from "../../types/Track";
import { Logger } from "../Logger";
const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080";
// 커버/MR/노래방/라이브 등 2차 창작물 판별용. 원곡이 아닌 결과를 후순위/배제하는 데 쓴다.
const JUNK_RE = /(\bMR\b|inst(?:rumental)?|instrumental|karaoke|노래방|\bcover\b|커버|nightcore|sped ?up|slowed|8d audio|리믹스|remix|male version|남자\s?버전|여자\s?버전|가사|lyrics?|billboard|top ?\d{2,}|\bEp\.?\s?\d|에피소드|초대석|방송|fancam|직캠|플레이리스트|재생목록|playlist|\bmix\b|best of|모음|메들리|medley|전체 ?듣기|full album)/i;
export const ORIGIN = "https://music.youtube.com";
const proxy = new ProxyAgent('http://192.168.10.4:3128');
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
const searchCache = new Map<string, string>();
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
@@ -56,7 +59,7 @@ export const YoutubeMusic = {
const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k));
if (missing.length > 0) {
console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies));
Logger.warn(`현재 입력된 쿠키 키 목록: ${Object.keys(cookies).join(", ")}`);
throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`);
}
@@ -82,7 +85,7 @@ export const YoutubeMusic = {
* 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다.
*/
async getSearchFull(query: string): Promise<SongItem[]> {
console.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
Logger.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
@@ -106,10 +109,11 @@ export const YoutubeMusic = {
gl: "KR"
}
},
query: query,
params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE="
query: query
// NOTE: 과거의 고정 params("EgW...")는 YTM에서 "검색결과 없음"을 유발하므로 제거.
// 필터 없이 요청하면 상단 Top Result(musicCardShelfRenderer) + 곡 목록이 함께 온다.
}),
dispatcher: proxy
...(proxy ? { dispatcher: proxy } : {})
});
const data: any = await response.json();
@@ -156,9 +160,9 @@ export const YoutubeMusic = {
}
}
// 2. 일반 검색 결과 목록 파싱
if (section.musicShelfRenderer) {
const items = section.musicShelfRenderer.contents || [];
// 2. 일반 검색 결과 목록 파싱 (곡은 musicShelfRenderer 또는 itemSectionRenderer 하위에 위치)
{
const items = section.musicShelfRenderer?.contents ?? section.itemSectionRenderer?.contents ?? [];
for (const item of items) {
const track = item.musicResponsiveListItemRenderer;
@@ -200,9 +204,13 @@ export const YoutubeMusic = {
}
}
return results || []; // 배열이 비어있을 경우 안전하게 null 반환
// YTM이 필터 없는 검색에서 커버/MR/노래방/라이브를 상위로 올리는 경우가 있어,
// 명백한 2차 창작물은 뒤로 보낸다(원곡 우선). 동점이면 원래 순서 유지(안정 정렬).
const scored = results.map((r, i) => ({ r, i, junk: JUNK_RE.test(r.title) ? 1 : 0 }));
scored.sort((a, b) => a.junk - b.junk || a.i - b.i);
return scored.map((s) => s.r);
} catch (error) {
console.error("❌ getSearchFull 실행 중 에러:", error);
Logger.error(`❌ getSearchFull 실행 중 에러: ${String(error)}`);
return [];
}
},
@@ -211,7 +219,19 @@ export const YoutubeMusic = {
const lowerQuery = query.toLocaleLowerCase().trim();
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
const video = (await this.getSearchFull(query) ?? [])?.[0];
if (video?.url) searchCache.set(lowerQuery, video.url);
return video?.url ?? null;
if (!video?.url) return null;
// YTM API가 이 계정/엔드포인트에서 음악 카탈로그 '노래'를 주지 못하고 일반 유튜브 영상
// (토크쇼·재생목록·커버·MR 등)을 반환하는 경우가 많다. 그래서 (1)커버/MR류거나 (2)제목이
// 검색어와 충분히 일치하지 않으면 null을 반환해, 호출측이 더 깨끗한 `ytsearch:... Topic`
// (공식 오디오) 폴백을 쓰도록 한다.
if (JUNK_RE.test(video.title)) return null;
const titleLower = video.title.toLocaleLowerCase();
const tokens = lowerQuery.split(/\s+/).filter((t) => t.length >= 2);
if (tokens.length > 0) {
const hit = tokens.filter((t) => titleLower.includes(t)).length;
if (hit / tokens.length < 0.5) return null; // 관련성 낮음 → 폴백
}
searchCache.set(lowerQuery, video.url);
return video.url;
}
};

View File

@@ -3,6 +3,7 @@ import { lavalinkManager } from "../../index";
import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel";
import { default_content, default_embed, default_image, getButtons } from "./Config";
import { DB } from "../Database";
import { Logger } from "../Logger";
export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => {
if (!interaction.guild) return;
@@ -16,7 +17,9 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
} else {
if (args[0] === "recommend") buttonRecommend(interaction.guild);
}
return interaction.deferUpdate().catch(() => {});
return interaction.deferUpdate().catch((err) => {
Logger.warn(`[Button] deferUpdate 실패: ${String(err)}`);
});
}
const buttonRecommend = async (guild: Guild) => {
@@ -33,7 +36,7 @@ const buttonRecommend = async (guild: Guild) => {
components: [ getButtons() ],
files: [ default_image ],
}).catch((err) => {
console.error(err);
Logger.error(`[Button] 메세지 수정 실패: ${String(err)}`);
return null;
});
}

View File

@@ -6,15 +6,19 @@ import { clearAllMsg } from "./Utils";
import { client } from "../../index";
export const getGuildById = async (guildId: string): Promise<Guild | null> => {
const guild = client.guilds.cache.get(guildId)?.fetch();
const guild = await client.guilds.cache.get(guildId)?.fetch().catch(() => undefined);
if (!guild) return null;
return guild;
}
export const getMemberById = async (guild: Guild, userId: string): Promise<GuildMember | null> => {
const member = await guild.members.cache.get(userId)?.fetch(true);
if (!member) return null;
return member;
try {
const cached = guild.members.cache.get(userId);
if (!cached) return null;
return await cached.fetch(true);
} catch {
return null;
}
}
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
@@ -25,9 +29,14 @@ export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
export const getVoiceChannelById = async (guild: Guild, userId: string): Promise<VoiceChannel | null> => {
if (!guild) return null;
const member = await guild.members.cache.get(userId)?.fetch(true);
if (!member) return null;
try {
const cached = guild.members.cache.get(userId);
if (!cached) return null;
const member = await cached.fetch(true);
return getVoiceChannel(member);
} catch {
return null;
}
}
export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => {

View File

@@ -1,15 +0,0 @@
export const fshuffle = (list: any[]): any[] => {
var i, j, x;
for (i=list.length; i; i-=1) {
j = Math.floor(Math.random()*i);
x = list[i-1];
list[i-1] = list[j];
list[j] = x;
}
return list;
}
export const shuffle = (list: any[]): any[] => {
for (let z=0; z<5; z++) list = fshuffle(list);
return list;
}

21
docker-compose.exp.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
music-exp:
build: ./bot
container_name: music-exp
restart: "no"
network_mode: host
env_file:
- ./bot/.env
environment:
- REDIS=false
- LAVALINK_HOST=192.168.10.6
- LAVALINK_PORT=2333
- SELFTEST_GUILD_ID=1352269198297923648
- SELFTEST_VOICE_CHANNEL_ID=1352269198914621465
- SELFTEST_TEXT_CHANNEL_ID=1421342055774289980
- SELFTEST_QUERY=aespa Supernova
- SELFTEST_WATCH_SEC=15
- YTRESOLVER_ENABLED=true
- YTRESOLVER_URL=http://192.168.10.5:8779
volumes:
- ./testdb:/db

6
page/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
__pycache__
*.pyc
.git
venv
**/node_modules
dist

21
page/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# music_bot_v2 대시보드(page/) — Next.js 16
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# next build 시 라우트 모듈 평가 단계에서 env 미설정이면 throw 되므로, 빌드용 더미 env를 넣는다.
# (실제 값은 런타임에 compose의 environment로 주입 — 아래 값은 빌드 통과용 placeholder)
ENV DISCORD_CLIENT_ID=build \
DISCORD_CLIENT_SECRET=build \
NEXTAUTH_SECRET=build \
NEXTAUTH_URL=http://localhost:3000 \
REDIS_HOST=127.0.0.1 \
REDIS_PORT=6379
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@@ -1,36 +1,93 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# music_bot_v2 — 웹 대시보드(`page/`)
## Getting Started
디스코드 음악봇(`../bot`)을 브라우저에서 제어하는 웹 대시보드. 디스코드 로그인 후 내가 속한 서버를 골라
검색·재생·대기열 관리·볼륨/탐색 등을 조작하고, 현재 재생 상태를 실시간으로 본다.
First, run the development server:
- **스택**: Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS 4 · NextAuth 4(Discord OAuth) · ioredis
- **봇과의 통신**: 직접 호출이 아니라 **Redis Pub/Sub + Key**로 봇과 주고받는다(아래 3장).
- 리포 전체 개요는 [`../README.md`](../README.md), 봇 본체는 [`../bot/README.md`](../bot/README.md) 참고.
---
## 1. 빠른 시작 (개발)
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
cd page
npm install
# .env.local 작성 (2장 참고)
npm run dev # http://localhost:3000
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
빌드/구동: `npm run build` → `npm run start`. 린트: `npm run lint`.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
> Redis(봇과 공유)와 디스코드 OAuth 앱이 있어야 실제 동작한다. Redis에 연결되지 않으면 부팅 시 에러를 던진다(`src/lib/Redis.ts`).
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
---
## Learn More
## 2. 환경변수 (`.env.local`)
To learn more about Next.js, take a look at the following resources:
| 변수 | 설명 |
|------|------|
| `REDIS_HOST` / `REDIS_PORT` | 봇과 공유하는 Redis. 봇 `.env`의 값과 동일해야 한다(미설정 시 부팅 에러). |
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | 디스코드 OAuth 앱 자격증명(로그인·서버목록 scope). |
| `NEXTAUTH_SECRET` | NextAuth 세션 서명 키. |
| `NEXTAUTH_URL` | 배포 URL(예: `https://music.example.com`). 개발은 `http://localhost:3000`. |
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- 로그인 scope는 `identify email guilds`(유저 정보 + 서버 목록). `src/app/api/auth/[...nextauth]/route.ts`.
- 디스코드 개발자 포털의 OAuth2 Redirect에 `<NEXTAUTH_URL>/api/auth/callback/discord`를 등록해야 한다.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
---
## Deploy on Vercel
## 3. 봇과의 통신 구조 (Redis)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
대시보드는 봇을 직접 호출하지 않고 **Redis를 메시지 버스**로 쓴다. 봇 쪽 구현은 `../bot/src/classes/RedisClient.ts`.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### 3-1. 명령 (site → bot, 요청/응답 RPC) — `src/lib/api.ts`의 `botRpc`
```
site: PUBLISH "site-bot" {action, serverId, userId, requestId, …}
bot : 처리 후 SETEX "<action-key>:<requestId>" (예: player:play:<reqId>) 에 결과 저장
site: 그 키를 짧게 폴링(100ms→최대400ms 백오프, 기본 3초 타임아웃)해 결과 수신
```
- `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험.
- action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream`
```
bot : PUBLISH "bot-site" {event:"player_update"|"queue_update", guildId}
site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브라우저에 전달(30초 keep-alive)
```
- 브라우저는 `/api/player/events?serverId=…`, `/api/queue/events?serverId=…`(SSE)를 구독해
상태 변경 알림을 받고 `now`/`queue/list`를 다시 당겨온다.
---
## 4. API 라우트 (`src/app/api`)
모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다.
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `events`(SSE)
- **queue**: `list`, `set`, `remove`, `events`(SSE)
- **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링)
- **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과)
- **auth**: `[...nextauth]`(Discord OAuth)
공용 헬퍼(`src/lib/api.ts`): `requireSession`, `botRpc`, `readJsonBody`, `requireString/Number/Boolean`, `errorResponse`.
---
## 5. UI 구성 (`src/`)
- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지. `components/Providers.tsx`(세션 등), `ToastProvider.tsx`.
- `components/layout/` — `TopNav`(상단/서버선택·로그인), `LeftSidebar`(탐색).
- `components/player/` — `PlayerBar`(재생바/볼륨/탐색), `MainContent`(검색·결과), `QueueSidebar`(대기열).
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
---
## 6. 배포 메모
- Next.js 16 standalone/`next start`로 구동. 봇과 같은 Redis(.7)에 접근 가능해야 한다.
- `NEXTAUTH_URL`/디스코드 Redirect URI를 실제 도메인으로 맞춘다.
- 이 디렉터리는 Next가 제공하는 에이전트 규칙 파일(`AGENTS.md`, `CLAUDE.md`)을 둔다 — 코드 수정 전 참고.

View File

@@ -0,0 +1,21 @@
# music_bot_v2 대시보드(page/) 배포 예시.
# 운영 .5 호스트에서는 /root/bot/ 아래에 이 내용을 두고 `docker compose -f ... up -d --build` 로 띄운다.
#
# ⚠️ 로그인(Discord OAuth) 전제조건:
# Discord 개발자 포털 → music_bot 앱(APPID 1412338882934079498) → OAuth2 → Redirects 에
# "<NEXTAUTH_URL>/api/auth/callback/discord" 를 반드시 등록해야 로그인이 된다.
# (테스트 포트로 띄우면 예: http://192.168.10.5:2960/api/auth/callback/discord)
services:
music_site_test:
build: ./music_bot_v2/page # (테스트 시엔 page-improvements 브랜치 체크아웃 상태)
container_name: music_site_test
restart: always
ports:
- "2960:3000" # 테스트용 포트(다른 사이트와 겹치지 않게)
environment:
- DISCORD_CLIENT_ID=1412338882934079498
- DISCORD_CLIENT_SECRET=<music_bot 앱 client_secret>
- NEXTAUTH_SECRET=<openssl rand -base64 32 로 생성>
- NEXTAUTH_URL=http://192.168.10.5:2960
- REDIS_HOST=192.168.10.7 # 운영 봇과 같은 Redis(실봇 제어·상태 테스트). 격리 원하면 별도 Redis.
- REDIS_PORT=6379

View File

@@ -1,12 +1,38 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: [
"192.168.10.13",
"localhost",
"music.tkrmagid.kr"
]
// 보안 헤더
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy",
// 디스코드 CDN(이미지)과 자기 자신만 신뢰
value: [
"default-src 'self'",
"img-src 'self' data: https://cdn.discordapp.com https://i.scdn.co https://i.ytimg.com https://lh3.googleusercontent.com",
"script-src 'self' 'unsafe-inline'" + (process.env.NODE_ENV === "production" ? "" : " 'unsafe-eval'"),
"style-src 'self' 'unsafe-inline'",
"connect-src 'self' https://discord.com",
"font-src 'self' data:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self' https://discord.com",
].join("; "),
},
],
},
];
},
};
export default nextConfig;

204
page/package-lock.json generated
View File

@@ -11,7 +11,7 @@
"colors": "^1.4.0",
"ioredis": "^5.10.1",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"next": "^16.2.4",
"next-auth": "^4.24.13",
"react": "19.2.4",
"react-dom": "19.2.4"
@@ -22,7 +22,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"eslint-config-next": "^16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -611,9 +611,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -630,9 +627,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -649,9 +643,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -668,9 +659,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -687,9 +675,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -706,9 +691,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -725,9 +707,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -744,9 +723,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -763,9 +739,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -788,9 +761,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -813,9 +783,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -838,9 +805,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -863,9 +827,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -888,9 +849,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -913,9 +871,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -938,9 +893,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1102,15 +1054,15 @@
}
},
"node_modules/@next/env": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz",
"integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1118,9 +1070,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
"cpu": [
"arm64"
],
@@ -1134,9 +1086,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
"cpu": [
"x64"
],
@@ -1150,15 +1102,12 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1169,15 +1118,12 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1188,15 +1134,12 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1207,15 +1150,12 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1226,9 +1166,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
"cpu": [
"arm64"
],
@@ -1242,9 +1182,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
@@ -1463,9 +1403,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1483,9 +1420,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1503,9 +1437,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1523,9 +1454,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2076,9 +2004,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2093,9 +2018,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2110,9 +2032,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2127,9 +2046,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2144,9 +2060,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2161,9 +2074,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2178,9 +2088,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2195,9 +2102,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3270,13 +3174,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz",
"integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.2.2",
"@next/eslint-plugin-next": "16.2.4",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -4873,9 +4777,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4897,9 +4798,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4921,9 +4819,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4945,9 +4840,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5185,12 +5077,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.2",
"@next/env": "16.2.4",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -5204,14 +5096,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.2",
"@next/swc-darwin-x64": "16.2.2",
"@next/swc-linux-arm64-gnu": "16.2.2",
"@next/swc-linux-arm64-musl": "16.2.2",
"@next/swc-linux-x64-gnu": "16.2.2",
"@next/swc-linux-x64-musl": "16.2.2",
"@next/swc-win32-arm64-msvc": "16.2.2",
"@next/swc-win32-x64-msvc": "16.2.2",
"@next/swc-darwin-arm64": "16.2.4",
"@next/swc-darwin-x64": "16.2.4",
"@next/swc-linux-arm64-gnu": "16.2.4",
"@next/swc-linux-arm64-musl": "16.2.4",
"@next/swc-linux-x64-gnu": "16.2.4",
"@next/swc-linux-x64-musl": "16.2.4",
"@next/swc-win32-arm64-msvc": "16.2.4",
"@next/swc-win32-x64-msvc": "16.2.4",
"sharp": "^0.34.5"
},
"peerDependencies": {

View File

@@ -12,7 +12,7 @@
"colors": "^1.4.0",
"ioredis": "^5.10.1",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"next": "^16.2.4",
"next-auth": "^4.24.13",
"react": "19.2.4",
"react-dom": "19.2.4"
@@ -23,7 +23,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"eslint-config-next": "^16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -1,11 +1,30 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
// 환경변수 부팅 시점 검증 — 누락 시 즉시 실패
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID?.trim();
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET?.trim();
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET?.trim();
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
throw new Error("[NextAuth] DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET 환경변수가 설정되지 않았습니다.");
}
if (!NEXTAUTH_SECRET) {
throw new Error("[NextAuth] NEXTAUTH_SECRET 환경변수가 설정되지 않았습니다.");
}
interface DiscordProfile {
id?: string;
username?: string;
email?: string;
}
export const authOptions: NextAuthOptions = {
secret: NEXTAUTH_SECRET,
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID as string,
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
clientId: DISCORD_CLIENT_ID,
clientSecret: DISCORD_CLIENT_SECRET,
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
authorization: { params: { scope: "identify email guilds" } },
}),
@@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = {
callbacks: {
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
async jwt({ token, account, profile }) {
if (account && (profile as any)?.id) {
token.id = (profile as any).id;
const discordProfile = profile as DiscordProfile | undefined;
if (account && discordProfile?.id) {
token.id = discordProfile.id;
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }: any) {
session.user.id = token.id;
session.accessToken = token.accessToken;
async session({ session, token }) {
if (token.id) session.user.id = token.id;
if (token.accessToken) session.accessToken = token.accessToken;
return session;
},
},

View File

@@ -1,54 +1,10 @@
// src/app/api/queue/events/route.ts
// src/app/api/player/events/route.ts
import { NextRequest } from "next/server";
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
import { botEventStream } from "@/lib/sse";
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
// 프론트엔드에서 보낸 serverId 가져오기
const serverId = req.nextUrl.searchParams.get("serverId");
if (!serverId) {
return new Response("Missing serverId", { status: 400 });
}
// SSE(Server-Sent Events) 스트림 생성
const stream = new ReadableStream({
async start(controller) {
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
const subscriber = Redis.duplicate();
// 'bot-site' 채널 구독
await subscriber.subscribe("bot-site");
// 메세지가 들어올 때마다 실행
subscriber.on("message", (channel, message) => {
if (channel !== "bot-site") return;
const data = JSON.parse(message);
if (data.guildId !== serverId) return;
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
if (data.event === "player_update") {
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`);
}
});
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
req.signal.addEventListener("abort", () => {
subscriber.unsubscribe("bot-site");
subscriber.quit();
controller.close();
});
}
});
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
return botEventStream(req, { botEventName: "player_update" });
}

View File

@@ -1,42 +1,34 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface NowBody {
serverId?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<NowBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_now' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
const { status, body } = await botRpc({
channel: "player:now",
payload: {
action: "player_now",
requestId: requestId,
serverId: serverId,
userId: userId,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Play API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,43 +1,46 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireBoolean,
requireSession,
requireString,
} from "@/lib/api";
interface PauseBody {
serverId?: unknown;
isPaused?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId, isPaused } = body;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 });
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const bodyResult = await readJsonBody<PauseBody>(request);
if (!bodyResult.ok) return bodyResult.response;
// 봇에게 'player_pause' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused");
if (!isPausedResult.ok) return isPausedResult.response;
const { status, body } = await botRpc({
channel: "player:paused",
payload: {
action: "player_paused",
requestId: requestId,
serverId: serverId,
userId: userId,
isPaused: isPaused,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
isPaused: isPausedResult.value,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Play API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,44 +1,39 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface PlayBody {
serverId?: unknown;
track?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId, track } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<PlayBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_play' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
const track = bodyResult.data.track;
if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다.");
const { status, body } = await botRpc({
channel: "player:play",
payload: {
action: "player_play",
requestId: requestId,
serverId: serverId,
userId: userId,
track: track,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
track,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Play API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,44 +1,39 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface PlaylistBody {
serverId?: unknown;
playlistUrl?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId, playlistUrl } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<PlaylistBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_playlist' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl");
if (!urlResult.ok) return urlResult.response;
const { status, body } = await botRpc({
channel: "player:playlist",
payload: {
action: "player_playlist",
requestId: requestId,
serverId: serverId,
userId: userId,
playlistUrl: playlistUrl,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
playlistUrl: urlResult.value,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Queue Adds API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,44 +1,47 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireNumber,
requireSession,
requireString,
} from "@/lib/api";
interface SeekBody {
serverId?: unknown;
seek?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId, seek } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<SeekBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_seek' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
// seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용.
const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true });
if (!seekResult.ok) return seekResult.response;
const { status, body } = await botRpc({
channel: "player:seek",
payload: {
action: "player_seek",
requestId: requestId,
serverId: serverId,
userId: userId,
seek: seek,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
seek: seekResult.value,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Play API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,42 +1,34 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface SkipBody {
serverId?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<SkipBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_skip' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
const { status, body } = await botRpc({
channel: "player:skip",
payload: {
action: "player_skip",
requestId: requestId,
serverId: serverId,
userId: userId,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Play API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,44 +1,50 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireNumber,
requireSession,
requireString,
} from "@/lib/api";
interface VolumeBody {
serverId?: unknown;
volume?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId, volume } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<VolumeBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_volume' 명령 전송
await Redis.publish("site-bot", JSON.stringify({
const volumeResult = requireNumber(bodyResult.data.volume, "volume", {
min: 0,
max: 100,
integer: true,
});
if (!volumeResult.ok) return volumeResult.response;
const { status, body } = await botRpc({
channel: "player:volume",
payload: {
action: "player_volume",
requestId: requestId,
serverId: serverId,
userId: userId,
volume: volume,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
volume: volumeResult.value,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Play API Error:", error);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,54 +1,10 @@
// src/app/api/queue/events/route.ts
import { NextRequest } from "next/server";
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
import { botEventStream } from "@/lib/sse";
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
// 프론트엔드에서 보낸 serverId 가져오기
const serverId = req.nextUrl.searchParams.get("serverId");
if (!serverId) {
return new Response("Missing serverId", { status: 400 });
}
// SSE(Server-Sent Events) 스트림 생성
const stream = new ReadableStream({
async start(controller) {
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
const subscriber = Redis.duplicate();
// 'bot-site' 채널 구독
await subscriber.subscribe("bot-site");
// 메세지가 들어올 때마다 실행
subscriber.on("message", (channel, message) => {
if (channel !== "bot-site") return;
const data = JSON.parse(message);
if (data.guildId !== serverId) return;
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
if (data.event === "queue_update") {
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`);
}
});
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
req.signal.addEventListener("abort", () => {
subscriber.unsubscribe("bot-site");
subscriber.quit();
controller.close();
});
}
});
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
return botEventStream(req, { botEventName: "queue_update" });
}

View File

@@ -1,44 +1,34 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface QueueListBody {
serverId?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, userId } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<QueueListBody>(request);
if (!bodyResult.ok) return bodyResult.response;
// 1. 고유한 요청 ID(진동벨) 생성
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `queue:list:${requestId}`;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 2. 봇에게 'queue_list' 명령 발송
await Redis.publish("site-bot", JSON.stringify({
const { status, body } = await botRpc({
channel: "queue:list",
payload: {
action: "queue_list",
serverId: serverId,
userId: userId,
requestId: requestId, // 🌟 봇이 대답을 남길 키
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
},
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Queue List API Error:", error);
return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 });
Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,45 +1,51 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireNumber,
requireSession,
requireString,
} from "@/lib/api";
interface QueueRemoveBody {
serverId?: unknown;
index?: unknown;
encoded?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, index, userId } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<QueueRemoveBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `queue:remove:${requestId}`;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라)
await Redis.publish("site-bot", JSON.stringify({
// index 0 도 정상값
const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true });
if (!indexResult.ok) return indexResult.response;
const { status, body } = await botRpc({
channel: "queue:remove",
payload: {
action: "queue_remove",
serverId: serverId,
requestId: requestId,
userId: userId,
index: index,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling)
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
for (let i = 0; i < 10; i++) {
// 0.5초 대기
await new Promise(resolve => setTimeout(resolve, 500));
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
return NextResponse.json(JSON.parse(resultData));
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
index: indexResult.value,
// 인덱스-트랙 일치 검증용(봇이 대상 encoded 불일치 시 거절). 문자열일 때만 전달.
encoded: typeof bodyResult.data.encoded === "string" ? bodyResult.data.encoded : undefined,
},
timeoutMs: 5000,
});
return NextResponse.json(body, { status });
} catch (error) {
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,46 +1,40 @@
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface QueueSetBody {
serverId?: unknown;
newQueue?: unknown;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { serverId, newQueue, userId } = body;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 });
const bodyResult = await readJsonBody<QueueSetBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `queue:set:${requestId}`;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!)
await Redis.publish("site-bot", JSON.stringify({
const newQueue = bodyResult.data.newQueue;
if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다.");
const { status, body } = await botRpc({
channel: "queue:set",
payload: {
action: "queue_set",
serverId: serverId,
requestId: requestId,
userId: userId,
newQueue: newQueue,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling)
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
for (let i = 0; i < 10; i++) {
// 0.5초 대기
await new Promise(resolve => setTimeout(resolve, 500));
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
return NextResponse.json(JSON.parse(resultData));
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
serverId: serverIdResult.value,
userId,
newQueue,
},
timeoutMs: 5000,
});
return NextResponse.json(body, { status });
} catch (error) {
console.error("Queue Reorder API Error:", error);
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,42 +1,29 @@
// src/app/api/search/route.ts
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, requireSession } from "@/lib/api";
export async function GET(request: Request) {
// 1. 검색어(query) 가져오기
try {
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
const query = searchParams.get("q")?.trim();
if (!query) {
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 });
}
if (!query) return errorResponse("검색어가 없습니다.", 400);
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `search:${requestId}`;
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
await Redis.publish("site-bot", JSON.stringify({
const { status, body } = await botRpc({
channel: "search",
payload: {
action: "search",
query: query,
requestId: requestId,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling)
// 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다.
for (let i=0; i<10; i++) {
// 1.0초 대기
await new Promise(resolve => setTimeout(resolve, 1000));
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
return NextResponse.json(JSON.parse(resultData));
query,
},
timeoutMs: 10000,
});
return NextResponse.json(body, { status });
} catch (error) {
Logger.error(`search API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
}

View File

@@ -1,34 +1,58 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { Redis } from "@/lib/Redis";
import { authOptions } from "../auth/[...nextauth]/route";
import { Logger } from "@/lib/Logger";
import { requireSession } from "@/lib/api";
interface DiscordGuild {
id: string;
name: string;
icon: string | null;
owner: boolean;
permissions: string;
}
export async function GET() {
const session = await getServerSession(authOptions) as any;
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
if (!session || !session.accessToken) {
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
const accessToken = sessionResult.session.accessToken;
if (!accessToken) {
return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 });
}
try {
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${session.accessToken}` },
headers: { Authorization: `Bearer ${accessToken}` },
});
const userGuilds = await userGuildsRes.json() ?? [];
if (!userGuildsRes.ok) {
Logger.warn(`Discord guilds API ${userGuildsRes.status} ${userGuildsRes.statusText}`);
return NextResponse.json(
{ success: false, error: "Discord 서버 목록을 가져오지 못했습니다." },
{ status: 502 },
);
}
const userGuildsRaw: unknown = await userGuildsRes.json();
const userGuilds: DiscordGuild[] = Array.isArray(userGuildsRaw) ? (userGuildsRaw as DiscordGuild[]) : [];
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
const botGuildsData = await Redis.get("bot-guilds");
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : [];
let botGuildIds: string[] = [];
if (botGuildsData) {
try {
const parsed = JSON.parse(botGuildsData);
if (Array.isArray(parsed)) botGuildIds = parsed.filter((v): v is string => typeof v === "string");
} catch {
Logger.warn("Redis bot-guilds JSON 파싱 실패");
}
}
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
const filteredGuilds = userGuilds.filter((guild: any) =>
botGuildIds.includes(guild.id)
);
const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id));
return NextResponse.json(filteredGuilds);
} catch (error) {
console.error("서버 필터링 에러:", error);
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
}
}

View File

@@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent";
import QueueSidebar from "@/components/player/QueueSidebar";
import PlayerBar from "@/components/player/PlayerBar";
import type { DiscordServer } from "@/types/music";
// 화면 모드 타입 정의
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
export default function MusicPlayerLayout() {
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
const [selectedServer, setSelectedServer] = useState<any>(null);
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
@@ -33,7 +34,7 @@ export default function MusicPlayerLayout() {
};
// 서버 선택 시
const handleSelectServer = (server: any) => {
const handleSelectServer = (server: DiscordServer) => {
setSelectedServer(server);
setViewMode("SERVER_DETAIL");
};

View File

@@ -1,25 +1,20 @@
"use client";
import { ListMusic, Library } from "lucide-react";
import { ListMusic } from "lucide-react";
export default function LeftSidebar() {
return (
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
<Library size={20} /> 내 플레이리스트
</button>
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
<ListMusic size={20} /> 좋아요 표시한 곡
</button>
</nav>
<div className="flex items-center gap-2 text-neutral-400 font-medium">
<ListMusic size={20} />
<span>음악 봇 컨트롤</span>
</div>
<hr className="border-neutral-800" />
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
<p className="hover:text-white cursor-pointer truncate">출근길 노동요 모음</p>
<p className="hover:text-white cursor-pointer truncate">2024 빌보드 탑 100</p>
<p className="hover:text-white cursor-pointer truncate">비 오는 날 듣기 좋은 재즈</p>
</div>
<p className="text-xs text-neutral-500 leading-relaxed">
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
</p>
</aside>
);
}

View File

@@ -2,11 +2,12 @@
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react";
import type { DiscordServer } from "@/types/music";
interface TopNavProps {
onSearch: (query: string) => void;
onHome: () => void;
selectedServer: any; // 🌟 추가: 선택된 서버 정보
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
}
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {

View File

@@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc }
import { ViewMode } from "@/app/page";
// 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
interface MainContentProps {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
selectedServer: any;
setSelectedServer: (server: any) => void;
selectedServer: DiscordServer | null;
setSelectedServer: (server: DiscordServer | null) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
onSelectServer: (server: any) => void;
}
interface SearchResultsType {
spotify: any[];
youtubeMusic: any[];
youtubeVideo: any[];
onSelectServer: (server: DiscordServer) => void;
}
export default function MainContent({
@@ -36,27 +31,31 @@ export default function MainContent({
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
const { showToast } = useToast();
const [servers, setServers] = useState<any[]>([]);
const [servers, setServers] = useState<DiscordServer[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResultsType>({
const [searchResults, setSearchResults] = useState<SearchResults>({
spotify: [],
youtubeMusic: [],
youtubeVideo: []
});
const [isSearching, setIsSearching] = useState(false);
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
if (!selectedServer) {
// 🌟 alert -> showToast 교체
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
return;
}
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) {
showToast("로그인이 필요합니다.", "error");
return;
}
let endpoint = "";
let bodyData: any = { serverId: selectedServer.id, userId: userId };
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
if (actionType === 'player_play') {
endpoint = "/api/player/play";
bodyData.track = track;
@@ -88,13 +87,16 @@ export default function MainContent({
}
};
const getPermissionLabel = (server: any) => {
const getPermissionLabel = (server: DiscordServer | null) => {
if (!server) return "알 수 없음";
if (server.owner) return "👑 서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
const raw: unknown = server.permissions;
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
try {
const perms = BigInt(server.permissions);
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
const perms = BigInt(raw);
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
return "👤 일반 멤버";
} catch {
return "👤 일반 멤버";
@@ -106,9 +108,17 @@ export default function MainContent({
setIsFetching(true);
const cached = sessionStorage.getItem("filtered_servers");
if (cached) {
setServers(JSON.parse(cached));
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed)) {
setServers(parsed);
setIsFetching(false);
}
} catch {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
sessionStorage.removeItem("filtered_servers");
}
}
fetch("/api/servers")
.then((res) => res.json())
@@ -159,8 +169,8 @@ export default function MainContent({
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
const renderTrackCard = (track: any) => (
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
const renderTrackCard = (track: SearchTrack) => (
<div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
<button
@@ -258,7 +268,7 @@ export default function MainContent({
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
{viewMode === "SEARCH_RESULT" && (
<div>
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" 검색 결과</h2>
<h2 className="text-2xl font-bold mb-2">&ldquo;{searchQuery}&rdquo; 검색 결과</h2>
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
{isSearching ? (

View File

@@ -2,16 +2,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
import { useSession } from "next-auth/react";
import type { DiscordServer, Track } from "@/types/music";
interface PlayerBarProps {
selectedServer: any;
selectedServer: DiscordServer | null;
}
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const { data: session } = useSession();
// 재생 상태 관리
const [track, setTrack] = useState<any>(null);
const [track, setTrack] = useState<Track | null>(null);
const [botPlayer, setBotPlayer] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isPaused, setIsPaused] = useState<boolean>(false);
@@ -21,6 +22,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const [position, setPosition] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
// eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
@@ -28,7 +34,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const fetchNowPlaying = useCallback(async () => {
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
try {
@@ -37,21 +43,22 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
const data = await res.json();
if (res.ok && data.success && data.track) {
setBotPlayer(data.botPlayer);
setIsPlaying(data.isPlaying);
setIsPaused(data.isPaused);
setTrack(data.track);
setDuration(data.track.info.length || 0);
setVolume(data.volume ?? 50);
setBotPlayer(Boolean(data.botPlayer));
setIsPlaying(Boolean(data.isPlaying));
setIsPaused(Boolean(data.isPaused));
setTrack(data.track as Track);
setDuration(Number(data.track?.info?.length ?? 0) || 0);
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
setPosition(data.position || 0);
const p = data.position || 0;
setPosition(p);
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
}
} else {
setTrack(null);
@@ -67,45 +74,53 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "player_update") {
if (data?.type === "player_update") {
fetchNowPlaying();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
eventSource.onerror = () => {
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
const isPausedRef = useRef(isPaused);
useEffect(() => {
let localInterval: NodeJS.Timeout;
let syncInterval: NodeJS.Timeout;
isPausedRef.current = isPaused;
}, [isPaused]);
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
if (isPlaying && !isDragging.current) {
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
localInterval = setInterval(() => {
if (!isPaused) setPosition((prev) => {
if (prev >= duration) return duration;
return prev + 1000;
});
}, 1000);
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
syncInterval = setInterval(() => {
if (!isPaused) fetchNowPlaying();
}, 10000);
}
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
useEffect(() => {
if (!isPlaying) return;
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
const tick = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
const a = anchorRef.current;
const next = a.pos + (Date.now() - a.at);
setPosition(duration > 0 ? Math.min(duration, next) : next);
}, 250);
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 15000);
return () => {
clearInterval(localInterval);
clearInterval(tick);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
@@ -117,44 +132,46 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const handleTogglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(!isPaused);
setIsPaused(nextPaused);
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
isPaused: String(isPaused),
isPaused: nextPaused, // boolean 그대로 전송
})
});
const data = await res.json();
if (res.ok && data.success) {
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
setIsPaused(true);
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
if (typeof data.paused === "boolean") setIsPaused(data.paused);
} else {
setIsPaused(false);
}
// 실패 시 롤백
setIsPaused(!nextPaused);
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!isPaused); // 실패 시 롤백
setIsPaused(!nextPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const handleSkip = async () => {
if (!selectedServer || !track) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
try {
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
@@ -167,11 +184,14 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
if (!selectedServer || !track) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
isDragging.current = false;
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
const newPosition = Number(e.currentTarget.value);
setPosition(newPosition);
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
try {
await fetch('/api/player/seek', {
@@ -180,7 +200,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
body: JSON.stringify({
serverId: selectedServer.id,
seek: newPosition,
userId: userId,
})
});
} catch (error) {
@@ -198,22 +217,25 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
setIsVolumeDragging(false);
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
const finalVolume = Number(e.currentTarget.value);
setVolume(finalVolume); // UI 즉시 반영
try {
await fetch('/api/player/volume', {
const res = await fetch('/api/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
volume: finalVolume,
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
} catch (error) {
console.error("볼륨 조절 에러:", error);
setVolume(prevVolumeRef.current); // 실패 시 롤백
}
};
@@ -331,8 +353,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
value={volume}
disabled={!botPlayer || !isPlaying || !track}
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
onMouseDown={() => setIsVolumeDragging(true)}
onTouchStart={() => setIsVolumeDragging(true)}
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"

View File

@@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { Trash2, GripVertical, Music } from "lucide-react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
interface QueueSidebarProps {
selectedServer: any;
selectedServer: DiscordServer | null;
}
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
@@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
// 👇 [추가] 토스트 사용 준비 완료!
const { showToast } = useToast();
const [queue, setQueue] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [queue, setQueue] = useState<Track[]>([]);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const fetchQueue = useCallback(async () => {
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
setIsLoading(true);
try {
const res = await fetch('/api/queue/list', {
method: 'POST',
@@ -41,35 +40,39 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
setQueue(data.queue);
setQueue(data.queue as Track[]);
} else {
setQueue([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
setQueue([]);
} finally {
setIsLoading(false);
}
}, [selectedServer, session]);
useEffect(() => {
if (status === "loading" || !selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchQueue();
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "queue_update") {
if (data?.type === "queue_update") {
fetchQueue();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
eventSource.onerror = (error) => {
console.error("SSE 연결 오류:", error);
eventSource.close();
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
eventSource.onerror = () => {
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
};
return () => {
@@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
}
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
// 3. 화면 즉시 업데이트
const newQueue = [...queue];
@@ -139,8 +142,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const handleDelete = async (indexToRemove: number) => {
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
const targetEncoded = queue[indexToRemove]?.encoded;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);
@@ -150,8 +154,8 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
index: indexToRemove,
encoded: targetEncoded,
})
});
const data = await res.json();
@@ -182,7 +186,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
return (
<div
key={`${item.id || index}-${item.info?.title || ''}`}
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
draggable
onDragStart={() => {
dragItem.current = index;

View File

@@ -1,28 +1,43 @@
import colors from "colors/safe";
// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출.
export const Timestamp = () => {
const Now = new Date();
Now.setHours(Now.getHours() + 9);
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
}
const now = new Date(Date.now() + 9 * 60 * 60 * 1000);
// YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss
const iso = now.toISOString();
const date = iso.slice(2, 10); // YY-MM-DD
const time = iso.slice(11, 19); // HH:mm:ss
return `${date} ${time}`;
};
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
type LogType = "log" | "info" | "warn" | "error" | "debug" | "ready";
const log = (content: string, type: logType) => {
const isProd = process.env.NODE_ENV === "production";
const write = (label: string, content: string, useStderr: boolean) => {
const timestamp = colors.white(`[${Timestamp()}]`);
const line = `${label} ${timestamp} ${content}`;
if (useStderr) console.error(line);
else console.log(line);
};
const log = (content: string, type: LogType) => {
switch (type) {
case "log":
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
// 일반 디버그성 로그는 프로덕션에서 숨김
if (isProd) return;
return write(colors.gray("[LOG]"), content, false);
case "info":
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
return write(colors.cyan("[INFO]"), content, false);
case "warn":
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
return write(colors.yellow("[WARN]"), content, true);
case "error":
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
return write(colors.red("[ERROR]"), content, true);
case "debug":
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
if (isProd) return;
return write(colors.magenta("[DEBUG]"), content, false);
case "ready":
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
return write(colors.green("[READY]"), content, false);
default:
throw new TypeError("Logger 타입이 올바르지 않습니다.");
}
@@ -34,5 +49,5 @@ export const Logger = {
error: (content: string) => log(content, "error"),
debug: (content: string) => log(content, "debug"),
info: (content: string) => log(content, "info"),
ready: (content: string) => log(content, "ready")
}
ready: (content: string) => log(content, "ready"),
};

View File

@@ -1,7 +1,16 @@
import { Redis as RedisClass } from "ioredis";
import { Logger } from "@/lib/Logger";
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅)
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7";
// Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다.
const REDIS_HOST = process.env.REDIS_HOST?.trim();
const REDIS_PORT = Number(process.env.REDIS_PORT?.trim() ?? "6379");
if (!REDIS_HOST) {
throw new Error("[Redis] REDIS_HOST 환경변수가 설정되지 않았습니다.");
}
if (!Number.isFinite(REDIS_PORT) || REDIS_PORT <= 0) {
throw new Error(`[Redis] REDIS_PORT 값이 올바르지 않습니다: ${process.env.REDIS_PORT}`);
}
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
const globalForRedis = global as unknown as {
@@ -9,16 +18,24 @@ const globalForRedis = global as unknown as {
};
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 });
export const Redis = globalForRedis.redisClient ?? new RedisClass({
host: REDIS_HOST,
port: REDIS_PORT,
lazyConnect: false,
maxRetriesPerRequest: 3,
});
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
// 연결 성공 시 로그 한 번만 찍기
let connectLogged = false;
Redis.on("connect", () => {
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!");
if (connectLogged) return;
connectLogged = true;
Logger.ready(`Redis 연결 완료 (${REDIS_HOST}:${REDIS_PORT})`);
});
Redis.on("error", (err) => {
console.error("❌ [Next.js] Redis 연결 에러:", err);
Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`);
});

146
page/src/lib/api.ts Normal file
View File

@@ -0,0 +1,146 @@
import { NextResponse } from "next/server";
import { getServerSession, type Session } from "next-auth";
import { randomUUID } from "node:crypto";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
// ========== 공용 응답 스키마 ==========
// 모든 API는 { success: boolean, ... } 형태로 응답한다.
// 실패시 { success: false, error: string } 보장.
export const errorResponse = (message: string, status = 400) =>
NextResponse.json({ success: false, error: message }, { status });
// ========== 세션 가드 ==========
// 세션이 없으면 401 NextResponse를 던지고, 있으면 세션을 돌려준다.
export async function requireSession(): Promise<
{ ok: true; session: Session } | { ok: false; response: NextResponse }
> {
const session = (await getServerSession(authOptions)) as Session | null;
if (!session?.user?.id) {
return { ok: false, response: errorResponse("인증되지 않았습니다.", 401) };
}
return { ok: true, session };
}
// ========== 봇 RPC 헬퍼 ==========
// site → bot: Redis Pub/Sub 으로 명령 전송 (채널 "site-bot")
// bot → site: 봇이 결과를 resultKey 리스트에 LPUSH
// 사이트는 resultKey 를 BRPOP 으로 블로킹 대기한다(폴링 없음 → 응답 즉시, 헛 GET 제거).
export interface BotRpcOptions {
/** Redis 결과 키 prefix (e.g. "player:now") */
channel: string;
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
payload: Record<string, unknown>;
/** 응답 대기 타임아웃 (ms). 기본 3000. */
timeoutMs?: number;
}
export async function botRpc(
opts: BotRpcOptions,
): Promise<{ status: number; body: Record<string, unknown> }> {
const { channel, payload, timeoutMs = 3000 } = opts;
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
const requestId = `req:${randomUUID()}`;
const resultKey = `${channel}:${requestId}`;
// 봇에게 명령 전송 (action 필드는 호출부에서 payload 에 포함)
await Redis.publish(
"site-bot",
JSON.stringify({ ...payload, requestId }),
);
// BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록
// 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다.
// (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음)
const waiter = Redis.duplicate();
// BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달.
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
try {
// brpop → [key, value] | null(타임아웃)
const popped = await waiter.brpop(resultKey, timeoutSec);
if (!popped) {
return {
status: 504,
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
};
}
const reply = popped[1];
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(reply);
} catch (err) {
Logger.error(`[botRpc:${channel}] 봇 응답 파싱 실패: ${String(err)}`);
return {
status: 502,
body: { success: false, error: "봇 응답을 해석할 수 없습니다." },
};
}
// 봇이 success 필드를 명시한 경우만 false로 평가. 없으면 데이터 응답으로 간주(200).
const hasSuccessField = "success" in parsed;
const ok = !hasSuccessField || parsed.success === true;
return { status: ok ? 200 : 400, body: parsed };
} finally {
// 전용 커넥션 정리 (블로킹 중이면 즉시 끊기)
waiter.disconnect();
}
}
// ========== POST 본문 파싱 ==========
export async function readJsonBody<T = unknown>(
request: Request,
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
const data = (await request.json()) as T;
return { ok: true, data };
} catch {
return { ok: false, response: errorResponse("요청 본문이 올바른 JSON이 아닙니다.", 400) };
}
}
// ========== 필수 필드 검증 ==========
export function requireString(
value: unknown,
field: string,
): { ok: true; value: string } | { ok: false; response: NextResponse } {
if (typeof value !== "string" || !value.trim()) {
return { ok: false, response: errorResponse(`${field} 정보가 필요합니다.`, 400) };
}
return { ok: true, value: value.trim() };
}
export function requireNumber(
value: unknown,
field: string,
opts?: { min?: number; max?: number; integer?: boolean },
): { ok: true; value: number } | { ok: false; response: NextResponse } {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) {
return { ok: false, response: errorResponse(`${field} 값이 올바르지 않습니다.`, 400) };
}
if (opts?.integer && !Number.isInteger(n)) {
return { ok: false, response: errorResponse(`${field} 는 정수여야 합니다.`, 400) };
}
if (opts?.min !== undefined && n < opts.min) {
return { ok: false, response: errorResponse(`${field} 가 너무 작습니다.`, 400) };
}
if (opts?.max !== undefined && n > opts.max) {
return { ok: false, response: errorResponse(`${field} 가 너무 큽니다.`, 400) };
}
return { ok: true, value: n };
}
export function requireBoolean(
value: unknown,
field: string,
): { ok: true; value: boolean } | { ok: false; response: NextResponse } {
if (typeof value !== "boolean") {
return { ok: false, response: errorResponse(`${field} 는 boolean 이어야 합니다.`, 400) };
}
return { ok: true, value };
}

122
page/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,122 @@
import { NextRequest } from "next/server";
import { getServerSession } from "next-auth";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
interface BotEvent {
event?: unknown;
guildId?: unknown;
[key: string]: unknown;
}
interface BotEventStreamOptions {
/** bot 이 발행하는 event 이름 (e.g. "player_update", "queue_update"). */
botEventName: string;
/** 클라이언트로 보낼 SSE type. 기본: botEventName 과 동일. */
clientEventType?: string;
}
// ─────────────────────────────────────────────────────────────
// 공유 구독자(fan-out): "bot-site" 채널은 프로세스당 구독자 1개만 두고,
// 메모리에서 리스너들에게 분배한다. (SSE 연결마다 Redis 커넥션을 복제하지 않음)
// ─────────────────────────────────────────────────────────────
interface Listener {
guildId: string;
botEventName: string;
send: () => void;
}
const listeners = new Set<Listener>();
const globalForSse = global as unknown as { botSiteSubscriber?: ReturnType<typeof Redis.duplicate> };
function ensureSubscriber() {
if (globalForSse.botSiteSubscriber) return;
const sub = Redis.duplicate();
globalForSse.botSiteSubscriber = sub;
sub.on("error", (err) => Logger.error(`[SSE] shared subscriber error: ${err.message}`));
sub.subscribe("bot-site").catch((err) => Logger.error(`[SSE] subscribe 실패: ${String(err)}`));
sub.on("message", (channel, message) => {
if (channel !== "bot-site") return;
let data: BotEvent;
try {
data = JSON.parse(message) as BotEvent;
} catch (err) {
Logger.warn(`[SSE] 잘못된 JSON: ${String(err)}`);
return;
}
for (const l of listeners) {
if (data.guildId === l.guildId && data.event === l.botEventName) {
try { l.send(); } catch { /* 개별 클라이언트 전송 실패는 무시 */ }
}
}
});
}
/**
* 봇이 publish 하는 "bot-site" 채널을 공유 구독자로 받아 SSE 로 흘려보내는 공용 핸들러.
* - 인증 가드: 세션 없으면 401
* - serverId 검증
* - 공유 구독자 + 메모리 fan-out (연결마다 Redis 커넥션 복제 안 함)
* - keepalive ping (30초)
*/
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
const clientEventType = opts.clientEventType ?? opts.botEventName;
const session = await getServerSession(authOptions);
if (!session) {
return new Response("인증되지 않았습니다.", { status: 401 });
}
const serverId = req.nextUrl.searchParams.get("serverId")?.trim();
if (!serverId) {
return new Response("Missing serverId", { status: 400 });
}
ensureSubscriber();
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(ping);
listeners.delete(listener);
try { controller.close(); } catch { /* 이미 닫힘 */ }
};
const listener: Listener = {
guildId: serverId,
botEventName: opts.botEventName,
send: () => {
if (closed) return;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`));
},
};
listeners.add(listener);
const ping = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`: keep-alive\n\n`));
} catch {
cleanup();
}
}, 30000);
req.signal.addEventListener("abort", cleanup);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

42
page/src/types/music.ts Normal file
View File

@@ -0,0 +1,42 @@
// 공용 도메인 타입 — 봇/Discord/Lavalink 데이터 표현.
export interface DiscordServer {
id: string;
name: string;
icon: string | null;
owner: boolean;
permissions: string;
}
export interface TrackInfo {
identifier?: string;
title?: string;
author?: string;
length?: number;
artworkUrl?: string;
uri?: string;
}
export interface Track {
encoded?: string;
info?: TrackInfo;
// 큐 항목에는 종종 추가 메타데이터(요청자 등)가 붙음.
[key: string]: unknown;
}
// 검색 결과(곡 카드) 공용 타입 — Spotify/YT Music/YT Video 모두 매핑됨.
export interface SearchTrack {
videoId?: string;
id?: string;
url?: string;
title?: string;
artist?: string;
thumbnail?: string;
duration?: number;
}
export interface SearchResults {
spotify: SearchTrack[];
youtubeMusic: SearchTrack[];
youtubeVideo: SearchTrack[];
}

21
page/src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
accessToken?: string;
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
accessToken?: string;
}
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

266
resolver/ytresolver.py Normal file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
ytresolver - yt-dlp 오디오 URL 리졸버 (HTTP 서비스)
봇(GuildPlayer.resolvePlayable)이 유튜브/스포티파이-미러 트랙을 재생하기 직전에 호출한다.
Lavalink의 youtube-plugin은 집 IP의 SABR 차단/성인인증(연령제한) 때문에 실재생이 막히므로,
여기서 yt-dlp로 "직접 오디오 URL"을 뽑아 봇에 돌려주고 봇은 그 URL을 Lavalink http 소스로 재생한다.
동작:
1) 먼저 익명(쿠키 없이)으로 시도 -> 일반 영상은 계정을 태우지 않는다(밴 리스크 최소화).
2) 실패하면 쿠키 + node(EJS 시그니처 해독) 로 재시도 -> 성인인증(연령제한) 영상 우회.
환경변수:
YTRESOLVER_COOKIE : Netscape 쿠키 파일 경로 (기본 /root/bot/db/ytcookie.txt)
YTRESOLVER_PORT : 리슨 포트 (기본 8779)
YTDLP : yt-dlp 실행 경로 (기본 /usr/local/bin/yt-dlp)
HTTP API:
GET /resolve?url=<유튜브 URL | ytsearch: | 검색어> -> {"url": "<직접 오디오 URL>"}
GET /health -> {"ok": true}
"""
import json, subprocess, time, threading, os, sys
sys.path.insert(0, os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")) # zipapp 내부 모듈 임포트
import yt_dlp
_YDL_OPTS = {"format": "bestaudio/best", "noplaylist": True, "quiet": True,
"no_warnings": True, "nocheckcertificate": True, "socket_timeout": 15,
"extractor_args": {"youtube": {"player_client": ["default"]}}}
_ydl = yt_dlp.YoutubeDL(_YDL_OPTS) # 프로세스 상주(임포트/초기화 1회)
_ydl_lock = threading.Lock()
# ===== 유튜브 내부 API(VISIONOS) 직접 호출 =====
# yt-dlp 를 거치지 않고 player API 한 번으로 오디오 직링크를 얻는다(실측 0.3초대).
# visitorData 는 이 프로세스가 직접 발급/캐시하며, 실패하면 즉시 재발급한다.
# 연령제한/로그인 필요 영상은 여기서 None 을 돌려 아래 기존 경로(yt-dlp+쿠키)로 폴백한다.
import urllib.request as _ur
_VD_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
_IT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15"
_VID_RE = __import__("re").compile(r"(?:v=|youtu\.be/|/shorts/|/embed/|/live/)([A-Za-z0-9_-]{11})")
_VD_TTL = 6 * 3600
_vd = {"value": "", "expires": 0}
_vd_lock = threading.Lock()
def _post_player(payload, ua, extra_headers=None):
headers = {"Content-Type": "application/json", "User-Agent": ua}
if extra_headers:
headers.update(extra_headers)
req = _ur.Request("https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
data=json.dumps(payload).encode(), headers=headers)
return json.loads(_ur.urlopen(req, timeout=15).read())
def get_visitor_data(force=False):
now = time.time()
with _vd_lock:
if not force and _vd["value"] and _vd["expires"] > now:
return _vd["value"]
body = {"videoId": "xgJ13Dt9F14",
"context": {"client": {"clientName": "WEB", "clientVersion": "2.20260101.00.00",
"hl": "ko", "gl": "KR"}}}
d = _post_player(body, _VD_UA)
vd = (d.get("responseContext") or {}).get("visitorData") or ""
if vd:
with _vd_lock:
_vd["value"] = vd
_vd["expires"] = time.time() + _VD_TTL
print("[visitorData] 갱신 완료(len=%d)" % len(vd), file=sys.stderr, flush=True)
return vd
def _innertube_once(vid, vd):
ctx = {"clientName": "VISIONOS", "clientVersion": "1.02", "deviceMake": "Apple",
"deviceModel": "RealityDevice17,1", "osName": "visionOS", "osVersion": "26.5.23O471",
"hl": "ko", "gl": "KR", "visitorData": vd}
body = {"videoId": vid, "context": {"client": ctx}, "contentCheckOk": True, "racyCheckOk": True}
d = _post_player(body, _IT_UA, {"X-Goog-Visitor-Id": vd,
"X-Youtube-Client-Name": "101", "X-Youtube-Client-Version": "1.02"})
status = d.get("playabilityStatus", {}).get("status")
if status != "OK":
return None, status
af = (d.get("streamingData") or {}).get("adaptiveFormats") or []
au = [f for f in af if str(f.get("mimeType", "")).startswith("audio") and f.get("url")]
if not au:
return None, "NO_AUDIO"
au.sort(key=lambda f: f.get("bitrate", 0), reverse=True)
return au[0]["url"], status
def innertube_resolve(target):
m = _VID_RE.search(target)
if not m:
return None # 검색어 등 영상 ID 가 없는 입력 -> 기존 경로
vid = m.group(1)
vd = get_visitor_data()
if not vd:
return None
url, status = _innertube_once(vid, vd)
if url:
return url
# visitorData 만료로 인한 로그인 요구면 1회 재발급 후 재시도
if status in ("LOGIN_REQUIRED", "ERROR"):
vd = get_visitor_data(force=True)
if vd:
url, status = _innertube_once(vid, vd)
if url:
return url
print("[innertube] 폴백(status=%s)" % status, file=sys.stderr, flush=True)
return None
def lib_resolve(target):
# yt-dlp 를 라이브러리로 직접 호출: 매 요청 프로세스 기동(약 0.4s) 을 없앤다.
q = target if (target.startswith("http") or target.startswith("ytsearch")) else "ytsearch1:" + target
with _ydl_lock:
info = _ydl.extract_info(q, download=False)
if info.get("entries"):
info = info["entries"][0]
u = info.get("url")
if not u:
for f in (info.get("requested_formats") or []):
if f.get("url"): u = f["url"]; break
return u
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")
COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt")
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
CACHE = {}
INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing)
LOCK = threading.Lock()
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
# 속도/안정성 공통 플래그
COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings",
"--no-check-certificate", "--socket-timeout", "15"]
def run_ytdlp(target, use_cookies=False):
if use_cookies and os.path.exists(COOKIE_FILE):
# 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요
cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE,
*COMMON, "--extractor-args", "youtube:player_client=web"]
else:
# 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합.
cmd = [YTDLP, *COMMON,
"--extractor-args", "youtube:player_client=default,web_safari,tv"]
cmd += ["--get-url", target]
return subprocess.run(cmd, capture_output=True, text=True, timeout=45)
def first_url(out):
lines = (out.stdout or "").strip().splitlines()
return lines[0] if lines else None
def _do_resolve(target):
# 0순위: 내부 API 직접 호출(0.3초대). 실패하면 아래 기존 경로로 자동 폴백.
try:
_u = innertube_resolve(target)
if _u and _u.startswith("http"):
print("[result] OK(innertube) " + _u[:40], file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (_u, time.time() + TTL)
return _u
except Exception as e:
print("[it-fail] " + str(e)[:120], file=sys.stderr, flush=True)
# 1순위: 라이브러리 상주 호출(가장 빠름). 실패하면 기존 subprocess 경로로 폴백.
try:
u = lib_resolve(target)
if u and u.startswith("http"):
print("[result] OK(lib) " + u[:50], file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (u, time.time() + TTL)
return u
except Exception as e:
print("[lib-fail] " + str(e)[:150], file=sys.stderr, flush=True)
try:
out = run_ytdlp(target, use_cookies=False)
url = first_url(out)
if not (url and url.startswith("http")) and os.path.exists(COOKIE_FILE):
print(f"[retry-cookies] {(out.stderr or out.stdout or '')[:180]}", file=sys.stderr, flush=True)
out = run_ytdlp(target, use_cookies=True)
url = first_url(out)
except subprocess.TimeoutExpired:
print("[result] TIMEOUT", file=sys.stderr, flush=True)
return None
if not (url and url.startswith("http")):
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
return None
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (url, time.time() + TTL)
return url
def resolve(target):
now = time.time()
with LOCK:
hit = CACHE.get(target)
if hit and hit[1] > now:
return hit[0]
# 같은 target이 이미 리졸브 중이면, 새 yt-dlp를 띄우지 않고 그 결과를 기다린다.
# (봇이 음성 접속과 동시에 미리 리졸브를 걸어두면, 실제 재생 요청이 이 진행분에 합쳐져
# 음성 접속 시간과 리졸브 시간이 겹쳐 첫 재생 지연이 크게 줄어든다.)
entry = INFLIGHT.get(target)
owner = entry is None
if owner:
entry = {"event": threading.Event(), "url": None}
INFLIGHT[target] = entry
if not owner:
entry["event"].wait(50)
with LOCK:
hit = CACHE.get(target)
return hit[0] if hit and hit[1] > time.time() else entry.get("url")
# owner: 실제 리졸브 수행
url = None
try:
url = _do_resolve(target)
except Exception as e:
print(f"[result] ERR {str(e)[:120]}", file=sys.stderr, flush=True)
finally:
entry["url"] = url
with LOCK:
INFLIGHT.pop(target, None)
entry["event"].set()
return url
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
p = urlparse(self.path)
q = parse_qs(p.query)
if p.path == "/health":
self._send(200, {"ok": True, "cookie": os.path.exists(COOKIE_FILE)})
return
if p.path != "/resolve":
self._send(404, {"error": "not found"})
return
target = (q.get("url") or q.get("q") or [None])[0]
print(f"[resolve] {target[:80] if target else None}", file=sys.stderr, flush=True)
if not target:
self._send(400, {"error": "missing url/q"})
return
if not (target.startswith("http") or target.startswith("ytsearch")):
target = "ytsearch1:" + target
url = resolve(target)
if url:
self._send(200, {"url": url})
else:
self._send(502, {"error": "resolve failed"})
def _send(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
if __name__ == "__main__":
print(f"ytresolver listening on :{PORT} (cookie={COOKIE_FILE}, exists={os.path.exists(COOKIE_FILE)})",
file=sys.stderr, flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()

View File

@@ -0,0 +1,14 @@
[Unit]
Description=music_bot_v2 yt-dlp audio resolver
After=network.target
[Service]
# 쿠키/봇과 같은 호스트(.5)에서 실행. 쿠키 파일은 db 폴더에 둔다(봇 컨테이너와 공유).
Environment=YTRESOLVER_COOKIE=/root/bot/db/ytcookie.txt
Environment=YTRESOLVER_PORT=8779
ExecStart=/usr/bin/python3 /opt/ytresolver.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,91 @@
/**
* refresh-cookies.mjs — 성인인증 계정 쿠키(ytcookie.txt) 재발급 스크립트
*
* 쿠키가 만료되면(19금 재생만 막히고 일반 재생은 계속 됨) 이 스크립트로 새 쿠키를 뽑는다.
* Chromium을 띄워 구글 로그인 → youtube.com 쿠키를 Netscape 형식으로 저장한다.
*
* 사용 전: npm i -D playwright (그리고 최초 1회) npx playwright install chromium
*
* 실행 예:
* GOOGLE_EMAIL=tmdgks040466@gmail.com GOOGLE_PASSWORD='...' \
* OUT=/root/bot/db/ytcookie.txt node scripts/refresh-cookies.mjs
*
* 환경변수:
* GOOGLE_EMAIL / GOOGLE_PASSWORD : 자동 로그인 시도용(생략 가능 — 그러면 수동 로그인)
* OUT : 저장 경로 (기본 ./ytcookie.txt). 운영은 /root/bot/db/ytcookie.txt
* HEADLESS : "1"이면 창 없이(자동 로그인 성공 시에만 권장). 기본은 창 표시(수동 로그인 가능)
*
* 구글이 자동 로그인을 막으면(2차인증/캡차/"안전하지 않은 브라우저") 뜬 창에서 직접 로그인하면 된다.
* 스크립트는 로그인 세션 쿠키(SID)가 잡힐 때까지 최대 5분 기다린 뒤 저장한다.
*/
import { chromium } from "playwright";
import { writeFileSync } from "node:fs";
const EMAIL = process.env.GOOGLE_EMAIL;
const PASSWORD = process.env.GOOGLE_PASSWORD;
const OUT = process.env.OUT || "./ytcookie.txt";
const HEADLESS = process.env.HEADLESS === "1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Playwright 쿠키 배열 -> Netscape cookies.txt (yt-dlp/youtubei.js 호환)
function toNetscape(cookies) {
const lines = ["# Netscape HTTP Cookie File", "# music_bot_v2 refresh-cookies.mjs"];
for (const c of cookies) {
if (!/(^|\.)youtube\.com$|(^|\.)google\.com$/.test(c.domain)) continue;
const domain = c.domain.startsWith(".") ? c.domain : "." + c.domain;
const expires = c.expires && c.expires > 0 ? Math.floor(c.expires) : 2000000000;
lines.push([
domain, "TRUE", c.path || "/", c.secure ? "TRUE" : "FALSE",
expires, c.name, c.value,
].join("\t"));
}
return lines.join("\n") + "\n";
}
async function tryAutoLogin(page) {
if (!EMAIL || !PASSWORD) return;
try {
await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/", { waitUntil: "domcontentloaded" });
await page.fill('input[type="email"]', EMAIL, { timeout: 15000 });
await page.click('#identifierNext, button:has-text("다음"), button:has-text("Next")');
await page.waitForTimeout(2500);
await page.fill('input[type="password"]', PASSWORD, { timeout: 15000 });
await page.click('#passwordNext, button:has-text("다음"), button:has-text("Next")');
await page.waitForTimeout(3000);
} catch (e) {
console.log("[auto-login] 자동 로그인 중단(수동 로그인으로 진행):", String(e).slice(0, 100));
}
}
async function waitForLogin(context, page, maxMs = 5 * 60 * 1000) {
const start = Date.now();
while (Date.now() - start < maxMs) {
const cookies = await context.cookies("https://www.youtube.com");
if (cookies.some((c) => c.name === "SID") && cookies.some((c) => c.name === "LOGIN_INFO")) {
return cookies;
}
await sleep(3000);
}
return null;
}
(async () => {
const browser = await chromium.launch({ headless: HEADLESS });
const context = await browser.newContext();
const page = await context.newPage();
await tryAutoLogin(page);
// youtube를 한번 열어 로그인 세션 쿠키를 확실히 발급
try { await page.goto("https://www.youtube.com/", { waitUntil: "domcontentloaded" }); } catch {}
console.log("로그인 완료를 기다리는 중... (자동 로그인이 막혔으면 뜬 창에서 직접 로그인하세요)");
const cookies = await waitForLogin(context, page);
if (!cookies) {
console.error("❌ 로그인 세션 쿠키(SID/LOGIN_INFO)를 못 잡았습니다. 다시 시도하세요.");
await browser.close();
process.exit(1);
}
const all = await context.cookies();
writeFileSync(OUT, toNetscape(all));
console.log(`✅ 쿠키 저장 완료: ${OUT} (youtube/google 쿠키 ${all.length}개 중 관련 항목)`);
await browser.close();
process.exit(0);
})();