#!/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()