Compare commits
55 Commits
4037afeb68
...
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 | ||
|
|
e476e23d37 | ||
| c86bc252f3 | |||
|
|
a4b3d40efa | ||
| 26393fec2f | |||
| b670a61192 | |||
| e5f3b87b1d | |||
| d0dcdb1563 | |||
| fefdf1fcdb | |||
|
|
04044650d0 | ||
|
|
3864b5d082 | ||
|
|
7c4c32d849 | ||
|
|
cbd56126f7 |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
venv
|
||||
**/node_modules
|
||||
dist
|
||||
64
README.md
64
README.md
@@ -1,12 +1,56 @@
|
||||
# music bot v2
|
||||
## 디스코드 음악봇 v2
|
||||
# music_bot_v2
|
||||
|
||||
### 마지막으로 만들었던 내용
|
||||
- 검색하고 재생 누르면 재생됨
|
||||
- 플레이리스트 재생 기능(주소 전달해서 재생하는 방식)
|
||||
- 아래 재생바 연동
|
||||
- 오른쪽 재생목록 연동
|
||||
디스코드 음악봇 + 웹 대시보드 모노레포. 디스코드에서 명령으로 음악을 틀 수도 있고, 웹에서 로그인해
|
||||
검색·재생·대기열을 제어할 수도 있다. 성인인증(연령제한) 영상 재생까지 지원한다.
|
||||
|
||||
### 이제 만들어야되는 내용
|
||||
- 왼쪽 플레이리스트 가져오기 제작
|
||||
- 플레이리스트 재생 추가
|
||||
## 구성
|
||||
|
||||
| 디렉터리 | 내용 | 문서 |
|
||||
|----------|------|------|
|
||||
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) |
|
||||
| [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) |
|
||||
| [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 |
|
||||
| [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
|
||||
|
||||
## 전체 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────── 디스코드 제어 평면 ───────────────┐
|
||||
[Discord 유저] ── 명령 ──▶ 봇(music1/2/3, .5 docker) ── shoukaku ──▶ Lavalink(.6:2333)
|
||||
│ ▲ └ youtube-plugin(일반)
|
||||
│ │ 재생 직전 리졸브
|
||||
▼ │
|
||||
ytresolver(.5:8779) ─┬─ ① 유튜브 내부 API(VISIONOS) ~0.3초
|
||||
│ ├─ ② yt-dlp 상주 호출 ~1.7초
|
||||
│ └─ ③ yt-dlp + 쿠키(연령제한) ~6~8초
|
||||
│ ──▶ 직접 오디오 URL
|
||||
│ (Lavalink http 소스로 재생)
|
||||
│
|
||||
┌───────── Redis(.7) Pub/Sub + Key ─────────┐ ← 제어/상태 버스
|
||||
│ │
|
||||
[브라우저] ──▶ 웹 대시보드 page/(Next.js, NextAuth) ──┘
|
||||
· site→bot: "site-bot" 명령 + 결과키 폴링(botRpc)
|
||||
· bot→site: "bot-site" 이벤트 → SSE 실시간 표시
|
||||
```
|
||||
|
||||
- **봇**과 **대시보드**는 직접 연결되지 않고 **Redis**로 느슨하게 연동된다(대시보드는 Redis만 있으면 봇과 통신).
|
||||
- 유튜브가 스트리밍을 SABR 방식으로 바꾼 뒤 Lavalink의 youtube-plugin(1.18.2)만으로는 실재생이 막힌다.
|
||||
그래서 `resolver/`가 재생 직전에 **직접 오디오 URL**을 뽑아 주고, 봇은 그 URL을 Lavalink http 소스로 재생한다.
|
||||
- 리졸버는 3단계로 폴백한다. ①이 대부분을 처리하고, 연령제한처럼 로그인이 필요한 영상만 ③으로 내려간다.
|
||||
1. **유튜브 내부 API(VISIONOS 클라이언트)** — player API 한 번으로 오디오 직링크 획득(~0.3초).
|
||||
`visitorData`는 리졸버가 스스로 발급·캐시(6시간)하고 만료 시 자동 재발급한다.
|
||||
2. **yt-dlp 상주 호출** — zipapp을 import해 `YoutubeDL` 인스턴스를 재사용(프로세스 기동 비용 제거, ~1.7초).
|
||||
3. **yt-dlp + 쿠키** — 연령제한(로그인 필요) 영상 전용 경로(~6~8초).
|
||||
- 봇 서버(.5)와 Lavalink(.6)가 같은 공인 IP라, .5에서 뽑은 스트림 URL을 .6이 그대로 재생한다.
|
||||
- 첫 곡 재생 지연은 음성채널 접속과 곡 해석을 **병렬**로 돌리고, 곡이 정해지는 즉시 리졸브를 **예열**해 줄였다
|
||||
(`bot/src/classes/LavalinkManager.ts`의 `resolveQuery`/`prewarmTrack`).
|
||||
|
||||
## 빠르게 보기
|
||||
|
||||
- 봇 배포/쿠키/검색·재생 동작·성능·트러블슈팅 → [bot/README.md](bot/README.md)
|
||||
- 대시보드 개발/환경변수/Redis 통신 구조/API·UI → [page/README.md](page/README.md)
|
||||
|
||||
## 리포 규칙
|
||||
|
||||
- `bot/.gitignore`가 `*.env`, `*.db`를 제외 → 실제 토큰/DB는 커밋되지 않는다. 예시는 [`bot/.env.example`](bot/.env.example).
|
||||
- 원격: Gitea `git.tkrmagid.kr/tkrmagid/music_bot_v2` (`master`).
|
||||
|
||||
@@ -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).
|
||||
6
bot/db/db.d.ts
vendored
6
bot/db/db.d.ts
vendored
@@ -8,9 +8,3 @@ export interface GuildType {
|
||||
};
|
||||
}
|
||||
export type GuildRow = Omit<GuildType, "options"> & { options: string };
|
||||
|
||||
// export interface UserType {
|
||||
// guild_id: string;
|
||||
// id: string;
|
||||
// name: string;
|
||||
// }
|
||||
580
bot/package-lock.json
generated
580
bot/package-lock.json
generated
@@ -9,14 +9,16 @@
|
||||
"version": "0.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"bgutils-js": "3.2.0",
|
||||
"colors": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsdom": "25.0.1",
|
||||
"shoukaku": "^4.3.0",
|
||||
"undici": "^7.24.7"
|
||||
"undici": "^7.24.7",
|
||||
"youtubei.js": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -26,6 +28,23 @@
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.0.tgz",
|
||||
"integrity": "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -39,10 +58,115 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.0.tgz",
|
||||
"integrity": "sha512-7pVKxVWkeLUtrTo9nTYkjRcJk0Hlms6lYervXAD7E7+K5lil9ms2JrEB1TalMiHvQMh7h1HJZ4fCJa0/vHpl4w==",
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
@@ -354,6 +478,14 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
@@ -364,19 +496,7 @@
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
@@ -412,6 +532,14 @@
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bgutils-js": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-3.2.0.tgz",
|
||||
"integrity": "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
]
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -460,7 +588,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -497,7 +624,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -512,6 +638,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -529,6 +684,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -557,7 +717,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -600,24 +759,24 @@
|
||||
]
|
||||
},
|
||||
"node_modules/discord.js": {
|
||||
"version": "14.25.1",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
|
||||
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
|
||||
"version": "14.26.3",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz",
|
||||
"integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "^1.13.0",
|
||||
"@discordjs/builders": "^1.14.1",
|
||||
"@discordjs/collection": "1.5.3",
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/rest": "^2.6.0",
|
||||
"@discordjs/rest": "^2.6.1",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@discordjs/ws": "^1.2.3",
|
||||
"@sapphire/snowflake": "3.5.3",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"discord-api-types": "^0.38.40",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"magic-bytes.js": "^1.10.0",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.21.3"
|
||||
"undici": "6.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -627,9 +786,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/discord.js/node_modules/undici": {
|
||||
"version": "6.21.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
@@ -651,7 +810,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -670,11 +828,21 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -683,16 +851,14 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
@@ -704,7 +870,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
@@ -730,43 +895,27 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -782,7 +931,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -791,7 +939,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -815,7 +962,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
@@ -834,7 +980,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -846,7 +991,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -858,7 +1002,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
@@ -870,10 +1013,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -881,6 +1023,52 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -937,10 +1125,54 @@
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "25.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"form-data": "^4.0.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.12",
|
||||
"parse5": "^7.1.2",
|
||||
"rrweb-cssom": "^0.7.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.0.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^2.11.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
@@ -961,6 +1193,11 @@
|
||||
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||
},
|
||||
"node_modules/magic-bytes.js": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
|
||||
@@ -978,16 +1215,22 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/meriyah": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-7.3.2.tgz",
|
||||
"integrity": "sha512-uFPXbsoxaEd7PafMz3K/n/Z3kXz7iZBJtYU0VQB/kcV7i33+JghLTKweabLir480FUsyLz6gUIj4LW7kCQ35TA==",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -996,7 +1239,6 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -1055,6 +1297,11 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.25",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.25.tgz",
|
||||
"integrity": "sha512-kktSgNDIbyEIs/LrE6dtEMnfzxSDpXViFl6c4gFjz/CgtnL4GDCR3wyZXzjvAXsNB8dXU4dAfvIOgLMH2/vwLg=="
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -1064,6 +1311,17 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@@ -1091,15 +1349,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
@@ -1110,6 +1359,14 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@@ -1160,6 +1417,11 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1180,6 +1442,22 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -1274,6 +1552,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
@@ -1302,6 +1585,44 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-mixer": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||
@@ -1412,6 +1733,57 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -1439,6 +1811,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
@@ -1448,6 +1833,19 @@
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/youtubei.js": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-18.0.0.tgz",
|
||||
"integrity": "sha512-7Tztl5QzNL4nIhiAxa4P/0C8cDDsUe0j4iaUeNiaA+ZfSe/l+HkHxXyoWlQXNW7IPi2n6AW23ysVMJLDh+AwWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.0.0",
|
||||
"fflate": "^0.8.2",
|
||||
"meriyah": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"bgutils-js": "3.2.0",
|
||||
"colors": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsdom": "25.0.1",
|
||||
"shoukaku": "^4.3.0",
|
||||
"undici": "^7.24.7"
|
||||
"undici": "^7.24.7",
|
||||
"youtubei.js": "^18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export class BotClient extends Client {
|
||||
public prefix = Config.prefix;
|
||||
@@ -69,8 +70,10 @@ export class BotClient extends Client {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const msg = await message.fetch(true).catch(() => undefined);
|
||||
if (msg?.deletable) msg.delete().catch(() => {});
|
||||
} catch {};
|
||||
if (msg?.deletable) msg.delete().catch((err) => {
|
||||
Logger.warn(`[BotClient] 메세지 삭제 실패: ${String(err)}`);
|
||||
});
|
||||
} catch {}
|
||||
}, Math.max(100, time * (customTime ? 1 : 6000)));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Guild, Message, TextChannel } from "discord.js";
|
||||
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent } from "shoukaku";
|
||||
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent, type WebSocketClosedEvent } from "shoukaku";
|
||||
import { client, lavalinkManager, Redis } from "../index";
|
||||
import { timeFormat } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
@@ -7,11 +7,20 @@ import { GuildType } from "../../db/db";
|
||||
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;
|
||||
@@ -21,6 +30,11 @@ export class GuildPlayer {
|
||||
public queue: QueueTrack[] = [];
|
||||
private errorTimer: NodeJS.Timeout | undefined;
|
||||
private endTimer: NodeJS.Timeout | undefined;
|
||||
private closedTimer: NodeJS.Timeout | undefined;
|
||||
private stallWatchdog: NodeJS.Timeout | undefined;
|
||||
private lastPosition = 0;
|
||||
private lastProgressAt = 0;
|
||||
private loadFailCount = 0;
|
||||
|
||||
constructor(
|
||||
public guild: Guild,
|
||||
@@ -30,14 +44,48 @@ export class GuildPlayer {
|
||||
public msg: Message,
|
||||
) {
|
||||
this.player.setGlobalVolume(50);
|
||||
this.attachPlayerListeners();
|
||||
}
|
||||
|
||||
private attachPlayerListeners() {
|
||||
this.player.on("start", (_data: TrackStartEvent) => {
|
||||
Redis.publishState("player_update", {
|
||||
// endTimer가 남아있으면 제거 (새 곡 재생 시작)
|
||||
if (this.endTimer !== undefined) {
|
||||
clearTimeout(this.endTimer);
|
||||
this.endTimer = undefined;
|
||||
}
|
||||
// 새 곡이 시작되면 재생 위치 멈춤 감시 시작
|
||||
this.startStallWatchdog();
|
||||
// 현재 곡이 재생되는 동안 다음 곡을 미리 리졸브(곡 전환 지연 제거)
|
||||
this.prefetch(this.queue[1]);
|
||||
Redis?.publishState("player_update", {
|
||||
guildId: this.guild.id,
|
||||
});
|
||||
});
|
||||
this.player.on("end", async (data: TrackEndEvent) => {
|
||||
try {
|
||||
if (this.isDead) return;
|
||||
if (data.reason === "replaced") return;
|
||||
// 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
|
||||
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
|
||||
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
|
||||
if (data.reason === "loadFailed" && this.queue[0]) {
|
||||
this.loadFailCount += 1;
|
||||
if (this.loadFailCount <= MaxLoadFailRetry) {
|
||||
const cur = this.queue[0];
|
||||
Logger.warn(`[GuildPlayer] 재생 실패, 같은 곡 재시도 ${this.loadFailCount}/${MaxLoadFailRetry}: "${cur.info.title}"`);
|
||||
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
|
||||
if (this.isDead) return;
|
||||
if (this.queue[0]?.encoded === cur.encoded) {
|
||||
const reEncoded = await this.resolvePlayable(cur);
|
||||
await this.player.playTrack({ track: { encoded: reEncoded } });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
|
||||
}
|
||||
}
|
||||
this.loadFailCount = 0;
|
||||
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
|
||||
this.lastPlayedTrack = this.queue.shift();
|
||||
if (this.queue.length > 0) {
|
||||
@@ -47,11 +95,116 @@ export class GuildPlayer {
|
||||
} else {
|
||||
this.end();
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] end 이벤트 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
this.player.on("closed", () => {
|
||||
this.player.on("closed", (data: WebSocketClosedEvent) => {
|
||||
if (this.isDead) return;
|
||||
Logger.warn(
|
||||
`[GuildPlayer] 음성 WS 끊김 (code=${data.code}, reason="${data.reason || "(none)"}", byRemote=${data.byRemote}). 5초 후 복구 시도...`,
|
||||
);
|
||||
// 이전 closed 타이머가 있으면 제거
|
||||
if (this.closedTimer !== undefined) {
|
||||
clearTimeout(this.closedTimer);
|
||||
}
|
||||
this.closedTimer = setTimeout(async () => {
|
||||
this.closedTimer = undefined;
|
||||
if (this.isDead) return;
|
||||
|
||||
// 디스코드 방에 내 봇(me)이 없으면 봇을 삭제(delete)한다!
|
||||
const meChannelId = this.guild.members.me?.voice?.channelId;
|
||||
if (!meChannelId) {
|
||||
Logger.warn(`[GuildPlayer] 음성채널에 봇이 없습니다. player를 초기화합니다.`);
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
// 봇이 아직 보이스 채널에 남아있으면 자가 회복 시도
|
||||
try {
|
||||
await this.reconnect(meChannelId);
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] 음성 재접속 실패: ${String(err)}. player를 초기화합니다.`);
|
||||
this.delete();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
this.player.on("exception", async (data) => {
|
||||
try {
|
||||
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
|
||||
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
|
||||
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
this.player.on("stuck", async (data) => {
|
||||
try {
|
||||
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과. 다음 곡으로 넘어갑니다.`);
|
||||
// errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수 있으므로,
|
||||
// stopTrack으로 end 이벤트를 유도해 실제로 다음 곡으로 넘긴다.
|
||||
await this.player.stopTrack();
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 음성 WS가 끊겼지만 봇이 아직 보이스 채널에 남아있을 때, player를 새로 만들어 현재 곡을 이어서 재생. */
|
||||
private async reconnect(targetChannelId: string) {
|
||||
const currentTrack = this.nowTrack;
|
||||
const currentPosition = this.position;
|
||||
const wasPaused = this.isPaused;
|
||||
const savedVolume = this.player.volume;
|
||||
|
||||
Logger.info(
|
||||
`[GuildPlayer] 음성 재접속 시도 (channel=${targetChannelId}, track="${currentTrack?.info.title ?? "(none)"}", position=${currentPosition}ms, paused=${wasPaused})`,
|
||||
);
|
||||
|
||||
// 기존 player listener 해제 + 정리
|
||||
this.player.removeAllListeners();
|
||||
try {
|
||||
await this.player.destroy();
|
||||
} catch (err) {
|
||||
Logger.warn(`[GuildPlayer] 기존 player.destroy 중 에러(무시): ${String(err)}`);
|
||||
}
|
||||
try {
|
||||
await lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
||||
} catch (err) {
|
||||
Logger.warn(`[GuildPlayer] 기존 leaveVoiceChannel 중 에러(무시): ${String(err)}`);
|
||||
}
|
||||
|
||||
if (this.isDead) return;
|
||||
|
||||
// 게이트웨이 측 정리 대기
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (this.isDead) return;
|
||||
|
||||
// 재접속 (봇이 옮겨졌을 수도 있으니 me의 현재 채널로)
|
||||
const newPlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
|
||||
guildId: this.guild.id,
|
||||
channelId: targetChannelId,
|
||||
shardId: this.guild.shardId,
|
||||
deaf: true,
|
||||
mute: false,
|
||||
});
|
||||
this.player = newPlayer;
|
||||
this.voiceChannelId = targetChannelId;
|
||||
this.attachPlayerListeners();
|
||||
await this.player.setGlobalVolume(savedVolume || 50);
|
||||
|
||||
// 곡이 있었으면 이어서 재생
|
||||
if (currentTrack) {
|
||||
const reEncoded = await this.resolvePlayable(currentTrack);
|
||||
await this.player.playTrack({
|
||||
track: { encoded: reEncoded },
|
||||
position: currentPosition,
|
||||
paused: wasPaused,
|
||||
});
|
||||
Logger.info(`[GuildPlayer] 음성 재접속 성공. 곡을 이어서 재생합니다.`);
|
||||
} else {
|
||||
Logger.info(`[GuildPlayer] 음성 재접속 성공 (재생 중인 곡 없음).`);
|
||||
}
|
||||
}
|
||||
|
||||
private get GDB() {
|
||||
@@ -84,7 +237,8 @@ export class GuildPlayer {
|
||||
// 큐가 비어있으면 바로 재생
|
||||
await this.playNext();
|
||||
} else {
|
||||
// 재생목록 추가되었을때
|
||||
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
|
||||
this.prefetch(this.queue[1]);
|
||||
this.setMsg({ player: false, queue: true });
|
||||
}
|
||||
}
|
||||
@@ -93,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();
|
||||
}
|
||||
@@ -155,19 +358,57 @@ export class GuildPlayer {
|
||||
if (!this.isPlaying) return;
|
||||
if (!this.nowTrack) return;
|
||||
this.player.seekTo(num);
|
||||
Redis.publishState("player_update", { guildId: this.guild.id });
|
||||
Redis?.publishState("player_update", { guildId: this.guild.id });
|
||||
}
|
||||
|
||||
private async autoPlay() {
|
||||
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;
|
||||
@@ -176,20 +417,118 @@ export class GuildPlayer {
|
||||
} else if (result?.loadType === LoadType.TRACK) {
|
||||
tracks = [ result.data ];
|
||||
}
|
||||
Logger.info(`[autoPlay] 자동재생 후보 ${tracks.length}곡${usedYtFallback ? " (YouTube 폴백)" : ""}`);
|
||||
if (tracks.length === 0) {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
if (tracks.length > 0 && tracks[0].info.identifier === dedupId) tracks = tracks.slice(1);
|
||||
if (tracks.length === 0) {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
|
||||
this.addTracks(tracks, "자동재생");
|
||||
}
|
||||
|
||||
public end() {
|
||||
/**
|
||||
* Spotify 트랙 메타데이터로 YouTube 검색 후 매칭된 곡의 RD 믹스를 가져옴.
|
||||
* (현재 Lavalink에서 `ytmsearch:`는 EMPTY를 자주 반환하므로 `ytsearch:` 사용)
|
||||
* 각 단계가 실패하면 다음 단계로 진행하지 않고 undefined 반환.
|
||||
* 호출 측에서 RD 믹스 첫 곡(=시드 자체)을 중복 제거할 수 있도록 seed videoId도 같이 반환.
|
||||
*/
|
||||
private async youtubeMixFromSpotifyTrack(author: string, title: string) {
|
||||
const cleanAuthor = author.replace(" - Topic", "");
|
||||
const query = `ytsearch:${cleanAuthor} ${title}`;
|
||||
let searchResult;
|
||||
try {
|
||||
searchResult = await this.player.node.rest.resolve(query);
|
||||
} catch (err) {
|
||||
Logger.warn(`[autoPlay] YouTube 폴백 검색 throw: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
searchResult?.loadType !== LoadType.SEARCH
|
||||
|| !Array.isArray(searchResult.data)
|
||||
|| searchResult.data.length === 0
|
||||
) {
|
||||
Logger.warn(`[autoPlay] YouTube 폴백 검색 결과 없음 (query="${cleanAuthor} ${title}", loadType=${searchResult?.loadType ?? "(null)"})`);
|
||||
return undefined;
|
||||
}
|
||||
const seedYtId = searchResult.data[0].info.identifier;
|
||||
Logger.info(`[autoPlay] YouTube 폴백 매칭: videoId=${seedYtId}`);
|
||||
try {
|
||||
const result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${seedYtId}&list=RD${seedYtId}`);
|
||||
return { result, seedYtId };
|
||||
} catch (err) {
|
||||
Logger.warn(`[autoPlay] YouTube 폴백 RD 믹스 resolve throw: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재생 위치가 일정 시간 동안 전혀 진행되지 않으면(=버퍼링/멈춤) 다음 곡으로 넘긴다.
|
||||
* Lavalink가 TrackStuckEvent를 못 던지는 트리클 스트리밍(느린 버퍼링) 상황까지 커버하는 백스탑.
|
||||
*/
|
||||
private startStallWatchdog() {
|
||||
this.stopStallWatchdog();
|
||||
this.lastPosition = this.position;
|
||||
this.lastProgressAt = Date.now();
|
||||
this.stallWatchdog = setInterval(() => {
|
||||
if (this.isDead) {
|
||||
this.stopStallWatchdog();
|
||||
return;
|
||||
}
|
||||
// 재생 중이 아니거나 일시정지면 정상 상태 → 진행시각 리셋
|
||||
if (!this.isPlaying || this.isPaused) {
|
||||
this.lastPosition = this.player.position;
|
||||
this.lastProgressAt = Date.now();
|
||||
return;
|
||||
}
|
||||
const pos = this.player.position;
|
||||
if (pos > this.lastPosition + 500) {
|
||||
// 정상적으로 재생 위치가 진행됨
|
||||
this.lastPosition = pos;
|
||||
this.lastProgressAt = Date.now();
|
||||
return;
|
||||
}
|
||||
// 재생 위치가 StallThresholdMs 동안 전혀 진행되지 않음 → 멈춤으로 판단하고 스킵
|
||||
if (Date.now() - this.lastProgressAt >= StallThresholdMs) {
|
||||
Logger.error(`[GuildPlayer] 재생 위치가 ${StallThresholdMs}ms 동안 진행되지 않음(멈춤 감지). 다음 곡으로 넘어갑니다. (position=${pos}ms)`);
|
||||
this.lastPosition = pos;
|
||||
this.lastProgressAt = Date.now();
|
||||
// stopTrack → end 이벤트 → 다음 곡 진행
|
||||
this.player.stopTrack().catch((err) => {
|
||||
Logger.error(`[GuildPlayer] 멈춤 감지 후 stopTrack 에러: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
}, StallCheckIntervalMs);
|
||||
}
|
||||
|
||||
private stopStallWatchdog() {
|
||||
if (this.stallWatchdog !== undefined) {
|
||||
clearInterval(this.stallWatchdog);
|
||||
this.stallWatchdog = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private clearAllTimers() {
|
||||
if (this.errorTimer !== undefined) {
|
||||
clearTimeout(this.errorTimer);
|
||||
this.errorTimer = undefined;
|
||||
}
|
||||
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
|
||||
if (this.endTimer !== undefined) {
|
||||
clearTimeout(this.endTimer);
|
||||
this.endTimer = undefined;
|
||||
}
|
||||
if (this.closedTimer !== undefined) {
|
||||
clearTimeout(this.closedTimer);
|
||||
this.closedTimer = undefined;
|
||||
}
|
||||
this.stopStallWatchdog();
|
||||
}
|
||||
|
||||
public end() {
|
||||
this.clearAllTimers();
|
||||
this.endTimer = setTimeout(() => {
|
||||
this.endTimer = undefined;
|
||||
this.delete(true);
|
||||
@@ -204,14 +543,17 @@ export class GuildPlayer {
|
||||
if (this.isDead) return;
|
||||
if (!afterEnd) this.end();
|
||||
this.isDead = true;
|
||||
this.player.destroy().catch(() => {});
|
||||
this.clearAllTimers();
|
||||
this.player.destroy().catch((err) => {
|
||||
Logger.error(`[GuildPlayer] player.destroy 에러: ${String(err)}`);
|
||||
});
|
||||
lavalinkManager.delPlayer(this.guild.id);
|
||||
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
||||
}
|
||||
|
||||
public async setMsg(update: { player: boolean; queue: boolean; } = { player: true, queue: true }) {
|
||||
if (update.player) Redis.publishState("player_update", { guildId: this.guild.id });
|
||||
if (update.queue) Redis.publishState("queue_update", { guildId: this.guild.id });
|
||||
if (update.player) Redis?.publishState("player_update", { guildId: this.guild.id });
|
||||
if (update.queue) Redis?.publishState("queue_update", { guildId: this.guild.id });
|
||||
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
|
||||
if (!check) return;
|
||||
this.textChannel = channel;
|
||||
@@ -275,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,
|
||||
@@ -291,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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, Collection } from "discord.js";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { Command } from "../types/Command";
|
||||
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export class Handler {
|
||||
public commands: Collection<string, Command> = new Collection();
|
||||
@@ -15,11 +16,16 @@ export class Handler {
|
||||
}
|
||||
}
|
||||
|
||||
public runCommand(interaction: ChatInputCommandInteraction) {
|
||||
public async runCommand(interaction: ChatInputCommandInteraction) {
|
||||
const commandName = interaction.commandName;
|
||||
const command = this.commands.get(commandName);
|
||||
|
||||
if (!command) return;
|
||||
if (command.slashRun) command.slashRun(interaction);
|
||||
try {
|
||||
if (command.slashRun) await command.slashRun(interaction);
|
||||
} catch (err) {
|
||||
Logger.error(`[Handler] 명령어 '${commandName}' 실행 중 에러: ${String(err)}`);
|
||||
await interaction.editReply({ content: "명령어 실행 중 오류가 발생했습니다." }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -23,7 +23,12 @@ type SubAction =
|
||||
"queue_set" |
|
||||
"queue_remove";
|
||||
|
||||
export class RedisClient {
|
||||
export const RedisClient = () => {
|
||||
if (Config.redis.state) return new RedisClientClass();
|
||||
return null;
|
||||
}
|
||||
|
||||
class RedisClientClass {
|
||||
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||||
public sub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||||
|
||||
@@ -58,48 +63,78 @@ export class RedisClient {
|
||||
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,
|
||||
@@ -111,17 +146,19 @@ export class RedisClient {
|
||||
}
|
||||
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];
|
||||
@@ -130,73 +167,85 @@ export class RedisClient {
|
||||
for (const rawTrack of data.newQueue) {
|
||||
if (rawTrack.encoded) context.player.queue.push(rawTrack);
|
||||
}
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
context.player.setMsg();
|
||||
}
|
||||
if (data.action === "queue_remove") {
|
||||
const resultKey = `queue:remove:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// index 는 number(0 도 유효) — typeof 검증으로 변경.
|
||||
if (typeof data.index !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
const numIndex = Number(data.index);
|
||||
if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
|
||||
if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
|
||||
if (numIndex >= context.player.queue.length-1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 queue.length보다 클수 없습니다." }));
|
||||
const [removedTrack] = context.player.queue.splice(numIndex+1, 1);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
if (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
|
||||
if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
|
||||
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
|
||||
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
|
||||
if (numIndex >= context.player.queue.length - 1) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
|
||||
// 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인.
|
||||
// SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지.
|
||||
const removeTarget = context.player.queue[numIndex + 1];
|
||||
if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) {
|
||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." }));
|
||||
}
|
||||
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
context.player.setMsg();
|
||||
}
|
||||
if (data.action === "player_paused") {
|
||||
const resultKey = `player:paused:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
|
||||
if (typeof data.isPaused !== "boolean") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
await context.player.setPause();
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
|
||||
}
|
||||
if (data.action === "player_skip") {
|
||||
const resultKey = `player:skip:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (context.player.isPlaying) context.player.skip();
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
}
|
||||
if (data.action === "player_seek") {
|
||||
const resultKey = `player:seek:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
|
||||
if (typeof data.seek !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const duration = context.player.nowTrack?.info.length || 0;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const duration = context.player.nowTrack.info.length || 0;
|
||||
const numSeek = Number(data.seek);
|
||||
if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
|
||||
if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
|
||||
if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
|
||||
if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
|
||||
if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
|
||||
if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
|
||||
context.player.seek(numSeek);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
}
|
||||
if (data.action === "player_volume") {
|
||||
const resultKey = `player:volume:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
|
||||
if (typeof data.volume !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const numVolume = Number(data.volume);
|
||||
if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
|
||||
if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
|
||||
if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
|
||||
if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
|
||||
if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
|
||||
if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
|
||||
context.player.setVolume(numVolume);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
||||
@@ -211,6 +260,17 @@ export class RedisClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트로 보내는 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,
|
||||
@@ -221,6 +281,16 @@ export class RedisClient {
|
||||
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;
|
||||
@@ -229,20 +299,29 @@ export class RedisClient {
|
||||
} | { 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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Command } from "../types/Command";
|
||||
import { clearAllMsg } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
import { DB } from "../utils/Database";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
/** channel 명령어 */
|
||||
export default class implements Command {
|
||||
@@ -100,7 +101,7 @@ export async function channelRegister(guild: Guild | null, channelId: string | n
|
||||
components: [ getButtons() ],
|
||||
files: [ default_image ],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
Logger.error(`[Channel] 메세지 생성 실패: ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
if (!msg) return client.mkembed({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { client, lavalinkManager } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { GuildPlayer } from "../classes/GuildPlayer";
|
||||
import { getTextChannelAndMsg } from "../utils/music/Channel";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
/** join 명령어 */
|
||||
export default class implements Command {
|
||||
@@ -72,16 +73,39 @@ export async function channelJoin(guild: Guild | null, voiceChannelId: string |
|
||||
}) };
|
||||
|
||||
let player = lavalinkManager.getPlayer(guild.id);
|
||||
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player };
|
||||
player = new GuildPlayer(
|
||||
guild,
|
||||
await lavalinkManager.shoukaku.joinVoiceChannel({
|
||||
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player };
|
||||
|
||||
// 연결 가능한 Lavalink 노드가 없으면 joinVoiceChannel이 throw 되어 unhandledRejection으로 번짐.
|
||||
// 노드 미연결 시 사용자에게 안내만 하고 종료.
|
||||
if (!lavalinkManager.hasReadyNode()) {
|
||||
return { embed: client.mkembed({
|
||||
title: "음악 서버에 연결할 수 없습니다.",
|
||||
description: "잠시 후 다시 시도해주세요.",
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
}
|
||||
|
||||
let voicePlayer;
|
||||
try {
|
||||
voicePlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
|
||||
guildId: guild.id,
|
||||
channelId: voiceChannel.id,
|
||||
shardId: guild.shardId,
|
||||
deaf: true,
|
||||
mute: false,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error(`[channelJoin] 음성채널 참가 실패: ${String(err)}`);
|
||||
return { embed: client.mkembed({
|
||||
title: "음성채널 참가에 실패했습니다.",
|
||||
description: "잠시 후 다시 시도해주세요.",
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
}
|
||||
|
||||
player = new GuildPlayer(
|
||||
guild,
|
||||
voicePlayer,
|
||||
voiceChannel.id,
|
||||
channel,
|
||||
msg,
|
||||
|
||||
@@ -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;
|
||||
@@ -14,9 +15,14 @@ export const clientReady = async () => {
|
||||
|
||||
reloadMsg();
|
||||
|
||||
if (Redis) {
|
||||
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
|
||||
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
|
||||
// [실험 전용] 환경변수가 있을 때만 동작. 프로덕션에는 변수 미설정이라 아무 일도 안 한다.
|
||||
runSelfTest();
|
||||
|
||||
if (!Config.dev) return;
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { client, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export const guildCreate = async () => {
|
||||
if (Redis) {
|
||||
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
|
||||
Logger.info(`[Redis Pub] bot-guilds 수정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { client, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export const guildDelete = async () => {
|
||||
if (Redis) {
|
||||
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
|
||||
Logger.info(`[Redis Pub] bot-guilds 수정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Interaction, MessageFlags } from "discord.js";
|
||||
import { handler } from "../index";
|
||||
import { buttonInteraction } from "../utils/music/Button";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export const interactionCreate = async (interaction: Interaction) => {
|
||||
try {
|
||||
if (interaction.isStringSelectMenu()) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
|
||||
Logger.warn(`[Interaction] SelectMenu deferReply 실패: ${String(err)}`);
|
||||
});
|
||||
const commandName = interaction.customId;
|
||||
const args = interaction.values;
|
||||
const command = handler.commands.get(commandName);
|
||||
@@ -17,7 +21,9 @@ export const interactionCreate = async (interaction: Interaction) => {
|
||||
|
||||
if (args[0] === "music") return buttonInteraction(interaction, args.slice(1));
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
|
||||
Logger.warn(`[Interaction] Button deferReply 실패: ${String(err)}`);
|
||||
});
|
||||
|
||||
const key = args.shift();
|
||||
if (!key) return;
|
||||
@@ -31,6 +37,11 @@ export const interactionCreate = async (interaction: Interaction) => {
|
||||
* 명령어 친사람만 보이게 설정
|
||||
* flags: MessageFlags.Ephemeral
|
||||
*/
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
handler.runCommand(interaction);
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
|
||||
Logger.warn(`[Interaction] Command deferReply 실패: ${String(err)}`);
|
||||
});
|
||||
await handler.runCommand(interaction);
|
||||
} catch (err) {
|
||||
Logger.error(`[Interaction] 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,3 @@
|
||||
import { VoiceState } from "discord.js";
|
||||
import { client } from "../index";
|
||||
|
||||
export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
|
||||
}
|
||||
// TODO: 음성 상태 변경 이벤트 핸들러 (추후 구현)
|
||||
// import { VoiceState } from "discord.js";
|
||||
// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {}
|
||||
|
||||
@@ -3,11 +3,25 @@ import { LavalinkManager } from "./classes/LavalinkManager";
|
||||
import { Handler } from "./classes/Handler";
|
||||
import { onEvents } from "./events";
|
||||
import { RedisClient } from "./classes/RedisClient";
|
||||
import { Logger } from "./utils/Logger";
|
||||
|
||||
// 봇이 예기치 않게 죽기 직전에 에러를 잡아내서 로그로 남기는 코드
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
Logger.error('🚨 [치명적 오류] 처리되지 않은 Promise 거부 발생:'+String(reason));
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
Logger.error('🚨 [치명적 오류] 잡지 못한 예외 발생:'+String(err));
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
Logger.log('💀 시스템에 의해 강제 종료(SIGTERM) 신호를 받았습니다!');
|
||||
});
|
||||
|
||||
export const client = new BotClient();
|
||||
export const lavalinkManager = new LavalinkManager(client);
|
||||
export const handler = new Handler();
|
||||
export const Redis = new RedisClient();
|
||||
export const Redis = RedisClient();
|
||||
|
||||
for (const eventName of Object.keys(onEvents) as (keyof typeof onEvents)[]) {
|
||||
client.onEvent(eventName, onEvents[eventName]);
|
||||
|
||||
6
bot/src/types/Command.d.ts
vendored
6
bot/src/types/Command.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
|
||||
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
export interface Command {
|
||||
/** 메세지 이름 */
|
||||
@@ -13,9 +13,9 @@ export interface Command {
|
||||
* 등록 메타: JSON 변환된 바디
|
||||
* (빌드 시 toJSON()해서 REST 등록에 사용)
|
||||
*/
|
||||
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody;
|
||||
metaData: ChatInputApplicationCommandData;
|
||||
|
||||
slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise<void>;
|
||||
slashRun?: (args: ChatInputCommandInteraction) => Promise<void>;
|
||||
messageRun?: (message: Message, args: string[]) => Promise<void>;
|
||||
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
|
||||
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;
|
||||
|
||||
@@ -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,14 +78,43 @@ export const Config = {
|
||||
}
|
||||
},
|
||||
|
||||
// Netscape 쿠키 파일 경로. 리졸버(yt-dlp)와 봇(YoutubeMusic 검색)이 공유하는 단일 쿠키 소스.
|
||||
// 기본값은 컨테이너 내 /db/ytcookie.txt (= 호스트 /root/bot/db/ytcookie.txt, db 볼륨 마운트).
|
||||
youtubeCookiePath: process.env.YOUTUBE_COOKIE_PATH?.trim() || "/db/ytcookie.txt",
|
||||
|
||||
_youtube_cookie: process.env.YOUTUBE_COOKIE?.trim(),
|
||||
_youtube_cookie_cache: undefined as string | undefined,
|
||||
/**
|
||||
* 쿠키 헤더 문자열. 우선순위:
|
||||
* 1) YOUTUBE_COOKIE (인라인 문자열, 하위호환)
|
||||
* 2) YOUTUBE_COOKIE_PATH 의 Netscape 파일을 헤더 문자열로 변환
|
||||
* 둘 다 없으면 예외.
|
||||
*/
|
||||
get youtube_cookie() {
|
||||
if (!this._youtube_cookie) throw new ReferenceError("YOUTUBE_COOKIE is missing");
|
||||
if (this._youtube_cookie) {
|
||||
if (this._youtube_cookie.startsWith('"')) this._youtube_cookie = this._youtube_cookie.slice(1,-1);
|
||||
return this._youtube_cookie;
|
||||
}
|
||||
// 파일에서 매번 새로 읽되(쿠키 갱신 반영), 파싱 결과는 캐시
|
||||
if (existsSync(this.youtubeCookiePath)) {
|
||||
this._youtube_cookie_cache = cookieFileToHeader(this.youtubeCookiePath);
|
||||
return this._youtube_cookie_cache;
|
||||
}
|
||||
throw new ReferenceError("YOUTUBE_COOKIE / YOUTUBE_COOKIE_PATH 둘 다 없습니다");
|
||||
},
|
||||
|
||||
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
|
||||
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
|
||||
|
||||
// ytresolver(yt-dlp) 사용 여부. false 면 Lavalink youtube-source(poToken 주입)로 직접 재생한다.
|
||||
// 첫 재생에서 yt-dlp 단계(콜드 3.6~4.2s)를 통째로 없애지만, 집 IP의 SABR/403 차단에
|
||||
// 다시 걸릴 수 있어 실험용 플래그로 둔다. 미설정 시 기존 동작(true) 유지.
|
||||
ytresolverEnabled: (process.env.YTRESOLVER_ENABLED?.trim()?.toLocaleLowerCase() ?? "true") !== "false",
|
||||
|
||||
proxyUrl: process.env.PROXY_URL?.trim() || "",
|
||||
|
||||
_redis: {
|
||||
state: process.env.REDIS?.trim()?.toLocaleLowerCase() === "true",
|
||||
host: process.env.REDIS_HOST?.trim(),
|
||||
port: process.env.REDIS_PORT?.trim(),
|
||||
},
|
||||
@@ -79,6 +124,7 @@ export const Config = {
|
||||
const port = Number(this._redis.port!);
|
||||
if (isNaN(port)) throw new TypeError("REDIS_PORT must be a number");
|
||||
return {
|
||||
state: this._redis.state,
|
||||
host: this._redis.host,
|
||||
port: port,
|
||||
};
|
||||
|
||||
@@ -7,12 +7,18 @@ import { Logger } from "./Logger";
|
||||
|
||||
const database = new Database(Config.dbPath);
|
||||
|
||||
const schemaPath = join(process.cwd(), "db/schema.sql");
|
||||
const schemaPath = join(__dirname, "../../db/schema.sql");
|
||||
const schema = readFileSync(schemaPath, "utf-8");
|
||||
database.exec(schema);
|
||||
|
||||
Logger.ready("DB 활성화!");
|
||||
|
||||
// 허용되는 guild 테이블 컬럼 화이트리스<EBA6AC><EC8AA4><EFBFBD>
|
||||
const GUILD_COLUMNS = new Set(["id", "name", "channel_id", "msg_id", "options"]);
|
||||
|
||||
const filterKeys = (keys: string[], whitelist: Set<string>) =>
|
||||
keys.filter(k => whitelist.has(k));
|
||||
|
||||
const stmt = {
|
||||
guild: {
|
||||
// 전체
|
||||
@@ -21,7 +27,7 @@ const stmt = {
|
||||
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
|
||||
// 추가
|
||||
insert: (data: GuildRow) => {
|
||||
const keys = Object.keys(data);
|
||||
const keys = filterKeys(Object.keys(data), GUILD_COLUMNS);
|
||||
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
|
||||
return database.prepare(`INSERT INTO guilds (${
|
||||
keys.map(k => `"${k}"`).join(", ")
|
||||
@@ -31,35 +37,13 @@ const stmt = {
|
||||
},
|
||||
// 수정
|
||||
update: (data: GuildRow) => {
|
||||
const keys = Object.keys(data).filter(k => k !== "id");
|
||||
const keys = filterKeys(Object.keys(data), GUILD_COLUMNS).filter(k => k !== "id");
|
||||
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||
return database.prepare(`UPDATE guilds SET ${
|
||||
keys.map(k => `${k} = @${k}`).join(", ")
|
||||
} WHERE id = @id`).run(data);
|
||||
},
|
||||
},
|
||||
// user: {
|
||||
// // 가져오기
|
||||
// get: database.prepare("SELECT * FROM users WHERE guild_id = ? AND id = ?"),
|
||||
// // 추가
|
||||
// insert: (data: UserType) => {
|
||||
// const keys = Object.keys(data);
|
||||
// if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
|
||||
// return database.prepare(`INSERT INTO users (${
|
||||
// keys.map(k => `"${k}"`).join(", ")
|
||||
// }) VALUES (${
|
||||
// keys.map(k => `@${k}`).join(", ")
|
||||
// })`).run(data);
|
||||
// },
|
||||
// // 수정
|
||||
// update: (data: UserType) => {
|
||||
// const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
||||
// if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||
// return database.prepare(`UPDATE users SET ${
|
||||
// keys.map(k => `${k} = @${k}`).join(", ")
|
||||
// } WHERE guild_id = @guild_id AND id = @id`).run(data);
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
export const DB = {
|
||||
@@ -91,27 +75,4 @@ export const DB = {
|
||||
}
|
||||
},
|
||||
},
|
||||
// user: {
|
||||
// get(guildId: string, id: string) {
|
||||
// return stmt.user.get.get(guildId, id) as UserType | undefined;
|
||||
// },
|
||||
// set(data: UserType) {
|
||||
// try {
|
||||
// stmt.user.insert(data);
|
||||
// return true;
|
||||
// } catch (err) {
|
||||
// Logger.error(String(err));
|
||||
// return false;
|
||||
// }
|
||||
// },
|
||||
// update(data: UserType) {
|
||||
// try {
|
||||
// stmt.user.update(data);
|
||||
// return true;
|
||||
// } catch (err) {
|
||||
// Logger.error(String(err));
|
||||
// return false;
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
};
|
||||
95
bot/src/utils/SelfTest.ts
Normal file
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)}`);
|
||||
}
|
||||
}
|
||||
8
bot/src/utils/Shuffle.ts
Normal file
8
bot/src/utils/Shuffle.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Fisher-Yates 셔플 (in-place) */
|
||||
export const shuffle = <T>(list: T[]): T[] => {
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
|
||||
|
||||
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
|
||||
const SPOTIFY_API_URL = "https://api.spotify.com/v1";
|
||||
const TOKENR_URL = "http://192.168.10.5:8075/api/token";
|
||||
const TOKENR_URL = process.env.SPOTIFY_TOKENER_URL?.trim() || "http://192.168.10.5:8075/api/token";
|
||||
|
||||
const searchCache = new Map<string, string>();
|
||||
|
||||
@@ -126,5 +126,35 @@ export const Spotify = {
|
||||
const track = (await this.getSearchFull(query) ?? [])?.[0];
|
||||
if (track?.url) searchCache.set(lowerQuery, track.url);
|
||||
return track?.url ?? null;
|
||||
},
|
||||
|
||||
/** open.spotify.com/track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
|
||||
parseTrackId(text: string): string | null {
|
||||
const m = text.trim().match(/(?:open\.spotify\.com\/track\/|spotify:track:)([A-Za-z0-9]+)/);
|
||||
return m ? m[1] : null;
|
||||
},
|
||||
|
||||
/** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */
|
||||
async getTrackById(id: string): Promise<{ title: string; artist: string } | null> {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
if (!token) return null;
|
||||
const response = await fetch(`${SPOTIFY_API_URL}/tracks/${encodeURIComponent(id)}?market=KR`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
Logger.error(`스포티파이 트랙 조회 에러 (${response.status})`);
|
||||
return null;
|
||||
}
|
||||
const track = await response.json() as SpotifyApi.TrackObjectFull;
|
||||
if (!track?.name) return null;
|
||||
return {
|
||||
title: track.name,
|
||||
artist: (track.artists ?? []).map((a) => a.name).join(", "),
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.error(`스포티파이 트랙 조회 실패: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import crypto from "node:crypto";
|
||||
import { Cookies } from "../../types/Youtube_Cookie";
|
||||
import { Config } from "../Config";
|
||||
import { SongItem } from "../../types/Track";
|
||||
import { Logger } from "../Logger";
|
||||
|
||||
const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080";
|
||||
// 커버/MR/노래방/라이브 등 2차 창작물 판별용. 원곡이 아닌 결과를 후순위/배제하는 데 쓴다.
|
||||
const JUNK_RE = /(\bMR\b|inst(?:rumental)?|instrumental|karaoke|노래방|\bcover\b|커버|nightcore|sped ?up|slowed|8d audio|리믹스|remix|male version|남자\s?버전|여자\s?버전|가사|lyrics?|billboard|top ?\d{2,}|\bEp\.?\s?\d|에피소드|초대석|방송|fancam|직캠|플레이리스트|재생목록|playlist|\bmix\b|best of|모음|메들리|medley|전체 ?듣기|full album)/i;
|
||||
export const ORIGIN = "https://music.youtube.com";
|
||||
const proxy = new ProxyAgent('http://192.168.10.4:3128');
|
||||
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
|
||||
const searchCache = new Map<string, string>();
|
||||
|
||||
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
|
||||
@@ -56,7 +59,7 @@ export const YoutubeMusic = {
|
||||
const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies));
|
||||
Logger.warn(`현재 입력된 쿠키 키 목록: ${Object.keys(cookies).join(", ")}`);
|
||||
throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -82,7 +85,7 @@ export const YoutubeMusic = {
|
||||
* 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다.
|
||||
*/
|
||||
async getSearchFull(query: string): Promise<SongItem[]> {
|
||||
console.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
|
||||
Logger.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
|
||||
|
||||
const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
|
||||
|
||||
@@ -106,10 +109,11 @@ export const YoutubeMusic = {
|
||||
gl: "KR"
|
||||
}
|
||||
},
|
||||
query: query,
|
||||
params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE="
|
||||
query: query
|
||||
// NOTE: 과거의 고정 params("EgW...")는 YTM에서 "검색결과 없음"을 유발하므로 제거.
|
||||
// 필터 없이 요청하면 상단 Top Result(musicCardShelfRenderer) + 곡 목록이 함께 온다.
|
||||
}),
|
||||
dispatcher: proxy
|
||||
...(proxy ? { dispatcher: proxy } : {})
|
||||
});
|
||||
|
||||
const data: any = await response.json();
|
||||
@@ -156,9 +160,9 @@ export const YoutubeMusic = {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 일반 검색 결과 목록 파싱
|
||||
if (section.musicShelfRenderer) {
|
||||
const items = section.musicShelfRenderer.contents || [];
|
||||
// 2. 일반 검색 결과 목록 파싱 (곡은 musicShelfRenderer 또는 itemSectionRenderer 하위에 위치)
|
||||
{
|
||||
const items = section.musicShelfRenderer?.contents ?? section.itemSectionRenderer?.contents ?? [];
|
||||
|
||||
for (const item of items) {
|
||||
const track = item.musicResponsiveListItemRenderer;
|
||||
@@ -200,9 +204,13 @@ export const YoutubeMusic = {
|
||||
}
|
||||
}
|
||||
|
||||
return results || []; // 배열이 비어있을 경우 안전하게 null 반환
|
||||
// YTM이 필터 없는 검색에서 커버/MR/노래방/라이브를 상위로 올리는 경우가 있어,
|
||||
// 명백한 2차 창작물은 뒤로 보낸다(원곡 우선). 동점이면 원래 순서 유지(안정 정렬).
|
||||
const scored = results.map((r, i) => ({ r, i, junk: JUNK_RE.test(r.title) ? 1 : 0 }));
|
||||
scored.sort((a, b) => a.junk - b.junk || a.i - b.i);
|
||||
return scored.map((s) => s.r);
|
||||
} catch (error) {
|
||||
console.error("❌ getSearchFull 실행 중 에러:", error);
|
||||
Logger.error(`❌ getSearchFull 실행 중 에러: ${String(error)}`);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -211,7 +219,19 @@ export const YoutubeMusic = {
|
||||
const lowerQuery = query.toLocaleLowerCase().trim();
|
||||
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
|
||||
const video = (await this.getSearchFull(query) ?? [])?.[0];
|
||||
if (video?.url) searchCache.set(lowerQuery, video.url);
|
||||
return video?.url ?? null;
|
||||
if (!video?.url) return null;
|
||||
// YTM API가 이 계정/엔드포인트에서 음악 카탈로그 '노래'를 주지 못하고 일반 유튜브 영상
|
||||
// (토크쇼·재생목록·커버·MR 등)을 반환하는 경우가 많다. 그래서 (1)커버/MR류거나 (2)제목이
|
||||
// 검색어와 충분히 일치하지 않으면 null을 반환해, 호출측이 더 깨끗한 `ytsearch:... Topic`
|
||||
// (공식 오디오) 폴백을 쓰도록 한다.
|
||||
if (JUNK_RE.test(video.title)) return null;
|
||||
const titleLower = video.title.toLocaleLowerCase();
|
||||
const tokens = lowerQuery.split(/\s+/).filter((t) => t.length >= 2);
|
||||
if (tokens.length > 0) {
|
||||
const hit = tokens.filter((t) => titleLower.includes(t)).length;
|
||||
if (hit / tokens.length < 0.5) return null; // 관련성 낮음 → 폴백
|
||||
}
|
||||
searchCache.set(lowerQuery, video.url);
|
||||
return video.url;
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { lavalinkManager } from "../../index";
|
||||
import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel";
|
||||
import { default_content, default_embed, default_image, getButtons } from "./Config";
|
||||
import { DB } from "../Database";
|
||||
import { Logger } from "../Logger";
|
||||
|
||||
export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => {
|
||||
if (!interaction.guild) return;
|
||||
@@ -16,7 +17,9 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
|
||||
} else {
|
||||
if (args[0] === "recommend") buttonRecommend(interaction.guild);
|
||||
}
|
||||
return interaction.deferUpdate().catch(() => {});
|
||||
return interaction.deferUpdate().catch((err) => {
|
||||
Logger.warn(`[Button] deferUpdate 실패: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const buttonRecommend = async (guild: Guild) => {
|
||||
@@ -33,7 +36,7 @@ const buttonRecommend = async (guild: Guild) => {
|
||||
components: [ getButtons() ],
|
||||
files: [ default_image ],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
Logger.error(`[Button] 메세지 수정 실패: ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@@ -6,15 +6,19 @@ import { clearAllMsg } from "./Utils";
|
||||
import { client } from "../../index";
|
||||
|
||||
export const getGuildById = async (guildId: string): Promise<Guild | null> => {
|
||||
const guild = client.guilds.cache.get(guildId)?.fetch();
|
||||
const guild = await client.guilds.cache.get(guildId)?.fetch().catch(() => undefined);
|
||||
if (!guild) return null;
|
||||
return guild;
|
||||
}
|
||||
|
||||
export const getMemberById = async (guild: Guild, userId: string): Promise<GuildMember | null> => {
|
||||
const member = await guild.members.cache.get(userId)?.fetch(true);
|
||||
if (!member) return null;
|
||||
return member;
|
||||
try {
|
||||
const cached = guild.members.cache.get(userId);
|
||||
if (!cached) return null;
|
||||
return await cached.fetch(true);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
||||
@@ -25,9 +29,14 @@ export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
||||
|
||||
export const getVoiceChannelById = async (guild: Guild, userId: string): Promise<VoiceChannel | null> => {
|
||||
if (!guild) return null;
|
||||
const member = await guild.members.cache.get(userId)?.fetch(true);
|
||||
if (!member) return null;
|
||||
try {
|
||||
const cached = guild.members.cache.get(userId);
|
||||
if (!cached) return null;
|
||||
const member = await cached.fetch(true);
|
||||
return getVoiceChannel(member);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const fshuffle = (list: any[]): any[] => {
|
||||
var i, j, x;
|
||||
for (i=list.length; i; i-=1) {
|
||||
j = Math.floor(Math.random()*i);
|
||||
x = list[i-1];
|
||||
list[i-1] = list[j];
|
||||
list[j] = x;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export const shuffle = (list: any[]): any[] => {
|
||||
for (let z=0; z<5; z++) list = fshuffle(list);
|
||||
return list;
|
||||
}
|
||||
21
docker-compose.exp.yml
Normal file
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
|
||||
@@ -1,12 +1,38 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
"192.168.10.13",
|
||||
"localhost",
|
||||
"music.tkrmagid.kr"
|
||||
]
|
||||
// 보안 헤더
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=()",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
// 디스코드 CDN(이미지)과 자기 자신만 신뢰
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data: https://cdn.discordapp.com https://i.scdn.co https://i.ytimg.com https://lh3.googleusercontent.com",
|
||||
"script-src 'self' 'unsafe-inline'" + (process.env.NODE_ENV === "production" ? "" : " 'unsafe-eval'"),
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"connect-src 'self' https://discord.com",
|
||||
"font-src 'self' data:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self' https://discord.com",
|
||||
].join("; "),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
204
page/package-lock.json
generated
204
page/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"colors": "^1.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next": "^16.2.4",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
@@ -22,7 +22,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -611,9 +611,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -630,9 +627,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -649,9 +643,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -668,9 +659,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -687,9 +675,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -706,9 +691,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -725,9 +707,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -744,9 +723,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -763,9 +739,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -788,9 +761,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -813,9 +783,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -838,9 +805,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -863,9 +827,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -888,9 +849,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -913,9 +871,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -938,9 +893,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1102,15 +1054,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
|
||||
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
|
||||
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz",
|
||||
"integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
|
||||
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1118,9 +1070,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
|
||||
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
|
||||
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1134,9 +1086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
|
||||
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
|
||||
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1150,15 +1102,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
|
||||
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
|
||||
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1169,15 +1118,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
|
||||
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
|
||||
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1188,15 +1134,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
|
||||
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
|
||||
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1207,15 +1150,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
|
||||
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
|
||||
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1226,9 +1166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
|
||||
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1242,9 +1182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
|
||||
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1463,9 +1403,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1483,9 +1420,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1503,9 +1437,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1523,9 +1454,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2076,9 +2004,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2093,9 +2018,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2110,9 +2032,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2127,9 +2046,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2144,9 +2060,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2161,9 +2074,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2178,9 +2088,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2195,9 +2102,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3270,13 +3174,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz",
|
||||
"integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
|
||||
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.2.2",
|
||||
"@next/eslint-plugin-next": "16.2.4",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -4873,9 +4777,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4897,9 +4798,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4921,9 +4819,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4945,9 +4840,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5185,12 +5077,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
|
||||
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
|
||||
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.2",
|
||||
"@next/env": "16.2.4",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -5204,14 +5096,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.2",
|
||||
"@next/swc-darwin-x64": "16.2.2",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.2",
|
||||
"@next/swc-linux-arm64-musl": "16.2.2",
|
||||
"@next/swc-linux-x64-gnu": "16.2.2",
|
||||
"@next/swc-linux-x64-musl": "16.2.2",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.2",
|
||||
"@next/swc-win32-x64-msvc": "16.2.2",
|
||||
"@next/swc-darwin-arm64": "16.2.4",
|
||||
"@next/swc-darwin-x64": "16.2.4",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.4",
|
||||
"@next/swc-linux-arm64-musl": "16.2.4",
|
||||
"@next/swc-linux-x64-gnu": "16.2.4",
|
||||
"@next/swc-linux-x64-musl": "16.2.4",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.4",
|
||||
"@next/swc-win32-x64-msvc": "16.2.4",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"colors": "^1.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next": "^16.2.4",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import DiscordProvider from "next-auth/providers/discord";
|
||||
|
||||
// 환경변수 부팅 시점 검증 — 누락 시 즉시 실패
|
||||
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID?.trim();
|
||||
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET?.trim();
|
||||
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET?.trim();
|
||||
|
||||
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
|
||||
throw new Error("[NextAuth] DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("[NextAuth] NEXTAUTH_SECRET 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
interface DiscordProfile {
|
||||
id?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
secret: NEXTAUTH_SECRET,
|
||||
providers: [
|
||||
DiscordProvider({
|
||||
clientId: process.env.DISCORD_CLIENT_ID as string,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
|
||||
clientId: DISCORD_CLIENT_ID,
|
||||
clientSecret: DISCORD_CLIENT_SECRET,
|
||||
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
|
||||
authorization: { params: { scope: "identify email guilds" } },
|
||||
}),
|
||||
@@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = {
|
||||
callbacks: {
|
||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||
async jwt({ token, account, profile }) {
|
||||
if (account && (profile as any)?.id) {
|
||||
token.id = (profile as any).id;
|
||||
const discordProfile = profile as DiscordProfile | undefined;
|
||||
if (account && discordProfile?.id) {
|
||||
token.id = discordProfile.id;
|
||||
token.accessToken = account.access_token;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: any) {
|
||||
session.user.id = token.id;
|
||||
session.accessToken = token.accessToken;
|
||||
async session({ session, token }) {
|
||||
if (token.id) session.user.id = token.id;
|
||||
if (token.accessToken) session.accessToken = token.accessToken;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,54 +1,10 @@
|
||||
// src/app/api/queue/events/route.ts
|
||||
// src/app/api/player/events/route.ts
|
||||
import { NextRequest } from "next/server";
|
||||
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
|
||||
import { botEventStream } from "@/lib/sse";
|
||||
|
||||
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// 프론트엔드에서 보낸 serverId 가져오기
|
||||
const serverId = req.nextUrl.searchParams.get("serverId");
|
||||
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
// SSE(Server-Sent Events) 스트림 생성
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
|
||||
const subscriber = Redis.duplicate();
|
||||
|
||||
// 'bot-site' 채널 구독
|
||||
await subscriber.subscribe("bot-site");
|
||||
|
||||
// 메세지가 들어올 때마다 실행
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site") return;
|
||||
const data = JSON.parse(message);
|
||||
if (data.guildId !== serverId) return;
|
||||
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
|
||||
if (data.event === "player_update") {
|
||||
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
|
||||
controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
subscriber.unsubscribe("bot-site");
|
||||
subscriber.quit();
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
return botEventStream(req, { botEventName: "player_update" });
|
||||
}
|
||||
@@ -1,42 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface NowBody {
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<NowBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_now' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:now",
|
||||
payload: {
|
||||
action: "player_now",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireBoolean,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface PauseBody {
|
||||
serverId?: unknown;
|
||||
isPaused?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, isPaused } = body;
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 });
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const bodyResult = await readJsonBody<PauseBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
// 봇에게 'player_pause' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused");
|
||||
if (!isPausedResult.ok) return isPausedResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:paused",
|
||||
payload: {
|
||||
action: "player_paused",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
isPaused: isPaused,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
isPaused: isPausedResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface PlayBody {
|
||||
serverId?: unknown;
|
||||
track?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, track } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<PlayBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_play' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const track = bodyResult.data.track;
|
||||
if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다.");
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:play",
|
||||
payload: {
|
||||
action: "player_play",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
track: track,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
track,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface PlaylistBody {
|
||||
serverId?: unknown;
|
||||
playlistUrl?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, playlistUrl } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<PlaylistBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_playlist' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl");
|
||||
if (!urlResult.ok) return urlResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:playlist",
|
||||
payload: {
|
||||
action: "player_playlist",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
playlistUrl: playlistUrl,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
playlistUrl: urlResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Queue Adds API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireNumber,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface SeekBody {
|
||||
serverId?: unknown;
|
||||
seek?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, seek } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<SeekBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_seek' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
// seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용.
|
||||
const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true });
|
||||
if (!seekResult.ok) return seekResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:seek",
|
||||
payload: {
|
||||
action: "player_seek",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
seek: seek,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
seek: seekResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface SkipBody {
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<SkipBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_skip' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:skip",
|
||||
payload: {
|
||||
action: "player_skip",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireNumber,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface VolumeBody {
|
||||
serverId?: unknown;
|
||||
volume?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, volume } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<VolumeBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_volume' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const volumeResult = requireNumber(bodyResult.data.volume, "volume", {
|
||||
min: 0,
|
||||
max: 100,
|
||||
integer: true,
|
||||
});
|
||||
if (!volumeResult.ok) return volumeResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:volume",
|
||||
payload: {
|
||||
action: "player_volume",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
volume: volume,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
volume: volumeResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,10 @@
|
||||
// src/app/api/queue/events/route.ts
|
||||
import { NextRequest } from "next/server";
|
||||
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
|
||||
import { botEventStream } from "@/lib/sse";
|
||||
|
||||
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// 프론트엔드에서 보낸 serverId 가져오기
|
||||
const serverId = req.nextUrl.searchParams.get("serverId");
|
||||
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
// SSE(Server-Sent Events) 스트림 생성
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
|
||||
const subscriber = Redis.duplicate();
|
||||
|
||||
// 'bot-site' 채널 구독
|
||||
await subscriber.subscribe("bot-site");
|
||||
|
||||
// 메세지가 들어올 때마다 실행
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site") return;
|
||||
const data = JSON.parse(message);
|
||||
if (data.guildId !== serverId) return;
|
||||
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
|
||||
if (data.event === "queue_update") {
|
||||
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
|
||||
controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
subscriber.unsubscribe("bot-site");
|
||||
subscriber.quit();
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
return botEventStream(req, { botEventName: "queue_update" });
|
||||
}
|
||||
@@ -1,44 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface QueueListBody {
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<QueueListBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
// 1. 고유한 요청 ID(진동벨) 생성
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `queue:list:${requestId}`;
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 2. 봇에게 'queue_list' 명령 발송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "queue:list",
|
||||
payload: {
|
||||
action: "queue_list",
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
requestId: requestId, // 🌟 봇이 대답을 남길 키
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Queue List API Error:", error);
|
||||
return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireNumber,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface QueueRemoveBody {
|
||||
serverId?: unknown;
|
||||
index?: unknown;
|
||||
encoded?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, index, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<QueueRemoveBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `queue:remove:${requestId}`;
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
// index 0 도 정상값
|
||||
const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true });
|
||||
if (!indexResult.ok) return indexResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "queue:remove",
|
||||
payload: {
|
||||
action: "queue_remove",
|
||||
serverId: serverId,
|
||||
requestId: requestId,
|
||||
userId: userId,
|
||||
index: index,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// 0.5초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
index: indexResult.value,
|
||||
// 인덱스-트랙 일치 검증용(봇이 대상 encoded 불일치 시 거절). 문자열일 때만 전달.
|
||||
encoded: typeof bodyResult.data.encoded === "string" ? bodyResult.data.encoded : undefined,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
||||
Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface QueueSetBody {
|
||||
serverId?: unknown;
|
||||
newQueue?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, newQueue, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<QueueSetBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `queue:set:${requestId}`;
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const newQueue = bodyResult.data.newQueue;
|
||||
if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다.");
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "queue:set",
|
||||
payload: {
|
||||
action: "queue_set",
|
||||
serverId: serverId,
|
||||
requestId: requestId,
|
||||
userId: userId,
|
||||
newQueue: newQueue,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// 0.5초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
newQueue,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Queue Reorder API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
||||
Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,29 @@
|
||||
// src/app/api/search/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, requireSession } from "@/lib/api";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// 1. 검색어(query) 가져오기
|
||||
try {
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q");
|
||||
const query = searchParams.get("q")?.trim();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 });
|
||||
}
|
||||
if (!query) return errorResponse("검색어가 없습니다.", 400);
|
||||
|
||||
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `search:${requestId}`;
|
||||
|
||||
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "search",
|
||||
payload: {
|
||||
action: "search",
|
||||
query: query,
|
||||
requestId: requestId,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다.
|
||||
for (let i=0; i<10; i++) {
|
||||
// 1.0초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
query,
|
||||
},
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
Logger.error(`search API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
}
|
||||
@@ -1,34 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { requireSession } from "@/lib/api";
|
||||
|
||||
interface DiscordGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions) as any;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
|
||||
if (!session || !session.accessToken) {
|
||||
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
|
||||
const accessToken = sessionResult.session.accessToken;
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
|
||||
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const userGuilds = await userGuildsRes.json() ?? [];
|
||||
if (!userGuildsRes.ok) {
|
||||
Logger.warn(`Discord guilds API ${userGuildsRes.status} ${userGuildsRes.statusText}`);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Discord 서버 목록을 가져오지 못했습니다." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const userGuildsRaw: unknown = await userGuildsRes.json();
|
||||
const userGuilds: DiscordGuild[] = Array.isArray(userGuildsRaw) ? (userGuildsRaw as DiscordGuild[]) : [];
|
||||
|
||||
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
|
||||
const botGuildsData = await Redis.get("bot-guilds");
|
||||
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : [];
|
||||
let botGuildIds: string[] = [];
|
||||
if (botGuildsData) {
|
||||
try {
|
||||
const parsed = JSON.parse(botGuildsData);
|
||||
if (Array.isArray(parsed)) botGuildIds = parsed.filter((v): v is string => typeof v === "string");
|
||||
} catch {
|
||||
Logger.warn("Redis bot-guilds JSON 파싱 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
|
||||
const filteredGuilds = userGuilds.filter((guild: any) =>
|
||||
botGuildIds.includes(guild.id)
|
||||
);
|
||||
const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id));
|
||||
|
||||
return NextResponse.json(filteredGuilds);
|
||||
} catch (error) {
|
||||
console.error("서버 필터링 에러:", error);
|
||||
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
||||
Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||
import MainContent from "@/components/player/MainContent";
|
||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
||||
import PlayerBar from "@/components/player/PlayerBar";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
// 화면 모드 타입 정의
|
||||
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||
|
||||
export default function MusicPlayerLayout() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||
const [selectedServer, setSelectedServer] = useState<any>(null);
|
||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
||||
@@ -33,7 +34,7 @@ export default function MusicPlayerLayout() {
|
||||
};
|
||||
|
||||
// 서버 선택 시
|
||||
const handleSelectServer = (server: any) => {
|
||||
const handleSelectServer = (server: DiscordServer) => {
|
||||
setSelectedServer(server);
|
||||
setViewMode("SERVER_DETAIL");
|
||||
};
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
"use client";
|
||||
import { ListMusic, Library } from "lucide-react";
|
||||
import { ListMusic } from "lucide-react";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
return (
|
||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<Library size={20} /> 내 플레이리스트
|
||||
</button>
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<ListMusic size={20} /> 좋아요 표시한 곡
|
||||
</button>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 text-neutral-400 font-medium">
|
||||
<ListMusic size={20} />
|
||||
<span>음악 봇 컨트롤</span>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-800" />
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
|
||||
<p className="hover:text-white cursor-pointer truncate">출근길 노동요 모음</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">2024 빌보드 탑 100</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">비 오는 날 듣기 좋은 재즈</p>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
|
||||
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
interface TopNavProps {
|
||||
onSearch: (query: string) => void;
|
||||
onHome: () => void;
|
||||
selectedServer: any; // 🌟 추가: 선택된 서버 정보
|
||||
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
|
||||
}
|
||||
|
||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||
|
||||
@@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc }
|
||||
import { ViewMode } from "@/app/page";
|
||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
||||
|
||||
interface MainContentProps {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
selectedServer: any;
|
||||
setSelectedServer: (server: any) => void;
|
||||
selectedServer: DiscordServer | null;
|
||||
setSelectedServer: (server: DiscordServer | null) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
onSelectServer: (server: any) => void;
|
||||
}
|
||||
|
||||
interface SearchResultsType {
|
||||
spotify: any[];
|
||||
youtubeMusic: any[];
|
||||
youtubeVideo: any[];
|
||||
onSelectServer: (server: DiscordServer) => void;
|
||||
}
|
||||
|
||||
export default function MainContent({
|
||||
@@ -36,27 +31,31 @@ export default function MainContent({
|
||||
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [servers, setServers] = useState<DiscordServer[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResultsType>({
|
||||
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||
spotify: [],
|
||||
youtubeMusic: [],
|
||||
youtubeVideo: []
|
||||
});
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
|
||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
||||
if (!selectedServer) {
|
||||
// 🌟 alert -> showToast 교체
|
||||
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
showToast("로그인이 필요합니다.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
let bodyData: any = { serverId: selectedServer.id, userId: userId };
|
||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
|
||||
if (actionType === 'player_play') {
|
||||
endpoint = "/api/player/play";
|
||||
bodyData.track = track;
|
||||
@@ -88,13 +87,16 @@ export default function MainContent({
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionLabel = (server: any) => {
|
||||
const getPermissionLabel = (server: DiscordServer | null) => {
|
||||
if (!server) return "알 수 없음";
|
||||
if (server.owner) return "👑 서버 주인";
|
||||
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
|
||||
const raw: unknown = server.permissions;
|
||||
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
|
||||
try {
|
||||
const perms = BigInt(server.permissions);
|
||||
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
|
||||
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
|
||||
const perms = BigInt(raw);
|
||||
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
|
||||
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
|
||||
return "👤 일반 멤버";
|
||||
} catch {
|
||||
return "👤 일반 멤버";
|
||||
@@ -106,9 +108,17 @@ export default function MainContent({
|
||||
setIsFetching(true);
|
||||
const cached = sessionStorage.getItem("filtered_servers");
|
||||
if (cached) {
|
||||
setServers(JSON.parse(cached));
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (Array.isArray(parsed)) {
|
||||
setServers(parsed);
|
||||
setIsFetching(false);
|
||||
}
|
||||
} catch {
|
||||
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
|
||||
sessionStorage.removeItem("filtered_servers");
|
||||
}
|
||||
}
|
||||
|
||||
fetch("/api/servers")
|
||||
.then((res) => res.json())
|
||||
@@ -159,8 +169,8 @@ export default function MainContent({
|
||||
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
const renderTrackCard = (track: any) => (
|
||||
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
<div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||
<button
|
||||
@@ -258,7 +268,7 @@ export default function MainContent({
|
||||
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
||||
{viewMode === "SEARCH_RESULT" && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" 검색 결과</h2>
|
||||
<h2 className="text-2xl font-bold mb-2">“{searchQuery}” 검색 결과</h2>
|
||||
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
|
||||
|
||||
{isSearching ? (
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface PlayerBarProps {
|
||||
selectedServer: any;
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// 재생 상태 관리
|
||||
const [track, setTrack] = useState<any>(null);
|
||||
const [track, setTrack] = useState<Track | null>(null);
|
||||
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [isPaused, setIsPaused] = useState<boolean>(false);
|
||||
@@ -21,6 +22,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const [position, setPosition] = useState<number>(0);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
|
||||
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
|
||||
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
|
||||
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
||||
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
||||
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
|
||||
|
||||
@@ -28,7 +34,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const fetchNowPlaying = useCallback(async () => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
@@ -37,21 +43,22 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success && data.track) {
|
||||
setBotPlayer(data.botPlayer);
|
||||
setIsPlaying(data.isPlaying);
|
||||
setIsPaused(data.isPaused);
|
||||
setTrack(data.track);
|
||||
setDuration(data.track.info.length || 0);
|
||||
setVolume(data.volume ?? 50);
|
||||
setBotPlayer(Boolean(data.botPlayer));
|
||||
setIsPlaying(Boolean(data.isPlaying));
|
||||
setIsPaused(Boolean(data.isPaused));
|
||||
setTrack(data.track as Track);
|
||||
setDuration(Number(data.track?.info?.length ?? 0) || 0);
|
||||
setVolume(typeof data.volume === "number" ? data.volume : 50);
|
||||
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
||||
if (!isDragging.current) {
|
||||
setPosition(data.position || 0);
|
||||
const p = data.position || 0;
|
||||
setPosition(p);
|
||||
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
|
||||
}
|
||||
} else {
|
||||
setTrack(null);
|
||||
@@ -67,45 +74,53 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
|
||||
useEffect(() => {
|
||||
if (!selectedServer) return;
|
||||
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchNowPlaying();
|
||||
|
||||
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
|
||||
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "player_update") {
|
||||
if (data?.type === "player_update") {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
return () => eventSource.close();
|
||||
}, [selectedServer, fetchNowPlaying]);
|
||||
|
||||
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
|
||||
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
|
||||
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
|
||||
const isPausedRef = useRef(isPaused);
|
||||
useEffect(() => {
|
||||
let localInterval: NodeJS.Timeout;
|
||||
let syncInterval: NodeJS.Timeout;
|
||||
isPausedRef.current = isPaused;
|
||||
}, [isPaused]);
|
||||
|
||||
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
|
||||
if (isPlaying && !isDragging.current) {
|
||||
|
||||
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||
localInterval = setInterval(() => {
|
||||
if (!isPaused) setPosition((prev) => {
|
||||
if (prev >= duration) return duration;
|
||||
return prev + 1000;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||
syncInterval = setInterval(() => {
|
||||
if (!isPaused) fetchNowPlaying();
|
||||
}, 10000);
|
||||
|
||||
}
|
||||
|
||||
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
|
||||
const tick = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
const a = anchorRef.current;
|
||||
const next = a.pos + (Date.now() - a.at);
|
||||
setPosition(duration > 0 ? Math.min(duration, next) : next);
|
||||
}, 250);
|
||||
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
|
||||
const syncInterval = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
fetchNowPlaying();
|
||||
}, 15000);
|
||||
return () => {
|
||||
clearInterval(localInterval);
|
||||
clearInterval(tick);
|
||||
clearInterval(syncInterval);
|
||||
};
|
||||
}, [isPlaying, duration, fetchNowPlaying]);
|
||||
@@ -117,44 +132,46 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const handleTogglePause = async () => {
|
||||
if (!selectedServer || !track) return;
|
||||
if (!isPlaying) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
const nextPaused = !isPaused;
|
||||
// UI 즉각 반영 (Optimistic UI)
|
||||
setIsPaused(!isPaused);
|
||||
setIsPaused(nextPaused);
|
||||
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
|
||||
try {
|
||||
const res = await fetch('/api/player/pause', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
isPaused: String(isPaused),
|
||||
isPaused: nextPaused, // boolean 그대로 전송
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
|
||||
setIsPaused(true);
|
||||
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
|
||||
if (typeof data.paused === "boolean") setIsPaused(data.paused);
|
||||
} else {
|
||||
setIsPaused(false);
|
||||
}
|
||||
// 실패 시 롤백
|
||||
setIsPaused(!nextPaused);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("일시정지 에러:", error);
|
||||
setIsPaused(!isPaused); // 실패 시 롤백
|
||||
setIsPaused(!nextPaused); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
// 다음 곡 스킵 (player_skip)
|
||||
const handleSkip = async () => {
|
||||
if (!selectedServer || !track) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
try {
|
||||
await fetch('/api/player/skip', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
|
||||
@@ -167,11 +184,14 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
|
||||
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||
if (!selectedServer || !track) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
isDragging.current = false;
|
||||
|
||||
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
||||
const newPosition = Number(e.currentTarget.value);
|
||||
setPosition(newPosition);
|
||||
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
|
||||
|
||||
try {
|
||||
await fetch('/api/player/seek', {
|
||||
@@ -180,7 +200,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
seek: newPosition,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -198,22 +217,25 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||
setIsVolumeDragging(false);
|
||||
if (!selectedServer) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
const finalVolume = Number(e.currentTarget.value);
|
||||
setVolume(finalVolume); // UI 즉시 반영
|
||||
|
||||
try {
|
||||
await fetch('/api/player/volume', {
|
||||
const res = await fetch('/api/player/volume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
volume: finalVolume,
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
|
||||
} catch (error) {
|
||||
console.error("볼륨 조절 에러:", error);
|
||||
setVolume(prevVolumeRef.current); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
@@ -331,8 +353,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
value={volume}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
||||
onMouseDown={() => setIsVolumeDragging(true)}
|
||||
onTouchStart={() => setIsVolumeDragging(true)}
|
||||
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
|
||||
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
|
||||
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Trash2, GripVertical, Music } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface QueueSidebarProps {
|
||||
selectedServer: any;
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
@@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
// 👇 [추가] 토스트 사용 준비 완료!
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [queue, setQueue] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
@@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
const fetchQueue = useCallback(async () => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/queue/list', {
|
||||
method: 'POST',
|
||||
@@ -41,35 +40,39 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success && Array.isArray(data.queue)) {
|
||||
setQueue(data.queue);
|
||||
setQueue(data.queue as Track[]);
|
||||
} else {
|
||||
setQueue([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("큐 불러오기 실패:", error);
|
||||
setQueue([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedServer, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading" || !selectedServer) return;
|
||||
|
||||
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchQueue();
|
||||
|
||||
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "queue_update") {
|
||||
if (data?.type === "queue_update") {
|
||||
fetchQueue();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
}
|
||||
|
||||
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// 3. 화면 즉시 업데이트
|
||||
const newQueue = [...queue];
|
||||
@@ -139,8 +142,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
|
||||
const handleDelete = async (indexToRemove: number) => {
|
||||
if (!selectedServer) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
|
||||
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
|
||||
const targetEncoded = queue[indexToRemove]?.encoded;
|
||||
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
||||
setQueue(newQueue);
|
||||
|
||||
@@ -150,8 +154,8 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
index: indexToRemove,
|
||||
encoded: targetEncoded,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -182,7 +186,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.id || index}-${item.info?.title || ''}`}
|
||||
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragItem.current = index;
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
import colors from "colors/safe";
|
||||
|
||||
// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출.
|
||||
export const Timestamp = () => {
|
||||
const Now = new Date();
|
||||
Now.setHours(Now.getHours() + 9);
|
||||
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
|
||||
}
|
||||
const now = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
// YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss
|
||||
const iso = now.toISOString();
|
||||
const date = iso.slice(2, 10); // YY-MM-DD
|
||||
const time = iso.slice(11, 19); // HH:mm:ss
|
||||
return `${date} ${time}`;
|
||||
};
|
||||
|
||||
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
||||
type LogType = "log" | "info" | "warn" | "error" | "debug" | "ready";
|
||||
|
||||
const log = (content: string, type: logType) => {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
const write = (label: string, content: string, useStderr: boolean) => {
|
||||
const timestamp = colors.white(`[${Timestamp()}]`);
|
||||
const line = `${label} ${timestamp} ${content}`;
|
||||
if (useStderr) console.error(line);
|
||||
else console.log(line);
|
||||
};
|
||||
|
||||
const log = (content: string, type: LogType) => {
|
||||
switch (type) {
|
||||
case "log":
|
||||
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
|
||||
// 일반 디버그성 로그는 프로덕션에서 숨김
|
||||
if (isProd) return;
|
||||
return write(colors.gray("[LOG]"), content, false);
|
||||
case "info":
|
||||
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
|
||||
return write(colors.cyan("[INFO]"), content, false);
|
||||
case "warn":
|
||||
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
|
||||
return write(colors.yellow("[WARN]"), content, true);
|
||||
case "error":
|
||||
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
|
||||
return write(colors.red("[ERROR]"), content, true);
|
||||
case "debug":
|
||||
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
|
||||
if (isProd) return;
|
||||
return write(colors.magenta("[DEBUG]"), content, false);
|
||||
case "ready":
|
||||
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
|
||||
return write(colors.green("[READY]"), content, false);
|
||||
default:
|
||||
throw new TypeError("Logger 타입이 올바르지 않습니다.");
|
||||
}
|
||||
@@ -34,5 +49,5 @@ export const Logger = {
|
||||
error: (content: string) => log(content, "error"),
|
||||
debug: (content: string) => log(content, "debug"),
|
||||
info: (content: string) => log(content, "info"),
|
||||
ready: (content: string) => log(content, "ready")
|
||||
}
|
||||
ready: (content: string) => log(content, "ready"),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Redis as RedisClass } from "ioredis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
|
||||
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅)
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7";
|
||||
// Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다.
|
||||
const REDIS_HOST = process.env.REDIS_HOST?.trim();
|
||||
const REDIS_PORT = Number(process.env.REDIS_PORT?.trim() ?? "6379");
|
||||
|
||||
if (!REDIS_HOST) {
|
||||
throw new Error("[Redis] REDIS_HOST 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
if (!Number.isFinite(REDIS_PORT) || REDIS_PORT <= 0) {
|
||||
throw new Error(`[Redis] REDIS_PORT 값이 올바르지 않습니다: ${process.env.REDIS_PORT}`);
|
||||
}
|
||||
|
||||
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
|
||||
const globalForRedis = global as unknown as {
|
||||
@@ -9,16 +18,24 @@ const globalForRedis = global as unknown as {
|
||||
};
|
||||
|
||||
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
|
||||
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 });
|
||||
export const Redis = globalForRedis.redisClient ?? new RedisClass({
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
|
||||
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
|
||||
|
||||
// 연결 성공 시 로그 한 번만 찍기
|
||||
let connectLogged = false;
|
||||
Redis.on("connect", () => {
|
||||
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!");
|
||||
if (connectLogged) return;
|
||||
connectLogged = true;
|
||||
Logger.ready(`Redis 연결 완료 (${REDIS_HOST}:${REDIS_PORT})`);
|
||||
});
|
||||
|
||||
Redis.on("error", (err) => {
|
||||
console.error("❌ [Next.js] Redis 연결 에러:", err);
|
||||
Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
146
page/src/lib/api.ts
Normal file
146
page/src/lib/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession, type Session } from "next-auth";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
// ========== 공용 응답 스키마 ==========
|
||||
// 모든 API는 { success: boolean, ... } 형태로 응답한다.
|
||||
// 실패시 { success: false, error: string } 보장.
|
||||
|
||||
export const errorResponse = (message: string, status = 400) =>
|
||||
NextResponse.json({ success: false, error: message }, { status });
|
||||
|
||||
// ========== 세션 가드 ==========
|
||||
// 세션이 없으면 401 NextResponse를 던지고, 있으면 세션을 돌려준다.
|
||||
export async function requireSession(): Promise<
|
||||
{ ok: true; session: Session } | { ok: false; response: NextResponse }
|
||||
> {
|
||||
const session = (await getServerSession(authOptions)) as Session | null;
|
||||
if (!session?.user?.id) {
|
||||
return { ok: false, response: errorResponse("인증되지 않았습니다.", 401) };
|
||||
}
|
||||
return { ok: true, session };
|
||||
}
|
||||
|
||||
// ========== 봇 RPC 헬퍼 ==========
|
||||
// site → bot: Redis Pub/Sub 으로 명령 전송 (채널 "site-bot")
|
||||
// bot → site: 봇이 결과를 resultKey 리스트에 LPUSH
|
||||
// 사이트는 resultKey 를 BRPOP 으로 블로킹 대기한다(폴링 없음 → 응답 즉시, 헛 GET 제거).
|
||||
|
||||
export interface BotRpcOptions {
|
||||
/** Redis 결과 키 prefix (e.g. "player:now") */
|
||||
channel: string;
|
||||
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
|
||||
payload: Record<string, unknown>;
|
||||
/** 응답 대기 타임아웃 (ms). 기본 3000. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function botRpc(
|
||||
opts: BotRpcOptions,
|
||||
): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const { channel, payload, timeoutMs = 3000 } = opts;
|
||||
|
||||
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
|
||||
const requestId = `req:${randomUUID()}`;
|
||||
const resultKey = `${channel}:${requestId}`;
|
||||
|
||||
// 봇에게 명령 전송 (action 필드는 호출부에서 payload 에 포함)
|
||||
await Redis.publish(
|
||||
"site-bot",
|
||||
JSON.stringify({ ...payload, requestId }),
|
||||
);
|
||||
|
||||
// BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록
|
||||
// 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다.
|
||||
// (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음)
|
||||
const waiter = Redis.duplicate();
|
||||
// BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달.
|
||||
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||
|
||||
try {
|
||||
// brpop → [key, value] | null(타임아웃)
|
||||
const popped = await waiter.brpop(resultKey, timeoutSec);
|
||||
if (!popped) {
|
||||
return {
|
||||
status: 504,
|
||||
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
|
||||
};
|
||||
}
|
||||
|
||||
const reply = popped[1];
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(reply);
|
||||
} catch (err) {
|
||||
Logger.error(`[botRpc:${channel}] 봇 응답 파싱 실패: ${String(err)}`);
|
||||
return {
|
||||
status: 502,
|
||||
body: { success: false, error: "봇 응답을 해석할 수 없습니다." },
|
||||
};
|
||||
}
|
||||
// 봇이 success 필드를 명시한 경우만 false로 평가. 없으면 데이터 응답으로 간주(200).
|
||||
const hasSuccessField = "success" in parsed;
|
||||
const ok = !hasSuccessField || parsed.success === true;
|
||||
return { status: ok ? 200 : 400, body: parsed };
|
||||
} finally {
|
||||
// 전용 커넥션 정리 (블로킹 중이면 즉시 끊기)
|
||||
waiter.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POST 본문 파싱 ==========
|
||||
export async function readJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
|
||||
try {
|
||||
const data = (await request.json()) as T;
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, response: errorResponse("요청 본문이 올바른 JSON이 아닙니다.", 400) };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 필수 필드 검증 ==========
|
||||
export function requireString(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): { ok: true; value: string } | { ok: false; response: NextResponse } {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { ok: false, response: errorResponse(`${field} 정보가 필요합니다.`, 400) };
|
||||
}
|
||||
return { ok: true, value: value.trim() };
|
||||
}
|
||||
|
||||
export function requireNumber(
|
||||
value: unknown,
|
||||
field: string,
|
||||
opts?: { min?: number; max?: number; integer?: boolean },
|
||||
): { ok: true; value: number } | { ok: false; response: NextResponse } {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return { ok: false, response: errorResponse(`${field} 값이 올바르지 않습니다.`, 400) };
|
||||
}
|
||||
if (opts?.integer && !Number.isInteger(n)) {
|
||||
return { ok: false, response: errorResponse(`${field} 는 정수여야 합니다.`, 400) };
|
||||
}
|
||||
if (opts?.min !== undefined && n < opts.min) {
|
||||
return { ok: false, response: errorResponse(`${field} 가 너무 작습니다.`, 400) };
|
||||
}
|
||||
if (opts?.max !== undefined && n > opts.max) {
|
||||
return { ok: false, response: errorResponse(`${field} 가 너무 큽니다.`, 400) };
|
||||
}
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
export function requireBoolean(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): { ok: true; value: boolean } | { ok: false; response: NextResponse } {
|
||||
if (typeof value !== "boolean") {
|
||||
return { ok: false, response: errorResponse(`${field} 는 boolean 이어야 합니다.`, 400) };
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
122
page/src/lib/sse.ts
Normal file
122
page/src/lib/sse.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
interface BotEvent {
|
||||
event?: unknown;
|
||||
guildId?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface BotEventStreamOptions {
|
||||
/** bot 이 발행하는 event 이름 (e.g. "player_update", "queue_update"). */
|
||||
botEventName: string;
|
||||
/** 클라이언트로 보낼 SSE type. 기본: botEventName 과 동일. */
|
||||
clientEventType?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 공유 구독자(fan-out): "bot-site" 채널은 프로세스당 구독자 1개만 두고,
|
||||
// 메모리에서 리스너들에게 분배한다. (SSE 연결마다 Redis 커넥션을 복제하지 않음)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
interface Listener {
|
||||
guildId: string;
|
||||
botEventName: string;
|
||||
send: () => void;
|
||||
}
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
const globalForSse = global as unknown as { botSiteSubscriber?: ReturnType<typeof Redis.duplicate> };
|
||||
|
||||
function ensureSubscriber() {
|
||||
if (globalForSse.botSiteSubscriber) return;
|
||||
const sub = Redis.duplicate();
|
||||
globalForSse.botSiteSubscriber = sub;
|
||||
sub.on("error", (err) => Logger.error(`[SSE] shared subscriber error: ${err.message}`));
|
||||
sub.subscribe("bot-site").catch((err) => Logger.error(`[SSE] subscribe 실패: ${String(err)}`));
|
||||
sub.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site") return;
|
||||
let data: BotEvent;
|
||||
try {
|
||||
data = JSON.parse(message) as BotEvent;
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE] 잘못된 JSON: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
for (const l of listeners) {
|
||||
if (data.guildId === l.guildId && data.event === l.botEventName) {
|
||||
try { l.send(); } catch { /* 개별 클라이언트 전송 실패는 무시 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇이 publish 하는 "bot-site" 채널을 공유 구독자로 받아 SSE 로 흘려보내는 공용 핸들러.
|
||||
* - 인증 가드: 세션 없으면 401
|
||||
* - serverId 검증
|
||||
* - 공유 구독자 + 메모리 fan-out (연결마다 Redis 커넥션 복제 안 함)
|
||||
* - keepalive ping (30초)
|
||||
*/
|
||||
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
|
||||
const clientEventType = opts.clientEventType ?? opts.botEventName;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return new Response("인증되지 않았습니다.", { status: 401 });
|
||||
}
|
||||
|
||||
const serverId = req.nextUrl.searchParams.get("serverId")?.trim();
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
ensureSubscriber();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearInterval(ping);
|
||||
listeners.delete(listener);
|
||||
try { controller.close(); } catch { /* 이미 닫힘 */ }
|
||||
};
|
||||
|
||||
const listener: Listener = {
|
||||
guildId: serverId,
|
||||
botEventName: opts.botEventName,
|
||||
send: () => {
|
||||
if (closed) return;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`));
|
||||
},
|
||||
};
|
||||
listeners.add(listener);
|
||||
|
||||
const ping = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keep-alive\n\n`));
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
req.signal.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
42
page/src/types/music.ts
Normal file
42
page/src/types/music.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// 공용 도메인 타입 — 봇/Discord/Lavalink 데이터 표현.
|
||||
|
||||
export interface DiscordServer {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
identifier?: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
length?: number;
|
||||
artworkUrl?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
encoded?: string;
|
||||
info?: TrackInfo;
|
||||
// 큐 항목에는 종종 추가 메타데이터(요청자 등)가 붙음.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 검색 결과(곡 카드) 공용 타입 — Spotify/YT Music/YT Video 모두 매핑됨.
|
||||
export interface SearchTrack {
|
||||
videoId?: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
spotify: SearchTrack[];
|
||||
youtubeMusic: SearchTrack[];
|
||||
youtubeVideo: SearchTrack[];
|
||||
}
|
||||
21
page/src/types/next-auth.d.ts
vendored
Normal file
21
page/src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import "next-auth";
|
||||
import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
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