From 2e6c9562656c6a5f5e85e7e7af1f0f4bb57fceb4 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Thu, 17 Sep 2026 15:44:13 +0900 Subject: [PATCH 1/3] =?UTF-8?q?ops(resolver):=20=EC=83=81=EC=A3=BC=20yt-dl?= =?UTF-8?q?p=20=EB=A6=AC=EC=A1=B8=EB=B2=84=20=EC=A0=81=EC=9A=A9=EB=B3=B8?= =?UTF-8?q?=EC=9D=84=20=EC=A0=80=EC=9E=A5=EC=86=8C=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영(.5 /opt/ytresolver.py)에 적용한 내용을 형상관리한다. 기존에는 저장소 밖에만 존재해 변경 이력이 남지 않았다. 변경 핵심: - yt-dlp zipapp 을 sys.path 에 넣어 import, YoutubeDL 인스턴스를 프로세스에 상주. 요청마다 발생하던 프로세스 기동 비용(실측 0.40s)을 제거. - player_client=default 단독 사용(기존 default,web_safari,tv 는 첫 번째에서 이미 성공해 나머지 2개 시도 시간이 낭비됐음). - 라이브러리 호출 실패 시 기존 subprocess 경로(익명 -> 쿠키)로 자동 폴백해 안전망 유지. 실측: 리졸브 4.2~4.7s -> 1.7~2.3s / 첫 소리 4.9s -> 2.6s 검증: 운영 리졸버+운영 Lavalink 로 90초 재생, 끊김 0/17. itag 251 동일, loadType=track. 롤백: /opt/ytresolver.py.bak-20260917-154054 로 교체 후 systemctl restart ytresolver --- scripts/ytresolver.py | 179 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 scripts/ytresolver.py diff --git a/scripts/ytresolver.py b/scripts/ytresolver.py new file mode 100644 index 0000000..dc4aae3 --- /dev/null +++ b/scripts/ytresolver.py @@ -0,0 +1,179 @@ +#!/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): + # yt-dlp 를 라이브러리로 직접 호출: 매 요청 프로세스 기동(약 0.4s) 을 없앤다. + q = target if (target.startswith("http") or target.startswith("ytsearch")) else "ytsearch1:" + target + with _ydl_lock: + info = _ydl.extract_info(q, download=False) + if info.get("entries"): + info = info["entries"][0] + u = info.get("url") + if not u: + for f in (info.get("requested_formats") or []): + if f.get("url"): u = f["url"]; break + return u +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp") +COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt") +PORT = int(os.environ.get("YTRESOLVER_PORT", "8779")) +CACHE = {} +INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing) +LOCK = threading.Lock() +TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효) + +# 속도/안정성 공통 플래그 +COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings", + "--no-check-certificate", "--socket-timeout", "15"] + + +def run_ytdlp(target, use_cookies=False): + if use_cookies and os.path.exists(COOKIE_FILE): + # 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요 + cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE, + *COMMON, "--extractor-args", "youtube:player_client=web"] + else: + # 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합. + cmd = [YTDLP, *COMMON, + "--extractor-args", "youtube:player_client=default,web_safari,tv"] + cmd += ["--get-url", target] + return subprocess.run(cmd, capture_output=True, text=True, timeout=45) + + +def first_url(out): + lines = (out.stdout or "").strip().splitlines() + return lines[0] if lines else None + + +def _do_resolve(target): + # 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() From 79f6201f857a4b768151fb5c5cc57648d698a072 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Fri, 18 Sep 2026 16:32:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?perf(resolver):=20=EC=9C=A0=ED=8A=9C?= =?UTF-8?q?=EB=B8=8C=20=EB=82=B4=EB=B6=80=20API(VISIONOS)=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=ED=98=B8=EC=B6=9C=EC=9D=84=200=EC=88=9C=EC=9C=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=81=EC=9A=A9=20=E2=80=94=20=EB=A6=AC=EC=A1=B8?= =?UTF-8?q?=EB=B8=8C=200.3=EC=B4=88=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 상주 yt-dlp(1.7~2.3초) 앞에 내부 player API 직접 호출을 두고, 실패 시 기존 경로로 자동 폴백한다. visitorData 는 리졸버가 스스로 발급/캐시(6시간)하고 만료 시 재발급하므로 봇과의 별도 배선이 필요 없다. 경로 우선순위: 내부API(0.3초) -> 상주 yt-dlp -> yt-dlp+쿠키(연령제한) 실측(운영 적용 후): 일반 유튜브 URL 리졸브 274~559ms (적용 전 1.7~2.3초) 연령제한 영상은 내부API가 LOGIN_REQUIRED 를 주어 쿠키 경로로 폴백(6~8초, 정상 재생) 재생 검증(운영 리졸버 + 운영 Lavalink, 전 케이스 성공/끊김 0): 짧은영상 18초 824ms | 일반곡 1646ms | 해외곡 2098ms | 스포티파이 2522ms 플레이리스트 2722ms | 곡전환 2230ms(전환 6.0초) 연령제한 4건(사용자 제공): 1453 / 1522 / 7653 / 8209ms 전부 재생 성공 32분 연속재생: position 5,180 -> 1,920,160ms, 멈춤 0/383 리졸버 경로 통계(테스트 1시간): 내부API 9 / 상주lib 폴백 0 / 쿠키 폴백 3(연령제한) 롤백: /opt/ytresolver.py.bak-20260918-153001 로 교체 후 systemctl restart ytresolver --- scripts/ytresolver.py | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/scripts/ytresolver.py b/scripts/ytresolver.py index dc4aae3..6871ff0 100644 --- a/scripts/ytresolver.py +++ b/scripts/ytresolver.py @@ -29,6 +29,83 @@ _YDL_OPTS = {"format": "bestaudio/best", "noplaylist": True, "quiet": True, _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 @@ -76,6 +153,16 @@ 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) From 8ab5b84fa753369cf25c996a0991ef3848505ea1 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Fri, 18 Sep 2026 19:54:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(readme):=20=EB=A6=AC=EC=A1=B8=EB=B2=84?= =?UTF-8?q?=203=EB=8B=A8=EA=B3=84=20=ED=8F=B4=EB=B0=B1=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=98=EC=98=81=20+=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EB=B3=B8=EC=9D=84=20resolver/=20=EB=A1=9C=20=EC=9D=BC=EC=9B=90?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolver/ytresolver.py 를 현재 운영본(.5 /opt/ytresolver.py)과 동일하게 갱신. (직전 커밋에서 scripts/ 로 잘못 중복 저장했던 사본은 제거) - 루트 README / bot README: - 아키텍처 다이어그램에 3단계 폴백(내부API 0.3초 / yt-dlp 상주 1.7초 / 쿠키 6~8초) 표기 - "왜 리졸버가 필요한가"를 SABR 기준으로 갱신, upstream feat/sabr-support 가 55초에서 403으로 끊긴다는 검증 결과와 재검증 조건을 주석으로 남김 - 성능 메모를 현재 구조(병렬화·예열·내부API)와 실측치로 갱신 첫 소리 약 7초 -> 4.9초 -> 2.6초 -> 1.6~2.3초, 남은 병목은 음성접속·유튜브뮤직 검색 --- README.md | 17 ++- bot/README.md | 43 +++++-- resolver/ytresolver.py | 118 ++++++++++++++++++ scripts/ytresolver.py | 266 ----------------------------------------- 4 files changed, 166 insertions(+), 278 deletions(-) delete mode 100644 scripts/ytresolver.py diff --git a/README.md b/README.md index 542f789..c54f2b1 100644 --- a/README.md +++ b/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`). ## 빠르게 보기 diff --git a/bot/README.md b/bot/README.md index 4e2e99d..d245f92 100644 --- a/bot/README.md +++ b/bot/README.md @@ -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초)이다. --- diff --git a/resolver/ytresolver.py b/resolver/ytresolver.py index d6845a9..6871ff0 100644 --- a/resolver/ytresolver.py +++ b/resolver/ytresolver.py @@ -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) diff --git a/scripts/ytresolver.py b/scripts/ytresolver.py deleted file mode 100644 index 6871ff0..0000000 --- a/scripts/ytresolver.py +++ /dev/null @@ -1,266 +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() - - -# ===== 유튜브 내부 API(VISIONOS) 직접 호출 ===== -# yt-dlp 를 거치지 않고 player API 한 번으로 오디오 직링크를 얻는다(실측 0.3초대). -# visitorData 는 이 프로세스가 직접 발급/캐시하며, 실패하면 즉시 재발급한다. -# 연령제한/로그인 필요 영상은 여기서 None 을 돌려 아래 기존 경로(yt-dlp+쿠키)로 폴백한다. -import urllib.request as _ur - -_VD_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" -_IT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15" -_VID_RE = __import__("re").compile(r"(?:v=|youtu\.be/|/shorts/|/embed/|/live/)([A-Za-z0-9_-]{11})") -_VD_TTL = 6 * 3600 -_vd = {"value": "", "expires": 0} -_vd_lock = threading.Lock() - -def _post_player(payload, ua, extra_headers=None): - headers = {"Content-Type": "application/json", "User-Agent": ua} - if extra_headers: - headers.update(extra_headers) - req = _ur.Request("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", - data=json.dumps(payload).encode(), headers=headers) - return json.loads(_ur.urlopen(req, timeout=15).read()) - -def get_visitor_data(force=False): - now = time.time() - with _vd_lock: - if not force and _vd["value"] and _vd["expires"] > now: - return _vd["value"] - body = {"videoId": "xgJ13Dt9F14", - "context": {"client": {"clientName": "WEB", "clientVersion": "2.20260101.00.00", - "hl": "ko", "gl": "KR"}}} - d = _post_player(body, _VD_UA) - vd = (d.get("responseContext") or {}).get("visitorData") or "" - if vd: - with _vd_lock: - _vd["value"] = vd - _vd["expires"] = time.time() + _VD_TTL - print("[visitorData] 갱신 완료(len=%d)" % len(vd), file=sys.stderr, flush=True) - return vd - -def _innertube_once(vid, vd): - ctx = {"clientName": "VISIONOS", "clientVersion": "1.02", "deviceMake": "Apple", - "deviceModel": "RealityDevice17,1", "osName": "visionOS", "osVersion": "26.5.23O471", - "hl": "ko", "gl": "KR", "visitorData": vd} - body = {"videoId": vid, "context": {"client": ctx}, "contentCheckOk": True, "racyCheckOk": True} - d = _post_player(body, _IT_UA, {"X-Goog-Visitor-Id": vd, - "X-Youtube-Client-Name": "101", "X-Youtube-Client-Version": "1.02"}) - status = d.get("playabilityStatus", {}).get("status") - if status != "OK": - return None, status - af = (d.get("streamingData") or {}).get("adaptiveFormats") or [] - au = [f for f in af if str(f.get("mimeType", "")).startswith("audio") and f.get("url")] - if not au: - return None, "NO_AUDIO" - au.sort(key=lambda f: f.get("bitrate", 0), reverse=True) - return au[0]["url"], status - -def innertube_resolve(target): - m = _VID_RE.search(target) - if not m: - return None # 검색어 등 영상 ID 가 없는 입력 -> 기존 경로 - vid = m.group(1) - vd = get_visitor_data() - if not vd: - return None - url, status = _innertube_once(vid, vd) - if url: - return url - # visitorData 만료로 인한 로그인 요구면 1회 재발급 후 재시도 - if status in ("LOGIN_REQUIRED", "ERROR"): - vd = get_visitor_data(force=True) - if vd: - url, status = _innertube_once(vid, vd) - if url: - return url - print("[innertube] 폴백(status=%s)" % status, file=sys.stderr, flush=True) - return None - -def lib_resolve(target): - # yt-dlp 를 라이브러리로 직접 호출: 매 요청 프로세스 기동(약 0.4s) 을 없앤다. - q = target if (target.startswith("http") or target.startswith("ytsearch")) else "ytsearch1:" + target - with _ydl_lock: - info = _ydl.extract_info(q, download=False) - if info.get("entries"): - info = info["entries"][0] - u = info.get("url") - if not u: - for f in (info.get("requested_formats") or []): - if f.get("url"): u = f["url"]; break - return u -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlparse, parse_qs - -YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp") -COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt") -PORT = int(os.environ.get("YTRESOLVER_PORT", "8779")) -CACHE = {} -INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing) -LOCK = threading.Lock() -TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효) - -# 속도/안정성 공통 플래그 -COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings", - "--no-check-certificate", "--socket-timeout", "15"] - - -def run_ytdlp(target, use_cookies=False): - if use_cookies and os.path.exists(COOKIE_FILE): - # 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요 - cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE, - *COMMON, "--extractor-args", "youtube:player_client=web"] - else: - # 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합. - cmd = [YTDLP, *COMMON, - "--extractor-args", "youtube:player_client=default,web_safari,tv"] - cmd += ["--get-url", target] - return subprocess.run(cmd, capture_output=True, text=True, timeout=45) - - -def first_url(out): - lines = (out.stdout or "").strip().splitlines() - return lines[0] if lines else None - - -def _do_resolve(target): - # 0순위: 내부 API 직접 호출(0.3초대). 실패하면 아래 기존 경로로 자동 폴백. - try: - _u = innertube_resolve(target) - if _u and _u.startswith("http"): - print("[result] OK(innertube) " + _u[:40], file=sys.stderr, flush=True) - with LOCK: - CACHE[target] = (_u, time.time() + TTL) - return _u - except Exception as e: - print("[it-fail] " + str(e)[:120], file=sys.stderr, flush=True) - # 1순위: 라이브러리 상주 호출(가장 빠름). 실패하면 기존 subprocess 경로로 폴백. - try: - u = lib_resolve(target) - if u and u.startswith("http"): - print("[result] OK(lib) " + u[:50], file=sys.stderr, flush=True) - with LOCK: - CACHE[target] = (u, time.time() + TTL) - return u - except Exception as e: - print("[lib-fail] " + str(e)[:150], file=sys.stderr, flush=True) - try: - out = run_ytdlp(target, use_cookies=False) - url = first_url(out) - if not (url and url.startswith("http")) and os.path.exists(COOKIE_FILE): - print(f"[retry-cookies] {(out.stderr or out.stdout or '')[:180]}", file=sys.stderr, flush=True) - out = run_ytdlp(target, use_cookies=True) - url = first_url(out) - except subprocess.TimeoutExpired: - print("[result] TIMEOUT", file=sys.stderr, flush=True) - return None - if not (url and url.startswith("http")): - print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True) - return None - print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True) - with LOCK: - CACHE[target] = (url, time.time() + TTL) - return url - - -def resolve(target): - now = time.time() - with LOCK: - hit = CACHE.get(target) - if hit and hit[1] > now: - return hit[0] - # 같은 target이 이미 리졸브 중이면, 새 yt-dlp를 띄우지 않고 그 결과를 기다린다. - # (봇이 음성 접속과 동시에 미리 리졸브를 걸어두면, 실제 재생 요청이 이 진행분에 합쳐져 - # 음성 접속 시간과 리졸브 시간이 겹쳐 첫 재생 지연이 크게 줄어든다.) - entry = INFLIGHT.get(target) - owner = entry is None - if owner: - entry = {"event": threading.Event(), "url": None} - INFLIGHT[target] = entry - if not owner: - entry["event"].wait(50) - with LOCK: - hit = CACHE.get(target) - return hit[0] if hit and hit[1] > time.time() else entry.get("url") - # owner: 실제 리졸브 수행 - url = None - try: - url = _do_resolve(target) - except Exception as e: - print(f"[result] ERR {str(e)[:120]}", file=sys.stderr, flush=True) - finally: - entry["url"] = url - with LOCK: - INFLIGHT.pop(target, None) - entry["event"].set() - return url - - -class H(BaseHTTPRequestHandler): - def log_message(self, *a): - pass - - def do_GET(self): - p = urlparse(self.path) - q = parse_qs(p.query) - if p.path == "/health": - self._send(200, {"ok": True, "cookie": os.path.exists(COOKIE_FILE)}) - return - if p.path != "/resolve": - self._send(404, {"error": "not found"}) - return - target = (q.get("url") or q.get("q") or [None])[0] - print(f"[resolve] {target[:80] if target else None}", file=sys.stderr, flush=True) - if not target: - self._send(400, {"error": "missing url/q"}) - return - if not (target.startswith("http") or target.startswith("ytsearch")): - target = "ytsearch1:" + target - url = resolve(target) - if url: - self._send(200, {"url": url}) - else: - self._send(502, {"error": "resolve failed"}) - - def _send(self, code, obj): - b = json.dumps(obj).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(b))) - self.end_headers() - self.wfile.write(b) - - -if __name__ == "__main__": - print(f"ytresolver listening on :{PORT} (cookie={COOKIE_FILE}, exists={os.path.exists(COOKIE_FILE)})", - file=sys.stderr, flush=True) - ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()