From 5d5bf9b3f3a497fefb50703661505cb4e135ae91 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Thu, 17 Sep 2026 15:29:21 +0900 Subject: [PATCH] =?UTF-8?q?experiment:=20SABR=20=EB=81=8A=EA=B9=80?= =?UTF-8?q?=EC=9D=80=20=EC=97=85=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EB=AF=B8?= =?UTF-8?q?=EC=99=84=EC=84=B1=EC=9C=BC=EB=A1=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?=EB=B6=88=EA=B0=80=20/=20=EB=8C=80=EC=95=88=20'=EC=83=81?= =?UTF-8?q?=EC=A3=BC=20=EB=A6=AC=EC=A1=B8=EB=B2=84'=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=84=B1=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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 포트로만 수행. --- bot/scripts/ytresolver_lib.py | 181 ++++++++++++++++++++++++++++++++++ docker-compose.exp.yml | 12 ++- 2 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 bot/scripts/ytresolver_lib.py diff --git a/bot/scripts/ytresolver_lib.py b/bot/scripts/ytresolver_lib.py new file mode 100644 index 0000000..c74f581 --- /dev/null +++ b/bot/scripts/ytresolver_lib.py @@ -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() diff --git a/docker-compose.exp.yml b/docker-compose.exp.yml index 94ca61b..00a46b1 100644 --- a/docker-compose.exp.yml +++ b/docker-compose.exp.yml @@ -8,13 +8,15 @@ services: - ./bot/.env environment: - REDIS=false - - LAVALINK_HOST=127.0.0.1 - - LAVALINK_PORT=2334 + - 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=IVE HEYA - - SELFTEST_WATCH_SEC=90 - - YTRESOLVER_ENABLED=false + - SELFTEST_QUERY=NewJeans Ditto + - SELFTEST_WATCH_SEC=12 + - SELFTEST_SECOND_QUERY=LE SSERAFIM CRAZY + - YTRESOLVER_ENABLED=true + - YTRESOLVER_URL=http://192.168.10.5:8780 volumes: - ./testdb:/db