Compare commits
1 Commits
experiment
...
1021a61ead
| Author | SHA1 | Date | |
|---|---|---|---|
| 1021a61ead |
@@ -1,72 +0,0 @@
|
|||||||
"""
|
|
||||||
[실험 후보] 유튜브 내부 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"]
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
#!/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,8 +261,6 @@ export class GuildPlayer {
|
|||||||
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
|
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
|
||||||
private async resolvePlayable(track: QueueTrack): Promise<string> {
|
private async resolvePlayable(track: QueueTrack): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// 실험: ytresolver 비활성화 시 Lavalink youtube-source(poToken)로 직접 재생 — yt-dlp 단계 생략.
|
|
||||||
if (!Config.ytresolverEnabled) return track.encoded;
|
|
||||||
const info: any = (track as any).info ?? {};
|
const info: any = (track as any).info ?? {};
|
||||||
const src = info.sourceName;
|
const src = info.sourceName;
|
||||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
|
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ export class LavalinkManager {
|
|||||||
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
|
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
|
||||||
*/
|
*/
|
||||||
private refreshPoToken(): void {
|
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 script = join(__dirname, "..", "..", "scripts", "potoken-refresh.mjs");
|
||||||
const child = spawn(process.execPath, [script], {
|
const child = spawn(process.execPath, [script], {
|
||||||
env: {
|
env: {
|
||||||
@@ -122,9 +117,9 @@ export class LavalinkManager {
|
|||||||
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
||||||
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
|
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
|
||||||
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
|
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
|
||||||
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
|
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 끝까지 기다린 뒤 다시
|
||||||
// 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
|
// ytsearch(~0.5초)를 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도
|
||||||
// (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
|
// 추가 대기가 없다. (적중 시엔 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
|
||||||
let searchText: string;
|
let searchText: string;
|
||||||
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
|
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
|
||||||
if (isUrl) searchText = text;
|
if (isUrl) searchText = text;
|
||||||
@@ -192,7 +187,6 @@ export class LavalinkManager {
|
|||||||
*/
|
*/
|
||||||
public prewarmTrack(track: Track) {
|
public prewarmTrack(track: Track) {
|
||||||
try {
|
try {
|
||||||
if (!Config.ytresolverEnabled) return; // yt-dlp 미사용 실험 모드에서는 예열 자체가 불필요
|
|
||||||
const info: any = (track as any).info ?? {};
|
const info: any = (track as any).info ?? {};
|
||||||
const src = info.sourceName;
|
const src = info.sourceName;
|
||||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
|
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { client, handler, Redis } from "../index";
|
|||||||
import { Logger } from "../utils/Logger";
|
import { Logger } from "../utils/Logger";
|
||||||
import { DB } from "../utils/Database";
|
import { DB } from "../utils/Database";
|
||||||
import { channelRegister } from "../commands/channel";
|
import { channelRegister } from "../commands/channel";
|
||||||
import { runSelfTest } from "../utils/SelfTest";
|
|
||||||
|
|
||||||
export const clientReady = async () => {
|
export const clientReady = async () => {
|
||||||
if (!client.user) return;
|
if (!client.user) return;
|
||||||
@@ -21,9 +20,6 @@ export const clientReady = async () => {
|
|||||||
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
|
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [실험 전용] 환경변수가 있을 때만 동작. 프로덕션에는 변수 미설정이라 아무 일도 안 한다.
|
|
||||||
runSelfTest();
|
|
||||||
|
|
||||||
if (!Config.dev) return;
|
if (!Config.dev) return;
|
||||||
try {
|
try {
|
||||||
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
||||||
|
|||||||
@@ -106,11 +106,6 @@ export const Config = {
|
|||||||
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
|
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
|
||||||
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
|
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() || "",
|
proxyUrl: process.env.PROXY_URL?.trim() || "",
|
||||||
|
|
||||||
_redis: {
|
_redis: {
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
Reference in New Issue
Block a user