docs(readme): 리졸버 3단계 폴백 구조 반영 + 운영본을 resolver/ 로 일원화
- 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초, 남은 병목은 음성접속·유튜브뮤직 검색
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user