Compare commits

...

10 Commits

Author SHA1 Message Date
5811c16c20 perf(search): 유튜브뮤직 검색과 ytsearch 폴백을 병렬 실행 (실험 브랜치)
측정으로 확인한 낭비: YoutubeMusic.getSearchUrl 적중률이 6/10(평균 548ms)이라,
빗나가는 40% 에서는 YTM 을 끝까지 기다린 뒤 다시 ytsearch(~0.5초)를 도는 순차 대기가
통째로 버려지고 있었다.

변경: resolveQuery 기본 경로에서 ytsearch 폴백 검색을 먼저 띄워두고 YTM 결과를 기다린다.
  - YTM 적중  -> 폴백 결과는 버리고 기존과 동일하게 동작(Lavalink 검색 1회가 헛돌 뿐)
  - YTM 빗나감 -> 이미 진행 중인 폴백 결과를 그대로 사용(추가 대기 0)
  - 버려지는 폴백 promise 는 catch 를 달아 unhandledRejection 방지
  - " Topic" 중복 부가 방지 조건 추가

실측 A/B(YTM 이 빗나가는 3곡, 검색+해석+리졸브 구간):
  기존 1500 / 1688 / 1391ms  ->  신규 1224 / 1180 / 1399ms
  재생은 3곡 모두 성공, 관찰 중 멈춤 0.
남은 병목: 음성접속 핸드셰이크(0.5~1.3초)는 디스코드 왕복이라 단축 여지가 거의 없다.
2026-09-18 20:02:16 +09:00
fed948ca02 Merge branch 'master' into experiment/potoken-direct 2026-09-18 19:54:46 +09:00
ef16b67839 experiment: 내부 API(VISIONOS) 리졸버 상세 검증 — 전 케이스 통과, 32분 연속재생 무결
운영 리졸버 + 내부API 0순위 조합을 테스트 포트(8780)에 올리고, 운영 Lavalink(.6)로
실제 재생을 검증했다. 프로덕션(8779/Lavalink/master)은 변경하지 않았다.

케이스별 결과(첫 소리 / 관찰 중 멈춤):
  짧은영상 7초     성공   888ms  (재생 후 정상 종료. 관찰창이 곡보다 길어 판정 오탐 1회 발생)
  짧은영상 18초    성공  1437ms  0/2
  일반곡(K-pop)    성공  2017ms  0/3
  해외곡           성공  4209ms  0/3
  스포티파이 URL   성공  2753ms  0/3
  플레이리스트120  성공  3147ms  0/3
  곡 전환          성공  2982ms  0/2 (Ditto -> HEYA, 6.0s)
  긴 곡(1시간)     성공  1430ms  0/3
  32분 연속재생    성공   888ms  0/383  <- position 5,180 -> 1,920,180ms 정확히 선형 증가
리졸버 경로 통계: 내부API 11회 / 폴백 0회 / 내부API 실패 0회

참고: SABR 직결은 55초에서 403으로 끊겼던 반면, 이 방식은 32분간 무결. 포맷도 itag 251 동일.
남은 과제(운영 적용 전): visitorData 를 봇이 2시간마다 갱신하는 값과 공유하도록 배선.
현재는 고정 파일(/tmp/vd.txt)을 읽는 프로토타입이라 만료 시 폴백으로 떨어진다.
연령제한곡은 샘플 확보 실패로 미검증(폴백 경로가 쿠키로 처리).
2026-09-17 23:33:11 +09:00
3c6dbc9631 experiment: 내부 API 리졸버 후보 구현 채움(직전 커밋에 빈 파일이 올라감) 2026-09-17 19:07:16 +09:00
e7283ae862 experiment: 더 빠른 경로 발견 — 유튜브 내부 API(VISIONOS) 직접 호출로 리졸브 0.3초
yt-dlp 호출 자체를 없애는 경로를 탐색해 실측했다.

[탐색 과정]
- 내부 player API 를 클라이언트별로 호출해 비교:
  ANDROID_VR/TVHTML5/WEB_EMBEDDED = LOGIN_REQUIRED·ERROR, WEB = SABR 전용(직접 URL 없음)
  IOS = 0.32s 로 직접 URL(itag 140) 획득했으나 뒷부분 range 요청이 403 → 이어받기 불가
- 정상 재생되는 yt-dlp URL 의 c= 파라미터가 VISIONOS 임을 확인하고 동일 클라이언트로 재현.
  visitorData 없이 호출하면 LOGIN_REQUIRED, 넣으면 status=OK.

[결과] VISIONOS + visitorData 직접 호출
- 추출 0.30~0.38s (현행 상주 리졸버 1.7~2.3s, 기존 subprocess 4.2~4.7s)
- itag 251 로 yt-dlp 결과와 동일 포맷, Lavalink loadType=track
- range 0 / 1MB / 2MB / 3MB 전부 206 → 이어받기 정상(SABR·IOS 와 다름)
- 실제 재생: 90초 끊김 0/17, 일반곡·곡전환 성공. 첫 소리 2.0~2.3s
- 리졸버에 0순위로 넣고 실패 시 상주 lib → subprocess 로 폴백(경로 통계 innertube 5 / lib 1 / 실패 0)

