Compare commits
20 Commits
ff391cc1c3
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| 5811c16c20 | |||
| fed948ca02 | |||
| 8ab5b84fa7 | |||
| 79f6201f85 | |||
| ef16b67839 | |||
| 3c6dbc9631 | |||
| e7283ae862 | |||
| 2e6c956265 | |||
| 5d5bf9b3f3 | |||
| 7f057d818c | |||
| 4fbf8d9326 | |||
| 7994e97631 | |||
| 6762b8faba | |||
| 6de959be43 | |||
| 1bb1d10b25 | |||
| f5778a7763 | |||
| 8e3791fb37 | |||
| 31b64b73c5 | |||
|
|
221c31bfa1 | ||
|
|
1efa55c3ec |
17
README.md
17
README.md
@@ -9,7 +9,7 @@
|
||||
|----------|------|------|
|
||||
| [`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` — yt-dlp 기반 오디오 URL 리졸버(+systemd 유닛). 봇이 연령제한/SABR 우회에 사용. | 아래 + bot/README §1·2 |
|
||||
| [`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 |
|
||||
|
||||
## 전체 아키텍처
|
||||
@@ -20,7 +20,10 @@
|
||||
│ ▲ └ youtube-plugin(일반)
|
||||
│ │ 재생 직전 리졸브
|
||||
▼ │
|
||||
ytresolver(.5:8779) ── yt-dlp(+쿠키) ──▶ 직접 오디오 URL
|
||||
ytresolver(.5:8779) ─┬─ ① 유튜브 내부 API(VISIONOS) ~0.3초
|
||||
│ ├─ ② yt-dlp 상주 호출 ~1.7초
|
||||
│ └─ ③ yt-dlp + 쿠키(연령제한) ~6~8초
|
||||
│ ──▶ 직접 오디오 URL
|
||||
│ (Lavalink http 소스로 재생)
|
||||
│
|
||||
┌───────── Redis(.7) Pub/Sub + Key ─────────┐ ← 제어/상태 버스
|
||||
@@ -31,8 +34,16 @@
|
||||
```
|
||||
|
||||
- **봇**과 **대시보드**는 직접 연결되지 않고 **Redis**로 느슨하게 연동된다(대시보드는 Redis만 있으면 봇과 통신).
|
||||
- 봇은 유튜브 연령제한 영상을 Lavalink만으로는 못 틀어서, `resolver/`가 쿠키로 직접 오디오 URL을 뽑아 우회한다.
|
||||
- 유튜브가 스트리밍을 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`).
|
||||
|
||||
## 빠르게 보기
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
재생 직전 리졸브 요청
|
||||
▼
|
||||
ytresolver (.5:8779, systemd)
|
||||
└ yt-dlp (+쿠키) → 직접 오디오 URL
|
||||
│
|
||||
├ ① 유튜브 내부 API(VISIONOS) ~0.3초
|
||||
├ ② yt-dlp 상주 호출 ~1.7초
|
||||
└ ③ yt-dlp + 쿠키(연령제한) ~6~8초
|
||||
│ → 직접 오디오 URL
|
||||
그 URL을 Lavalink http 소스로 재생
|
||||
```
|
||||
|
||||
@@ -30,10 +32,25 @@
|
||||
- `.5`와 `.6`은 **같은 공인 IP**를 쓰므로, `.5`에서 뽑은 googlevideo URL을 `.6` Lavalink가 그대로 재생할 수 있다.
|
||||
|
||||
### 왜 리졸버가 필요한가
|
||||
Lavalink `youtube-plugin`은 OAuth 로그인만 지원하는데, 유튜브는 **한국 19금(본인인증 연령제한)** 스트림을
|
||||
OAuth 세션엔 안 열어준다(“This video requires login”). 반면 **웹 쿠키 세션**엔 열어준다. 그래서 쿠키를 쓸 수 있는
|
||||
`yt-dlp`로 직접 오디오 URL을 뽑아 Lavalink `http` 소스로 재생하는 우회 경로를 둔다. 일반 영상은 계정을 태우지
|
||||
않도록 **익명 먼저 시도 → 실패 시에만 쿠키 재시도** 한다.
|
||||
유튜브가 스트리밍을 **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회.
|
||||
|
||||
---
|
||||
|
||||
@@ -152,10 +169,18 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
|
||||
## 7. 성능(재생 시작 지연) 메모
|
||||
|
||||
- **다음 곡 프리페치(prefetch)**: 현재 곡 재생 중 큐의 다음 곡을 미리 리졸브해 곡 전환 대기를 없앤다(`GuildPlayer`).
|
||||
- **첫 곡 프리워밍(prewarm) + 리졸버 요청 합치기(coalescing)**: 봇이 유튜브 URL을 받으면 음성채널 접속(~2초) *전에* 리졸브를 미리 걸어두고(`LavalinkManager.prewarmYoutubeUrl`), 리졸버는 같은 영상의 진행 중 요청을 하나로 합친다(`ytresolver` INFLIGHT). 실제 재생 요청이 새 `yt-dlp`를 띄우지 않고 prewarm 진행분에 붙으므로, 음성 접속 시간과 리졸브 시간이 겹쳐 **첫 재생이 ~2초 단축**된다(실측 콜드 3.8초 → prewarm 후 1.7초).
|
||||
- 캐시 키를 맞추려고 prewarm은 표준 watch URL(`https://www.youtube.com/watch?v=ID`)로 요청한다(= `resolvePlayable`이 쓰는 `info.uri`).
|
||||
- **음성접속 ↔ 곡 해석 병렬화**: `player_play`가 `channelJoin()`을 기다렸다 검색하던 직렬 구조를 분리했다.
|
||||
`LavalinkManager.resolveQuery()`(해석, 플레이어 불필요) + `addResolved()`(큐 반영)로 나눠, 해석을 먼저
|
||||
출발시키고 음성접속과 동시에 진행한다.
|
||||
- **첫 곡 프리워밍(prewarm) + 리졸버 요청 합치기(coalescing)**: 곡이 정해지는 즉시 `prewarmTrack()`이
|
||||
리졸브를 걸어두고, 리졸버는 같은 영상의 진행 중 요청을 하나로 합친다(`ytresolver` INFLIGHT). 실제 재생
|
||||
요청이 prewarm 진행분에 붙으므로 음성접속 시간 뒤로 리졸브가 숨는다.
|
||||
- 캐시 키를 맞추려고 prewarm은 `resolvePlayable`과 **동일한 대상 문자열**을 쓴다(youtube면 `info.uri`).
|
||||
- 리졸버는 리졸브된 URL을 30분 캐시한다(같은 곡 재생/재시도 시 즉시).
|
||||
- 남는 지연: `yt-dlp` 추출(~2.5초, 연령제한이면 쿠키 재시도로 더)과 음성 첫 접속 핸드셰이크는 제거 불가. 그래서 "완전 즉시"는 아니고 대략 7초 → 4~4.5초 수준.
|
||||
- **리졸브 자체 단축**: 내부 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초)이다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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()
|
||||
@@ -261,6 +261,8 @@ export class GuildPlayer {
|
||||
* 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;
|
||||
@@ -300,7 +302,9 @@ export class GuildPlayer {
|
||||
public async playNext() {
|
||||
const track = this.queue[0];
|
||||
if (!track) return this.end();
|
||||
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 } });
|
||||
|
||||
@@ -43,6 +43,11 @@ export class LavalinkManager {
|
||||
* 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: {
|
||||
@@ -102,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) {
|
||||
@@ -110,41 +210,13 @@ export class LavalinkManager {
|
||||
}
|
||||
player = player ?? this.getPlayer(guildId);
|
||||
if (!player) return;
|
||||
const { isUrl, text, flags } = parseLink(query.trim());
|
||||
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
|
||||
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
||||
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
|
||||
let searchText: string;
|
||||
if (isUrl) searchText = text;
|
||||
else if (flags.has('p')) searchText = (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
else searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
|
||||
const result = await node.rest.resolve(searchText);
|
||||
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
|
||||
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
|
||||
const resolved = await this.resolveQuery(query);
|
||||
if (!resolved) {
|
||||
// 노래 못찾았을때
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.PLAYLIST) {
|
||||
player.addTracks(
|
||||
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
|
||||
userId
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.TRACK) {
|
||||
player.addTrack(result.data, userId);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.SEARCH) {
|
||||
if (result.data.length === 0) {
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
player.addTrack(result.data[0], userId);
|
||||
return;
|
||||
}
|
||||
await this.addResolved(player, resolved, userId);
|
||||
}
|
||||
|
||||
public async youtubeSearch(query: string): Promise<Track[]> {
|
||||
|
||||
@@ -63,48 +63,78 @@ class RedisClientClass {
|
||||
thumbnail: video.info.artworkUrl ?? "",
|
||||
duration: video.info.length,
|
||||
}));
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
|
||||
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
|
||||
await this.reply(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
|
||||
Logger.log(`[Redis Pub] [reply] 결과 저장: (${resultKey})`);
|
||||
}
|
||||
if (data.action === "player_play") {
|
||||
const resultKey = `player:play:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
const guild = await getGuildById(data.serverId);
|
||||
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
|
||||
// 곡 해석(검색+Lavalink+yt-dlp 예열)은 음성접속과 무관하므로 먼저 출발시켜 둘을 겹친다.
|
||||
const t0 = Date.now();
|
||||
const resolving = lavalinkManager.resolveQuery(data.track.url)
|
||||
.catch((e) => { Logger.warn(`[player_play] 곡 해석 실패: ${String(e)}`); return null; });
|
||||
let player = lavalinkManager.getPlayer(guild.id);
|
||||
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
||||
if (!player) {
|
||||
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
|
||||
if (!voiceChannel) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
|
||||
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
||||
await resolving;
|
||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
|
||||
}
|
||||
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
|
||||
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
|
||||
const tJoined = Date.now();
|
||||
if (!player) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
|
||||
const resolved = await resolving;
|
||||
const tResolved = Date.now();
|
||||
if (!resolved) {
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
|
||||
}
|
||||
await lavalinkManager.addResolved(player, resolved, data.userId);
|
||||
Logger.log(`[player_play] 음성접속 ${tJoined - t0}ms / 곡해석 대기 ${tResolved - tJoined}ms / 큐반영까지 ${Date.now() - t0}ms`);
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
|
||||
}
|
||||
if (data.action === "player_playlist") {
|
||||
const resultKey = `player:playlist:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
const guild = await getGuildById(data.serverId);
|
||||
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
|
||||
// 재생목록 해석도 음성접속과 병렬로 진행한다.
|
||||
const resolvingPl = lavalinkManager.resolveQuery(data.playlistUrl)
|
||||
.catch((e) => { Logger.warn(`[player_playlist] 해석 실패: ${String(e)}`); return null; });
|
||||
let player = lavalinkManager.getPlayer(guild.id);
|
||||
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
||||
if (!player) {
|
||||
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
|
||||
if (!voiceChannel) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
|
||||
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
||||
await resolvingPl;
|
||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
|
||||
}
|
||||
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
|
||||
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
|
||||
if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
|
||||
const resolvedPl = await resolvingPl;
|
||||
if (!resolvedPl) {
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
|
||||
}
|
||||
await lavalinkManager.addResolved(player, resolvedPl, data.userId);
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
|
||||
}
|
||||
if (data.action === "player_now") {
|
||||
const resultKey = `player:now:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
const nowGuild = await getGuildById(data.serverId);
|
||||
if (!nowGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
if (!(await this.isMember(nowGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
|
||||
const player = lavalinkManager.getPlayer(data.serverId);
|
||||
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({
|
||||
await this.reply(resultKey, 60, JSON.stringify({
|
||||
success: true,
|
||||
botPlayer: !!player,
|
||||
isPlaying: player?.isPlaying,
|
||||
@@ -116,17 +146,19 @@ class RedisClientClass {
|
||||
}
|
||||
if (data.action === "queue_list") {
|
||||
const resultKey = `queue:list:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
const qlGuild = await getGuildById(data.serverId);
|
||||
if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
|
||||
const player = lavalinkManager.getPlayer(data.serverId);
|
||||
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
|
||||
}
|
||||
if (data.action === "queue_set") {
|
||||
const resultKey = `queue:set:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.newQueue) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.newQueue) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
|
||||
@@ -135,79 +167,85 @@ class RedisClientClass {
|
||||
for (const rawTrack of data.newQueue) {
|
||||
if (rawTrack.encoded) context.player.queue.push(rawTrack);
|
||||
}
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
context.player.setMsg();
|
||||
}
|
||||
if (data.action === "queue_remove") {
|
||||
const resultKey = `queue:remove:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// index 는 number(0 도 유효) — typeof 검증으로 변경.
|
||||
if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
|
||||
if (typeof data.index !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
const numIndex = Number(data.index);
|
||||
if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
|
||||
if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
|
||||
if (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
|
||||
if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
|
||||
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
|
||||
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
|
||||
if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
|
||||
if (numIndex >= context.player.queue.length - 1) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
|
||||
// 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인.
|
||||
// SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지.
|
||||
const removeTarget = context.player.queue[numIndex + 1];
|
||||
if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) {
|
||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." }));
|
||||
}
|
||||
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
context.player.setMsg();
|
||||
}
|
||||
if (data.action === "player_paused") {
|
||||
const resultKey = `player:paused:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
|
||||
if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
|
||||
if (typeof data.isPaused !== "boolean") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
await context.player.setPause();
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
|
||||
}
|
||||
if (data.action === "player_skip") {
|
||||
const resultKey = `player:skip:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (context.player.isPlaying) context.player.skip();
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
}
|
||||
if (data.action === "player_seek") {
|
||||
const resultKey = `player:seek:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
|
||||
if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
|
||||
if (typeof data.seek !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const duration = context.player.nowTrack.info.length || 0;
|
||||
const numSeek = Number(data.seek);
|
||||
if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
|
||||
if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
|
||||
if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
|
||||
if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
|
||||
if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
|
||||
if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
|
||||
context.player.seek(numSeek);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
}
|
||||
if (data.action === "player_volume") {
|
||||
const resultKey = `player:volume:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
|
||||
if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
|
||||
if (typeof data.volume !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const numVolume = Number(data.volume);
|
||||
if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
|
||||
if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
|
||||
if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
|
||||
if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
|
||||
if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
|
||||
if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
|
||||
context.player.setVolume(numVolume);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
||||
@@ -222,6 +260,17 @@ class RedisClientClass {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트로 보내는 RPC 응답 쓰기.
|
||||
* 기존 SET+폴링 대신, 사이트가 BRPOP 으로 블로킹 대기하도록 리스트에 LPUSH 한다.
|
||||
* ttlSec 는 사이트가 이미 타임아웃된 뒤 남는 고아 응답을 자동 정리하기 위한 보험.
|
||||
* (시그니처를 기존 setex(key, ttl, value) 와 동일하게 맞춰 호출부를 기계적으로 교체)
|
||||
*/
|
||||
private async reply(key: string, ttlSec: number, value: string): Promise<void> {
|
||||
await this.pub.lpush(key, value);
|
||||
await this.pub.expire(key, ttlSec);
|
||||
}
|
||||
|
||||
public publishState(event: string, data: any) {
|
||||
const payload = JSON.stringify({
|
||||
event,
|
||||
@@ -232,6 +281,16 @@ class RedisClientClass {
|
||||
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청한 userId가 해당 guild의 멤버인지 확인(캐시 우선, 없으면 단건 fetch).
|
||||
* 대시보드가 보낸 serverId를 그대로 신뢰하지 않기 위한 인가 검증.
|
||||
*/
|
||||
private async isMember(guild: Guild, userId: string): Promise<boolean> {
|
||||
if (guild.members.cache.has(userId)) return true;
|
||||
const fetched = await guild.members.fetch(userId).catch(() => null);
|
||||
return !!fetched;
|
||||
}
|
||||
|
||||
private async getContext(guildId: string, resultKey: string, userId: string): Promise<{
|
||||
ok: true;
|
||||
guild: Guild;
|
||||
@@ -240,20 +299,29 @@ class RedisClientClass {
|
||||
} | { ok: false; }> {
|
||||
const guild = await getGuildById(guildId);
|
||||
if (!guild) {
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||
return { ok: false };
|
||||
}
|
||||
// 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단)
|
||||
if (!(await this.isMember(guild, userId))) {
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
|
||||
return { ok: false };
|
||||
}
|
||||
let player = lavalinkManager.getPlayer(guild.id);
|
||||
const voiceChannel = await getVoiceChannelById(guild, userId);
|
||||
if (!player) {
|
||||
if (!voiceChannel) {
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
|
||||
return { ok: false };
|
||||
}
|
||||
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
||||
// 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
|
||||
return { ok: false };
|
||||
}
|
||||
if (!player) {
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
|
||||
return { ok: false };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { client, handler, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { DB } from "../utils/Database";
|
||||
import { channelRegister } from "../commands/channel";
|
||||
import { runSelfTest } from "../utils/SelfTest";
|
||||
|
||||
export const clientReady = async () => {
|
||||
if (!client.user) return;
|
||||
@@ -20,6 +21,9 @@ export const clientReady = async () => {
|
||||
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
|
||||
// [실험 전용] 환경변수가 있을 때만 동작. 프로덕션에는 변수 미설정이라 아무 일도 안 한다.
|
||||
runSelfTest();
|
||||
|
||||
if (!Config.dev) return;
|
||||
try {
|
||||
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
||||
|
||||
@@ -106,6 +106,11 @@ export const Config = {
|
||||
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
|
||||
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
|
||||
|
||||
// ytresolver(yt-dlp) 사용 여부. false 면 Lavalink youtube-source(poToken 주입)로 직접 재생한다.
|
||||
// 첫 재생에서 yt-dlp 단계(콜드 3.6~4.2s)를 통째로 없애지만, 집 IP의 SABR/403 차단에
|
||||
// 다시 걸릴 수 있어 실험용 플래그로 둔다. 미설정 시 기존 동작(true) 유지.
|
||||
ytresolverEnabled: (process.env.YTRESOLVER_ENABLED?.trim()?.toLocaleLowerCase() ?? "true") !== "false",
|
||||
|
||||
proxyUrl: process.env.PROXY_URL?.trim() || "",
|
||||
|
||||
_redis: {
|
||||
|
||||
95
bot/src/utils/SelfTest.ts
Normal file
95
bot/src/utils/SelfTest.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { client, lavalinkManager } from "../index";
|
||||
import { channelJoin } from "../commands/join";
|
||||
import { channelRegister } from "../commands/channel";
|
||||
import { Logger } from "./Logger";
|
||||
|
||||
/**
|
||||
* [실험 전용] poToken 직결 재생 검증용 자가 테스트.
|
||||
*
|
||||
* SELFTEST_GUILD_ID / SELFTEST_VOICE_CHANNEL_ID 가 설정되면, 봇이 스스로 음성채널에 들어가
|
||||
* 한 곡을 재생한 뒤 실제로 오디오가 흐르는지(position 이 증가하는지) 확인해 결과를 로그로 남긴다.
|
||||
* 사람이 음성채널에 없어도 검증할 수 있게 하기 위한 것으로, 실험 브랜치에서만 사용한다.
|
||||
*/
|
||||
export async function runSelfTest() {
|
||||
const guildId = process.env.SELFTEST_GUILD_ID?.trim();
|
||||
const voiceChannelId = process.env.SELFTEST_VOICE_CHANNEL_ID?.trim();
|
||||
const query = process.env.SELFTEST_QUERY?.trim() || "아이유 좋은 날";
|
||||
if (!guildId || !voiceChannelId) return;
|
||||
|
||||
try {
|
||||
Logger.info(`[SelfTest] 시작: guild=${guildId} voice=${voiceChannelId} query="${query}"`);
|
||||
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) return Logger.error(`[SelfTest] guild 를 찾을 수 없습니다: ${guildId}`);
|
||||
|
||||
// Lavalink 노드 연결을 기다린다(연결 전이면 channelJoin 이 실패한다).
|
||||
for (let i = 0; i < 60 && !lavalinkManager.hasReadyNode(); i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!lavalinkManager.hasReadyNode()) return Logger.error(`[SelfTest] Lavalink 노드 연결 대기 실패`);
|
||||
|
||||
// 음악 텍스트 채널이 등록돼 있어야 재생 세션을 만들 수 있다.
|
||||
const textChannelId = process.env.SELFTEST_TEXT_CHANNEL_ID?.trim();
|
||||
if (textChannelId) {
|
||||
const embed = await channelRegister(guild, textChannelId);
|
||||
Logger.info(`[SelfTest] 음악채널 등록: ${embed.data.title ?? "-"}`);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const { player } = await channelJoin(guild, voiceChannelId);
|
||||
if (!player) return Logger.error(`[SelfTest] 음성채널 참가 실패`);
|
||||
const tJoin = Date.now();
|
||||
Logger.info(`[SelfTest] 음성접속 완료 ${tJoin - t0}ms`);
|
||||
|
||||
await lavalinkManager.search(guild.id, query, client.user?.id ?? "selftest", player);
|
||||
const tPlay = Date.now();
|
||||
Logger.info(`[SelfTest] 재생 요청까지 ${tPlay - t0}ms (검색+해석+리졸브 ${tPlay - tJoin}ms)`);
|
||||
|
||||
// 실제 오디오가 흐르는지 확인: position 이 시간에 따라 증가해야 한다.
|
||||
// SELFTEST_WATCH_SEC 로 관찰 시간을 늘리면 장시간 재생 안정성(중간 끊김)을 검증할 수 있다.
|
||||
const watchSec = Number(process.env.SELFTEST_WATCH_SEC?.trim() || "9");
|
||||
// Lavalink 는 position 을 약 5초 주기로 보고하므로 그 주기에 맞춰 샘플링해야 오탐이 없다.
|
||||
const stepMs = 5000;
|
||||
const steps = Math.max(3, Math.round((watchSec * 1000) / stepMs));
|
||||
const samples: number[] = [];
|
||||
let stallCount = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await new Promise((r) => setTimeout(r, stepMs));
|
||||
const pos = player.position;
|
||||
if (samples.length > 0 && pos <= samples[samples.length - 1]) stallCount++;
|
||||
samples.push(pos);
|
||||
if (i % 3 === 0 || i === steps - 1) {
|
||||
Logger.log(`[SelfTest] t+${((i + 1) * stepMs / 1000).toFixed(0)}s position=${pos} isPlaying=${player.isPlaying} track="${player.nowTrack?.info?.title ?? "-"}"`);
|
||||
}
|
||||
}
|
||||
const advanced = samples[samples.length - 1] > samples[0] && samples[samples.length - 1] > 0;
|
||||
if (advanced) {
|
||||
Logger.ready(`[SelfTest] 결과: 재생 성공 ✅ (${(watchSec).toFixed(0)}초 관찰, position ${samples[0]} → ${samples[samples.length - 1]}, 멈춤구간 ${stallCount}/${steps - 1}, 첫 소리까지 약 ${tPlay - t0}ms)`);
|
||||
} else {
|
||||
Logger.error(`[SelfTest] 결과: 재생 실패 ❌ (position 이 진행하지 않음: ${samples.slice(0, 6).join(", ")})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 곡 전환 검증: 두 번째 곡을 큐에 넣고 스킵해서 실제로 다음 곡이 재생되는지 본다.
|
||||
const secondQuery = process.env.SELFTEST_SECOND_QUERY?.trim();
|
||||
if (secondQuery) {
|
||||
const firstTitle = player.nowTrack?.info?.title ?? "";
|
||||
await lavalinkManager.search(guild.id, secondQuery, client.user?.id ?? "selftest", player);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const tSkip = Date.now();
|
||||
player.skip();
|
||||
let switched = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const cur = player.nowTrack?.info?.title ?? "";
|
||||
if (cur && cur !== firstTitle && player.position > 0) { switched = true; break; }
|
||||
}
|
||||
if (switched) {
|
||||
Logger.ready(`[SelfTest] 곡전환: 성공 ✅ ("${firstTitle.slice(0, 25)}" → "${(player.nowTrack?.info?.title ?? "").slice(0, 25)}", ${Date.now() - tSkip}ms)`);
|
||||
} else {
|
||||
Logger.error(`[SelfTest] 곡전환: 실패 ❌ (다음 곡이 재생되지 않음)`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[SelfTest] 예외: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -126,5 +126,35 @@ export const Spotify = {
|
||||
const track = (await this.getSearchFull(query) ?? [])?.[0];
|
||||
if (track?.url) searchCache.set(lowerQuery, track.url);
|
||||
return track?.url ?? null;
|
||||
},
|
||||
|
||||
/** open.spotify.com/track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
|
||||
parseTrackId(text: string): string | null {
|
||||
const m = text.trim().match(/(?:open\.spotify\.com\/track\/|spotify:track:)([A-Za-z0-9]+)/);
|
||||
return m ? m[1] : null;
|
||||
},
|
||||
|
||||
/** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */
|
||||
async getTrackById(id: string): Promise<{ title: string; artist: string } | null> {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
if (!token) return null;
|
||||
const response = await fetch(`${SPOTIFY_API_URL}/tracks/${encodeURIComponent(id)}?market=KR`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
Logger.error(`스포티파이 트랙 조회 에러 (${response.status})`);
|
||||
return null;
|
||||
}
|
||||
const track = await response.json() as SpotifyApi.TrackObjectFull;
|
||||
if (!track?.name) return null;
|
||||
return {
|
||||
title: track.name,
|
||||
artist: (track.artists ?? []).map((a) => a.name).join(", "),
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.error(`스포티파이 트랙 조회 실패: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
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"]
|
||||
21
page/docker-compose.example.yml
Normal file
21
page/docker-compose.example.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# music_bot_v2 대시보드(page/) 배포 예시.
|
||||
# 운영 .5 호스트에서는 /root/bot/ 아래에 이 내용을 두고 `docker compose -f ... up -d --build` 로 띄운다.
|
||||
#
|
||||
# ⚠️ 로그인(Discord OAuth) 전제조건:
|
||||
# Discord 개발자 포털 → music_bot 앱(APPID 1412338882934079498) → OAuth2 → Redirects 에
|
||||
# "<NEXTAUTH_URL>/api/auth/callback/discord" 를 반드시 등록해야 로그인이 된다.
|
||||
# (테스트 포트로 띄우면 예: http://192.168.10.5:2960/api/auth/callback/discord)
|
||||
services:
|
||||
music_site_test:
|
||||
build: ./music_bot_v2/page # (테스트 시엔 page-improvements 브랜치 체크아웃 상태)
|
||||
container_name: music_site_test
|
||||
restart: always
|
||||
ports:
|
||||
- "2960:3000" # 테스트용 포트(다른 사이트와 겹치지 않게)
|
||||
environment:
|
||||
- DISCORD_CLIENT_ID=1412338882934079498
|
||||
- DISCORD_CLIENT_SECRET=<music_bot 앱 client_secret>
|
||||
- NEXTAUTH_SECRET=<openssl rand -base64 32 로 생성>
|
||||
- NEXTAUTH_URL=http://192.168.10.5:2960
|
||||
- REDIS_HOST=192.168.10.7 # 운영 봇과 같은 Redis(실봇 제어·상태 테스트). 격리 원하면 별도 Redis.
|
||||
- REDIS_PORT=6379
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
interface QueueRemoveBody {
|
||||
serverId?: unknown;
|
||||
index?: unknown;
|
||||
encoded?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -37,6 +38,8 @@ export async function POST(request: Request) {
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
index: indexResult.value,
|
||||
// 인덱스-트랙 일치 검증용(봇이 대상 encoded 불일치 시 거절). 문자열일 때만 전달.
|
||||
encoded: typeof bodyResult.data.encoded === "string" ? bodyResult.data.encoded : undefined,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ export async function GET(request: Request) {
|
||||
query,
|
||||
},
|
||||
timeoutMs: 10000,
|
||||
pollIntervalMs: 250,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function MainContent({
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id, userId };
|
||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
|
||||
if (actionType === 'player_play') {
|
||||
endpoint = "/api/player/play";
|
||||
bodyData.track = track;
|
||||
@@ -108,8 +108,16 @@ export default function MainContent({
|
||||
setIsFetching(true);
|
||||
const cached = sessionStorage.getItem("filtered_servers");
|
||||
if (cached) {
|
||||
setServers(JSON.parse(cached));
|
||||
setIsFetching(false);
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (Array.isArray(parsed)) {
|
||||
setServers(parsed);
|
||||
setIsFetching(false);
|
||||
}
|
||||
} catch {
|
||||
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
|
||||
sessionStorage.removeItem("filtered_servers");
|
||||
}
|
||||
}
|
||||
|
||||
fetch("/api/servers")
|
||||
@@ -162,7 +170,7 @@ export default function MainContent({
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||
<div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||
<button
|
||||
|
||||
@@ -22,6 +22,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const [position, setPosition] = useState<number>(0);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
|
||||
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
|
||||
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
|
||||
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
||||
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
||||
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
|
||||
|
||||
@@ -38,7 +43,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -52,7 +56,9 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
setVolume(typeof data.volume === "number" ? data.volume : 50);
|
||||
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
||||
if (!isDragging.current) {
|
||||
setPosition(data.position || 0);
|
||||
const p = data.position || 0;
|
||||
setPosition(p);
|
||||
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
|
||||
}
|
||||
} else {
|
||||
setTrack(null);
|
||||
@@ -84,15 +90,16 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("Player SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
return () => eventSource.close();
|
||||
}, [selectedServer, fetchNowPlaying]);
|
||||
|
||||
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
|
||||
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
|
||||
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
|
||||
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
|
||||
const isPausedRef = useRef(isPaused);
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused;
|
||||
@@ -100,25 +107,20 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||
const localInterval = setInterval(() => {
|
||||
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
|
||||
const tick = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
setPosition((prev) => {
|
||||
if (prev >= duration) return duration;
|
||||
return prev + 1000;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||
const a = anchorRef.current;
|
||||
const next = a.pos + (Date.now() - a.at);
|
||||
setPosition(duration > 0 ? Math.min(duration, next) : next);
|
||||
}, 250);
|
||||
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
|
||||
const syncInterval = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
fetchNowPlaying();
|
||||
}, 10000);
|
||||
|
||||
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||
}, 15000);
|
||||
return () => {
|
||||
clearInterval(localInterval);
|
||||
clearInterval(tick);
|
||||
clearInterval(syncInterval);
|
||||
};
|
||||
}, [isPlaying, duration, fetchNowPlaying]);
|
||||
@@ -135,13 +137,13 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const nextPaused = !isPaused;
|
||||
// UI 즉각 반영 (Optimistic UI)
|
||||
setIsPaused(nextPaused);
|
||||
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
|
||||
try {
|
||||
const res = await fetch('/api/player/pause', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
isPaused: nextPaused, // boolean 그대로 전송
|
||||
})
|
||||
});
|
||||
@@ -170,7 +172,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
|
||||
@@ -189,6 +190,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
|
||||
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
||||
const newPosition = Number(e.currentTarget.value);
|
||||
setPosition(newPosition);
|
||||
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
|
||||
|
||||
try {
|
||||
await fetch('/api/player/seek', {
|
||||
@@ -197,7 +200,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
seek: newPosition,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -221,17 +223,19 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
setVolume(finalVolume); // UI 즉시 반영
|
||||
|
||||
try {
|
||||
await fetch('/api/player/volume', {
|
||||
const res = await fetch('/api/player/volume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
volume: finalVolume,
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
|
||||
} catch (error) {
|
||||
console.error("볼륨 조절 에러:", error);
|
||||
setVolume(prevVolumeRef.current); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,8 +353,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
value={volume}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
||||
onMouseDown={() => setIsVolumeDragging(true)}
|
||||
onTouchStart={() => setIsVolumeDragging(true)}
|
||||
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
|
||||
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
|
||||
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||
|
||||
@@ -70,9 +70,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -142,8 +142,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
|
||||
const handleDelete = async (indexToRemove: number) => {
|
||||
if (!selectedServer) return;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
|
||||
const targetEncoded = queue[indexToRemove]?.encoded;
|
||||
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
||||
setQueue(newQueue);
|
||||
|
||||
@@ -153,8 +154,8 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
index: indexToRemove,
|
||||
encoded: targetEncoded,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -185,7 +186,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.id || index}-${item.info?.title || ''}`}
|
||||
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragItem.current = index;
|
||||
|
||||
@@ -25,35 +25,23 @@ export async function requireSession(): Promise<
|
||||
}
|
||||
|
||||
// ========== 봇 RPC 헬퍼 ==========
|
||||
// site → bot: Redis Pub/Sub 으로 명령 전송
|
||||
// bot → site: Redis SET 으로 결과 저장 (resultKey)
|
||||
// 사이트는 resultKey 를 short polling 으로 확인.
|
||||
// site → bot: Redis Pub/Sub 으로 명령 전송 (채널 "site-bot")
|
||||
// bot → site: 봇이 결과를 resultKey 리스트에 LPUSH
|
||||
// 사이트는 resultKey 를 BRPOP 으로 블로킹 대기한다(폴링 없음 → 응답 즉시, 헛 GET 제거).
|
||||
|
||||
export interface BotRpcOptions {
|
||||
/** Redis 결과 키 prefix (e.g. "player:now") */
|
||||
channel: string;
|
||||
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
|
||||
payload: Record<string, unknown>;
|
||||
/** 폴링 총 타임아웃 (ms). 기본 3000. */
|
||||
/** 응답 대기 타임아웃 (ms). 기본 3000. */
|
||||
timeoutMs?: number;
|
||||
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
|
||||
pollIntervalMs?: number;
|
||||
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
|
||||
resultTtlSec?: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export async function botRpc(
|
||||
opts: BotRpcOptions,
|
||||
): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const {
|
||||
channel,
|
||||
payload,
|
||||
timeoutMs = 3000,
|
||||
pollIntervalMs = 100,
|
||||
resultTtlSec = 5,
|
||||
} = opts;
|
||||
const { channel, payload, timeoutMs = 3000 } = opts;
|
||||
|
||||
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
|
||||
const requestId = `req:${randomUUID()}`;
|
||||
@@ -65,19 +53,24 @@ export async function botRpc(
|
||||
JSON.stringify({ ...payload, requestId }),
|
||||
);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let interval = pollIntervalMs;
|
||||
// BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록
|
||||
// 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다.
|
||||
// (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음)
|
||||
const waiter = Redis.duplicate();
|
||||
// BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달.
|
||||
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval);
|
||||
interval = Math.min(interval * 2, 400);
|
||||
|
||||
const reply = await Redis.get(resultKey);
|
||||
if (!reply) continue;
|
||||
|
||||
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
|
||||
await Redis.del(resultKey);
|
||||
try {
|
||||
// brpop → [key, value] | null(타임아웃)
|
||||
const popped = await waiter.brpop(resultKey, timeoutSec);
|
||||
if (!popped) {
|
||||
return {
|
||||
status: 504,
|
||||
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
|
||||
};
|
||||
}
|
||||
|
||||
const reply = popped[1];
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(reply);
|
||||
@@ -92,20 +85,10 @@ export async function botRpc(
|
||||
const hasSuccessField = "success" in parsed;
|
||||
const ok = !hasSuccessField || parsed.success === true;
|
||||
return { status: ok ? 200 : 400, body: parsed };
|
||||
} finally {
|
||||
// 전용 커넥션 정리 (블로킹 중이면 즉시 끊기)
|
||||
waiter.disconnect();
|
||||
}
|
||||
|
||||
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
|
||||
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
|
||||
try {
|
||||
await Redis.expire(resultKey, resultTtlSec);
|
||||
} catch {
|
||||
// 무시: 정리 실패는 치명적 아님
|
||||
}
|
||||
|
||||
return {
|
||||
status: 504,
|
||||
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
|
||||
};
|
||||
}
|
||||
|
||||
// ========== POST 본문 파싱 ==========
|
||||
|
||||
@@ -17,12 +17,47 @@ interface BotEventStreamOptions {
|
||||
clientEventType?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 공유 구독자(fan-out): "bot-site" 채널은 프로세스당 구독자 1개만 두고,
|
||||
// 메모리에서 리스너들에게 분배한다. (SSE 연결마다 Redis 커넥션을 복제하지 않음)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
interface Listener {
|
||||
guildId: string;
|
||||
botEventName: string;
|
||||
send: () => void;
|
||||
}
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
const globalForSse = global as unknown as { botSiteSubscriber?: ReturnType<typeof Redis.duplicate> };
|
||||
|
||||
function ensureSubscriber() {
|
||||
if (globalForSse.botSiteSubscriber) return;
|
||||
const sub = Redis.duplicate();
|
||||
globalForSse.botSiteSubscriber = sub;
|
||||
sub.on("error", (err) => Logger.error(`[SSE] shared subscriber error: ${err.message}`));
|
||||
sub.subscribe("bot-site").catch((err) => Logger.error(`[SSE] subscribe 실패: ${String(err)}`));
|
||||
sub.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site") return;
|
||||
let data: BotEvent;
|
||||
try {
|
||||
data = JSON.parse(message) as BotEvent;
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE] 잘못된 JSON: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
for (const l of listeners) {
|
||||
if (data.guildId === l.guildId && data.event === l.botEventName) {
|
||||
try { l.send(); } catch { /* 개별 클라이언트 전송 실패는 무시 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇이 publish 하는 "bot-site" 채널을 구독해서 SSE 로 흘려보내는 공용 핸들러.
|
||||
* 봇이 publish 하는 "bot-site" 채널을 공유 구독자로 받아 SSE 로 흘려보내는 공용 핸들러.
|
||||
* - 인증 가드: 세션 없으면 401
|
||||
* - serverId 검증
|
||||
* - JSON.parse 안전 처리
|
||||
* - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리
|
||||
* - 공유 구독자 + 메모리 fan-out (연결마다 Redis 커넥션 복제 안 함)
|
||||
* - keepalive ping (30초)
|
||||
*/
|
||||
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
|
||||
@@ -38,83 +73,41 @@ export async function botEventStream(req: NextRequest, opts: BotEventStreamOptio
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const subscriber = Redis.duplicate();
|
||||
let closed = false;
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
ensureSubscriber();
|
||||
|
||||
const cleanup = async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const t of timers) clearInterval(t);
|
||||
try {
|
||||
await subscriber.unsubscribe("bot-site");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
await subscriber.quit();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* 이미 닫혔을 수 있음 */
|
||||
}
|
||||
clearInterval(ping);
|
||||
listeners.delete(listener);
|
||||
try { controller.close(); } catch { /* 이미 닫힘 */ }
|
||||
};
|
||||
|
||||
subscriber.on("error", async (err) => {
|
||||
Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
try {
|
||||
await subscriber.subscribe("bot-site");
|
||||
} catch (err) {
|
||||
Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site" || closed) return;
|
||||
let data: BotEvent;
|
||||
try {
|
||||
data = JSON.parse(message) as BotEvent;
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (data.guildId !== serverId) return;
|
||||
if (data.event !== opts.botEventName) return;
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`),
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`);
|
||||
void cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지)
|
||||
timers.push(
|
||||
setInterval(() => {
|
||||
const listener: Listener = {
|
||||
guildId: serverId,
|
||||
botEventName: opts.botEventName,
|
||||
send: () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`));
|
||||
} catch {
|
||||
void cleanup();
|
||||
}
|
||||
}, 30000),
|
||||
);
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`));
|
||||
},
|
||||
};
|
||||
listeners.add(listener);
|
||||
|
||||
// 클라이언트가 연결을 끊으면 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
void cleanup();
|
||||
});
|
||||
const ping = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keep-alive\n\n`));
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
req.signal.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,104 @@ HTTP API:
|
||||
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
|
||||
|
||||
@@ -55,6 +153,26 @@ def first_url(out):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user