Compare commits
43 Commits
e476e23d37
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| 5811c16c20 | |||
| fed948ca02 | |||
| 8ab5b84fa7 | |||
| 79f6201f85 | |||
| ef16b67839 | |||
| 3c6dbc9631 | |||
| e7283ae862 | |||
| 2e6c956265 | |||
| 5d5bf9b3f3 | |||
| 7f057d818c | |||
| 4fbf8d9326 | |||
| 7994e97631 | |||
| 6762b8faba | |||
| 6de959be43 | |||
| 1bb1d10b25 | |||
| f5778a7763 | |||
| 8e3791fb37 | |||
| 31b64b73c5 | |||
|
|
221c31bfa1 | ||
|
|
1efa55c3ec | ||
|
|
ff391cc1c3 | ||
|
|
cb224cad88 | ||
|
|
3a4849acab | ||
|
|
e73f23582c | ||
|
|
3301913e7c | ||
|
|
ddf8801db7 | ||
|
|
fd92995255 | ||
|
|
83f7310d53 | ||
|
|
cf75d047bc | ||
|
|
56bea22e4a | ||
|
|
e198c5244a | ||
| adb3c59a85 | |||
| a090e853c2 | |||
| 1263ca4308 | |||
|
|
5003c044c5 | ||
|
|
6d79655181 | ||
|
|
5a81ce7416 | ||
|
|
d335287e7e | ||
|
|
67ff52227d | ||
|
|
09684b2070 | ||
|
|
e8c63a2118 | ||
|
|
5756f4e10a | ||
|
|
083b54e14e |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
venv
|
||||
**/node_modules
|
||||
dist
|
||||
65
README.md
65
README.md
@@ -1,13 +1,56 @@
|
||||
# music bot v2
|
||||
## 디스코드 음악봇 v2
|
||||
# music_bot_v2
|
||||
|
||||
### 마지막으로 만들었던 내용
|
||||
- 검색하고 재생 누르면 재생됨
|
||||
- 플레이리스트 재생 기능(주소 전달해서 재생하는 방식)
|
||||
- 아래 재생바 연동
|
||||
- 오른쪽 재생목록 연동
|
||||
디스코드 음악봇 + 웹 대시보드 모노레포. 디스코드에서 명령으로 음악을 틀 수도 있고, 웹에서 로그인해
|
||||
검색·재생·대기열을 제어할 수도 있다. 성인인증(연령제한) 영상 재생까지 지원한다.
|
||||
|
||||
### 이제 만들어야되는 내용
|
||||
- 왼쪽 플레이리스트 가져오기 제작
|
||||
- 플레이리스트 재생 추가
|
||||
- claude code 사용해서 개발중
|
||||
## 구성
|
||||
|
||||
| 디렉터리 | 내용 | 문서 |
|
||||
|----------|------|------|
|
||||
| [`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`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
venv/
|
||||
node_modules/
|
||||
dist/
|
||||
venv
|
||||
**/node_modules
|
||||
dist
|
||||
|
||||
40
bot/.env.example
Normal file
40
bot/.env.example
Normal 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
204
bot/README.md
Normal 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).
|
||||
683
bot/package-lock.json
generated
683
bot/package-lock.json
generated
@@ -10,12 +10,15 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"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",
|
||||
@@ -25,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",
|
||||
@@ -38,6 +58,111 @@
|
||||
"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.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||
@@ -353,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",
|
||||
@@ -360,6 +493,11 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -394,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",
|
||||
@@ -438,6 +584,18 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"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==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
@@ -462,6 +620,17 @@
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
@@ -469,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",
|
||||
@@ -486,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",
|
||||
@@ -510,6 +713,14 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -595,6 +806,19 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -604,6 +828,58 @@
|
||||
"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==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"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==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -619,24 +895,180 @@
|
||||
"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/form-data": {
|
||||
"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.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"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",
|
||||
@@ -693,6 +1125,50 @@
|
||||
"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.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -717,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",
|
||||
@@ -730,6 +1211,41 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"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==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
@@ -781,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",
|
||||
@@ -790,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",
|
||||
@@ -827,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",
|
||||
@@ -877,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",
|
||||
@@ -897,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",
|
||||
@@ -991,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",
|
||||
@@ -1019,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",
|
||||
@@ -1129,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",
|
||||
@@ -1156,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",
|
||||
@@ -1165,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
84
bot/scripts/potoken-refresh.mjs
Normal file
84
bot/scripts/potoken-refresh.mjs
Normal 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);
|
||||
}
|
||||
72
bot/scripts/ytresolver_innertube.py
Normal file
72
bot/scripts/ytresolver_innertube.py
Normal 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"]
|
||||
181
bot/scripts/ytresolver_lib.py
Normal file
181
bot/scripts/ytresolver_lib.py
Normal 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()
|
||||
@@ -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;
|
||||
@@ -23,6 +31,10 @@ export class GuildPlayer {
|
||||
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,
|
||||
@@ -42,6 +54,10 @@ export class GuildPlayer {
|
||||
clearTimeout(this.endTimer);
|
||||
this.endTimer = undefined;
|
||||
}
|
||||
// 새 곡이 시작되면 재생 위치 멈춤 감시 시작
|
||||
this.startStallWatchdog();
|
||||
// 현재 곡이 재생되는 동안 다음 곡을 미리 리졸브(곡 전환 지연 제거)
|
||||
this.prefetch(this.queue[1]);
|
||||
Redis?.publishState("player_update", {
|
||||
guildId: this.guild.id,
|
||||
});
|
||||
@@ -50,6 +66,26 @@ export class GuildPlayer {
|
||||
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) {
|
||||
@@ -95,16 +131,19 @@ export class GuildPlayer {
|
||||
|
||||
this.player.on("exception", async (data) => {
|
||||
try {
|
||||
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`);
|
||||
await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다.");
|
||||
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) => {
|
||||
try {
|
||||
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`);
|
||||
await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다.");
|
||||
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)}`);
|
||||
}
|
||||
@@ -156,8 +195,9 @@ export class GuildPlayer {
|
||||
|
||||
// 곡이 있었으면 이어서 재생
|
||||
if (currentTrack) {
|
||||
const reEncoded = await this.resolvePlayable(currentTrack);
|
||||
await this.player.playTrack({
|
||||
track: { encoded: currentTrack.encoded },
|
||||
track: { encoded: reEncoded },
|
||||
position: currentPosition,
|
||||
paused: wasPaused,
|
||||
});
|
||||
@@ -197,7 +237,8 @@ export class GuildPlayer {
|
||||
// 큐가 비어있으면 바로 재생
|
||||
await this.playNext();
|
||||
} else {
|
||||
// 재생목록 추가되었을때
|
||||
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
|
||||
this.prefetch(this.queue[1]);
|
||||
this.setMsg({ player: false, queue: true });
|
||||
}
|
||||
}
|
||||
@@ -206,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();
|
||||
}
|
||||
@@ -275,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;
|
||||
@@ -289,11 +417,12 @@ 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 === trackId) tracks = tracks.slice(1);
|
||||
if (tracks.length > 0 && tracks[0].info.identifier === dedupId) tracks = tracks.slice(1);
|
||||
if (tracks.length === 0) {
|
||||
this.end();
|
||||
return;
|
||||
@@ -301,6 +430,87 @@ export class GuildPlayer {
|
||||
this.addTracks(tracks, "자동재생");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -314,6 +524,7 @@ export class GuildPlayer {
|
||||
clearTimeout(this.closedTimer);
|
||||
this.closedTimer = undefined;
|
||||
}
|
||||
this.stopStallWatchdog();
|
||||
}
|
||||
|
||||
public end() {
|
||||
@@ -406,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,
|
||||
@@ -422,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;
|
||||
}
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,79 +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.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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
|
||||
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 (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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
|
||||
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.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
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.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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
|
||||
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.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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
|
||||
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) return 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 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.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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
|
||||
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) return 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)}`);
|
||||
@@ -222,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,
|
||||
@@ -232,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;
|
||||
@@ -240,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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -73,15 +74,38 @@ 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({
|
||||
|
||||
// 연결 가능한 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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,39 @@ 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: {
|
||||
|
||||
95
bot/src/utils/SelfTest.ts
Normal file
95
bot/src/utils/SelfTest.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ 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 = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
|
||||
const searchCache = new Map<string, string>();
|
||||
@@ -107,8 +109,9 @@ export const YoutubeMusic = {
|
||||
gl: "KR"
|
||||
}
|
||||
},
|
||||
query: query,
|
||||
params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE="
|
||||
query: query
|
||||
// NOTE: 과거의 고정 params("EgW...")는 YTM에서 "검색결과 없음"을 유발하므로 제거.
|
||||
// 필터 없이 요청하면 상단 Top Result(musicCardShelfRenderer) + 곡 목록이 함께 온다.
|
||||
}),
|
||||
...(proxy ? { dispatcher: proxy } : {})
|
||||
});
|
||||
@@ -157,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;
|
||||
@@ -201,7 +204,11 @@ export const YoutubeMusic = {
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
// 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) {
|
||||
Logger.error(`❌ getSearchFull 실행 중 에러: ${String(error)}`);
|
||||
return [];
|
||||
@@ -212,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;
|
||||
}
|
||||
};
|
||||
21
docker-compose.exp.yml
Normal file
21
docker-compose.exp.yml
Normal 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
6
page/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
venv
|
||||
**/node_modules
|
||||
dist
|
||||
21
page/Dockerfile
Normal file
21
page/Dockerfile
Normal 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"]
|
||||
@@ -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`)을 둔다 — 코드 수정 전 참고.
|
||||
|
||||
21
page/docker-compose.example.yml
Normal file
21
page/docker-compose.example.yml
Normal 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
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
interface QueueRemoveBody {
|
||||
serverId?: unknown;
|
||||
index?: unknown;
|
||||
encoded?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -37,6 +38,8 @@ export async function POST(request: Request) {
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
index: indexResult.value,
|
||||
// 인덱스-트랙 일치 검증용(봇이 대상 encoded 불일치 시 거절). 문자열일 때만 전달.
|
||||
encoded: typeof bodyResult.data.encoded === "string" ? bodyResult.data.encoded : undefined,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ export async function GET(request: Request) {
|
||||
query,
|
||||
},
|
||||
timeoutMs: 10000,
|
||||
pollIntervalMs: 250,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function MainContent({
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id, userId };
|
||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
|
||||
if (actionType === 'player_play') {
|
||||
endpoint = "/api/player/play";
|
||||
bodyData.track = track;
|
||||
@@ -108,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())
|
||||
@@ -162,7 +170,7 @@ export default function MainContent({
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
<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">
|
||||
<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
|
||||
|
||||
@@ -22,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);
|
||||
|
||||
@@ -38,7 +43,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -52,7 +56,9 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
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);
|
||||
@@ -84,15 +90,16 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("Player SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
return () => eventSource.close();
|
||||
}, [selectedServer, fetchNowPlaying]);
|
||||
|
||||
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
|
||||
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
|
||||
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
|
||||
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
|
||||
const isPausedRef = useRef(isPaused);
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused;
|
||||
@@ -100,25 +107,20 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||
const localInterval = setInterval(() => {
|
||||
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
|
||||
const tick = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
setPosition((prev) => {
|
||||
if (prev >= duration) return duration;
|
||||
return prev + 1000;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||
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();
|
||||
}, 10000);
|
||||
|
||||
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||
}, 15000);
|
||||
return () => {
|
||||
clearInterval(localInterval);
|
||||
clearInterval(tick);
|
||||
clearInterval(syncInterval);
|
||||
};
|
||||
}, [isPlaying, duration, fetchNowPlaying]);
|
||||
@@ -135,13 +137,13 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const nextPaused = !isPaused;
|
||||
// UI 즉각 반영 (Optimistic UI)
|
||||
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: nextPaused, // boolean 그대로 전송
|
||||
})
|
||||
});
|
||||
@@ -170,7 +172,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
|
||||
@@ -189,6 +190,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
|
||||
// 🌟 [수정됨] 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', {
|
||||
@@ -197,7 +200,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
seek: newPosition,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -221,17 +223,19 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
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); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,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"
|
||||
|
||||
@@ -70,9 +70,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -142,8 +142,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
|
||||
const handleDelete = async (indexToRemove: number) => {
|
||||
if (!selectedServer) return;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
|
||||
const targetEncoded = queue[indexToRemove]?.encoded;
|
||||
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
||||
setQueue(newQueue);
|
||||
|
||||
@@ -153,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();
|
||||
@@ -185,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;
|
||||
|
||||
@@ -25,35 +25,23 @@ export async function requireSession(): Promise<
|
||||
}
|
||||
|
||||
// ========== 봇 RPC 헬퍼 ==========
|
||||
// site → bot: Redis Pub/Sub 으로 명령 전송
|
||||
// bot → site: Redis SET 으로 결과 저장 (resultKey)
|
||||
// 사이트는 resultKey 를 short polling 으로 확인.
|
||||
// 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. */
|
||||
/** 응답 대기 타임아웃 (ms). 기본 3000. */
|
||||
timeoutMs?: number;
|
||||
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
|
||||
pollIntervalMs?: number;
|
||||
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
|
||||
resultTtlSec?: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export async function botRpc(
|
||||
opts: BotRpcOptions,
|
||||
): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const {
|
||||
channel,
|
||||
payload,
|
||||
timeoutMs = 3000,
|
||||
pollIntervalMs = 100,
|
||||
resultTtlSec = 5,
|
||||
} = opts;
|
||||
const { channel, payload, timeoutMs = 3000 } = opts;
|
||||
|
||||
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
|
||||
const requestId = `req:${randomUUID()}`;
|
||||
@@ -65,19 +53,24 @@ export async function botRpc(
|
||||
JSON.stringify({ ...payload, requestId }),
|
||||
);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let interval = pollIntervalMs;
|
||||
// BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록
|
||||
// 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다.
|
||||
// (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음)
|
||||
const waiter = Redis.duplicate();
|
||||
// BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달.
|
||||
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval);
|
||||
interval = Math.min(interval * 2, 400);
|
||||
|
||||
const reply = await Redis.get(resultKey);
|
||||
if (!reply) continue;
|
||||
|
||||
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
|
||||
await Redis.del(resultKey);
|
||||
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);
|
||||
@@ -92,20 +85,10 @@ export async function botRpc(
|
||||
const hasSuccessField = "success" in parsed;
|
||||
const ok = !hasSuccessField || parsed.success === true;
|
||||
return { status: ok ? 200 : 400, body: parsed };
|
||||
} finally {
|
||||
// 전용 커넥션 정리 (블로킹 중이면 즉시 끊기)
|
||||
waiter.disconnect();
|
||||
}
|
||||
|
||||
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
|
||||
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
|
||||
try {
|
||||
await Redis.expire(resultKey, resultTtlSec);
|
||||
} catch {
|
||||
// 무시: 정리 실패는 치명적 아님
|
||||
}
|
||||
|
||||
return {
|
||||
status: 504,
|
||||
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
|
||||
};
|
||||
}
|
||||
|
||||
// ========== POST 본문 파싱 ==========
|
||||
|
||||
@@ -17,12 +17,47 @@ interface BotEventStreamOptions {
|
||||
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 로 흘려보내는 공용 핸들러.
|
||||
* 봇이 publish 하는 "bot-site" 채널을 공유 구독자로 받아 SSE 로 흘려보내는 공용 핸들러.
|
||||
* - 인증 가드: 세션 없으면 401
|
||||
* - serverId 검증
|
||||
* - JSON.parse 안전 처리
|
||||
* - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리
|
||||
* - 공유 구독자 + 메모리 fan-out (연결마다 Redis 커넥션 복제 안 함)
|
||||
* - keepalive ping (30초)
|
||||
*/
|
||||
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
|
||||
@@ -38,83 +73,41 @@ export async function botEventStream(req: NextRequest, opts: BotEventStreamOptio
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const subscriber = Redis.duplicate();
|
||||
let closed = false;
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
ensureSubscriber();
|
||||
|
||||
const cleanup = async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const t of timers) clearInterval(t);
|
||||
try {
|
||||
await subscriber.unsubscribe("bot-site");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
await subscriber.quit();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* 이미 닫혔을 수 있음 */
|
||||
}
|
||||
clearInterval(ping);
|
||||
listeners.delete(listener);
|
||||
try { controller.close(); } catch { /* 이미 닫힘 */ }
|
||||
};
|
||||
|
||||
subscriber.on("error", async (err) => {
|
||||
Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`);
|
||||
await cleanup();
|
||||
});
|
||||
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);
|
||||
|
||||
try {
|
||||
await subscriber.subscribe("bot-site");
|
||||
} catch (err) {
|
||||
Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site" || closed) return;
|
||||
let data: BotEvent;
|
||||
try {
|
||||
data = JSON.parse(message) as BotEvent;
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (data.guildId !== serverId) return;
|
||||
if (data.event !== opts.botEventName) return;
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`),
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`);
|
||||
void cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지)
|
||||
timers.push(
|
||||
setInterval(() => {
|
||||
const ping = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`));
|
||||
controller.enqueue(encoder.encode(`: keep-alive\n\n`));
|
||||
} catch {
|
||||
void cleanup();
|
||||
cleanup();
|
||||
}
|
||||
}, 30000),
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
// 클라이언트가 연결을 끊으면 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
void cleanup();
|
||||
});
|
||||
req.signal.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
266
resolver/ytresolver.py
Normal file
266
resolver/ytresolver.py
Normal 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()
|
||||
14
resolver/ytresolver.service
Normal file
14
resolver/ytresolver.service
Normal 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
|
||||
91
scripts/refresh-cookies.mjs
Normal file
91
scripts/refresh-cookies.mjs
Normal 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user