미해결/주의: visitorData 를 정적 파일에서 읽는 프로토타입이라, 운영 적용 시 봇이 2시간마다
생성하는 visitorData 를 공유받도록 배선해야 한다. 검색어(비 URL) 입력은 영상 ID 가 없어
기존 경로로 폴백한다. 연령제한곡은 미검증(폴백 경로가 쿠키로 처리).
프로덕션(리졸버 8779, Lavalink, master)은 변경하지 않았다.
2026-09-17 19:06:47 +09:00
5d5bf9b3f3 experiment: SABR 끊김은 업스트림 미완성으로 해결 불가 / 대안 '상주 리졸버' 검증 성공
[1] 재생 중 끊김(~55초) 해결 시도 — 전부 실패
  - poToken 정상 주입 + 프로덕션 동일 설정 : 55초 끊김 동일
  - 클라이언트 WEB 단독(SABR 경로 강제)    : 55초 끊김 동일(decode 오류)
  - 프록시(.4:3128) 제거                   : 55초 끊김 동일
  원인: 첫 청크 재생 후 '이어받기' 요청이 실패(정적 경로 403 / SABR 경로 decode).
  feat/sabr-support 는 열린 PR #229, 마지막 커밋 2026-07-27 로 미병합 진행 중 기능.
  => 우리 쪽 설정으로 고칠 수 없음. SABR 직결 보류 확정.

[2] 새 방식: yt-dlp 를 '상주 라이브러리'로 호출하는 리졸버(프로토타입 첨부)
  - zipapp(/usr/local/bin/yt-dlp) 을 sys.path 에 넣어 import, YoutubeDL 인스턴스 1회 생성
  - 매 요청 프로세스 기동(0.40s) 제거 + player_client=default 단독
  - 실패 시 기존 subprocess 경로로 자동 폴백(안전)
  리졸브 실측: 1.58~2.30s (기존 subprocess 3곡 평균 4.2~4.7s)
  재생 검증(프로덕션 Lavalink .6 + 이 리졸버):
    일반곡 2656ms / 해외곡 2968ms / 직접URL 1900ms / 곡전환 성공
    90초 장시간: 85초 진행, 끊김 0/17  ← SABR(55초 실패) 대비 안정
프로덕션(리졸버 8779, Lavalink, master)은 변경하지 않음. 테스트는 8780 포트로만 수행.
2026-09-17 15:29:21 +09:00
7f057d818c experiment: SABR 빌드 확대 검증 — 첫 재생은 빠르나 ~55초 뒤 403으로 끊김(적용 불가)
SelfTest 확장: 관찰시간(SELFTEST_WATCH_SEC), 곡전환(SELFTEST_SECOND_QUERY) 지원.
position 샘플링 주기를 Lavalink 보고 주기(5초)에 맞춰 멈춤 오탐 제거.

격리 Lavalink(.9:2334, SABR 빌드) + ytresolver OFF 결과:
  일반곡(K-pop)      성공  첫소리 1455ms  20초 관찰 끊김 0
  해외곡             성공  첫소리 1696ms  20초 관찰 끊김 0
  직접 URL           성공  첫소리  932ms  20초 관찰 끊김 0
  긴 곡(1시간)       성공  첫소리 3865ms  20초 관찰 끊김 0
  플레이리스트(120곡) 성공  첫소리 5656ms  20초 관찰 끊김 0
  곡 전환            성공  (Ditto → HEYA, 6.0s)
  라이브 스트림      실패  ※ 현행 방식(1.18.2+ytresolver)도 동일 실패 → 기존 한계
  장시간 90초        실패  50초 재생 후 정지, 2곡 재현
장시간 실패 원인: 재생 중 googlevideo 403
  (PersistentHttpStream.connect → "Not success status code: 403")
대조군(현행 방식) 동일 90초 테스트: 85초 진행, 끊김 0 → 현행이 안정적.

결론: SABR 빌드는 첫 재생이 약 3초 빠르지만 곡 길이(3~4분)를 못 버티므로 적용 불가.
프로덕션(Lavalink .6, 리졸버, master)은 변경하지 않았다.
2026-09-17 15:08:47 +09:00
4fbf8d9326 experiment: SABR 지원 빌드로 직결 재생 성공 — ytresolver 제거 가능성 확인
이전 결론(직결 불가)은 youtube-source 1.18.2 '릴리스' 기준이었다. upstream 에
feat/sabr-support 브랜치(커밋 02f536d "SABR stream support")가 있고, 스냅샷
저장소에 빌드가 배포돼 있어 격리 Lavalink(.9:2334)에 올려 재검증했다.

빌드 확인: 1.18.2 에 없던 dev/lavalink/youtube/sabr/* 클래스 14개 추가(SabrStream 등).
(참고: main 최신 스냅샷 f45bbb7 에는 SABR 미포함 — 감지 문자열만 존재)

실측(자가 테스트, 동일 3곡 + 1곡, 프로덕션 무영향):
  A) 현재 방식(.6 Lavalink 1.18.2 + ytresolver): 4437~4884ms, 성공
  B) SABR 빌드 + ytresolver OFF(직결)          : 1155~2816ms, 성공
→ 첫 소리까지 약 3초 단축. yt-dlp 단계 자체가 사라져 구조도 단순해진다.

미검증/주의: 연령제한곡·라이브·장시간 재생 안정성, 비공식 브랜치(미병합) 빌드 사용.
프로덕션 Lavalink(.6)·리졸버·master 는 변경하지 않았다.
2026-09-17 14:48:35 +09:00
7994e97631 experiment: 4번 추가 검증 — Lavalink 직결 전면 불가 확인 + 리졸버 고속화 후보 발견
POTOKEN_INJECT 플래그 추가(기본 true). 격리 테스트 Lavalink(.9:2334)를 별도로 띄워
클라이언트/OAuth/poToken 조합을 바꿔가며 직결 재생을 검증했다. 프로덕션 무영향.

[직결 재생 — 전부 실패]
- ANDROID_VR 우선 + OAuth + poToken : 실패
- ANDROID_VR 단독 + OAuth off       : 실패
- ANDROID_VR 단독 + OAuth/poToken off: 실패
Lavalink 측 실패 사유(클라이언트별):
  ANDROID_VR "This video requires login" / TVHTML5 "page needs to be reloaded"
  WEB "No supported audio streams available"(응답이 sabr=1 + serverAbrStreamingUrl 뿐)
  WEB_EMBEDDED "player configuration error" / MWEB 403
→ youtube-source 1.18.2(최신)는 SABR 미지원. 어떤 조합으로도 직결 불가 → ytresolver 필수.

[리졸버 고속화 — 유효한 개선 발견]
yt-dlp 클라이언트별 실측(동일 영상 5개, --get-url):
  현재 default,web_safari,tv : 5/5 성공, 평균 2.95s, itag 251
  default 단독               : 5/5 성공, 평균 2.00s, itag 251  ← 약 1.0s 단축
  android_vr 단독            : 1.6s로 가장 빠르나 오디오 전용 포맷 없음(항상 itag 18)
                               → Lavalink loadtracks 실패, 사용 불가
검증: default 단독 URL을 Lavalink가 loadType=track 로 정상 로드,
      실제 재생까지 성공(자가 테스트 position 증가 확인).
2026-09-17 14:40:42 +09:00
6762b8faba experiment: ytresolver 제거(poToken 직결) 검증 — 결론 '불가'
4번 안(yt-dlp 단계 제거) 독립 검증용 실험 브랜치. master 에는 병합하지 않는다.

- Config.ytresolverEnabled 플래그 추가(YTRESOLVER_ENABLED, 기본 true=기존 동작).
  false 면 resolvePlayable 이 Lavalink youtube-source(poToken) encoded 를 그대로 사용.
- SelfTest 추가: 사람이 음성채널에 없어도 봇이 스스로 입장→재생→position 증가를
  확인해 실제 오디오 흐름 여부를 판정(환경변수 미설정 시 비활성).

실측 결과(테스트봇, Redis off, 프로덕션 무영향):
- A) ytresolver ON : 재생 성공, 첫 소리까지 4779ms (오디오 리졸브 3166ms)
- B) ytresolver OFF: 첫 소리까지 1279~1391ms 로 빠르지만 재생 실패.
     Lavalink 예외 "All clients failed to load the item." 반복, position 0 고정.
     서로 다른 2곡에서 동일 재현 → 곡 특성 아닌 구조적 차단(SABR).
결론: poToken 주입만으로는 스트림 로드가 안 되므로 ytresolver 는 계속 필요.
2026-09-17 13:16:44 +09:00
8 changed files with 402 additions and 3 deletions

View File

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

View File

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

View File

@@ -261,6 +261,8 @@ 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;

View File

@@ -43,6 +43,11 @@ 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: {
@@ -116,10 +121,22 @@ export class LavalinkManager {
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
// ('-p' 플래그를 붙이면 스포티파이 우선 검색) // ('-p' 플래그를 붙이면 스포티파이 우선 검색)
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
// 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
// (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
let searchText: string; let searchText: string;
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
if (isUrl) searchText = text; if (isUrl) searchText = text;
else if (flags.has('p')) searchText = (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${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}`; 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가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도). // 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로 // 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
// 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선). // 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
@@ -131,8 +148,9 @@ export class LavalinkManager {
searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`; searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
} }
} }
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic"; if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
const result = await node.rest.resolve(searchText); // 위에서 미리 띄워둔 폴백 검색이 있으면 그 결과를 그대로 쓴다(중복 요청 방지).
const result = pendingFallback ? await pendingFallback : await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) { if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`); if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
return null; return null;
@@ -174,6 +192,7 @@ 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;

View File

@@ -3,6 +3,7 @@ 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;
@@ -20,6 +21,9 @@ 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));

View File

@@ -106,6 +106,11 @@ 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: {

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

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

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

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