From 3a4849acabd0352e420aaef40e351218e2b4dc46 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Fri, 11 Sep 2026 23:02:35 +0900 Subject: [PATCH] =?UTF-8?q?perf(=EC=9E=AC=EC=83=9D=EC=A7=80=EC=97=B0):=20?= =?UTF-8?q?=EC=9C=A0=ED=8A=9C=EB=B8=8C=20URL=20=EC=9D=8C=EC=84=B1=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EC=A0=84=20=ED=94=84=EB=A6=AC=EC=9B=8C=EB=B0=8D=20?= =?UTF-8?q?+=20=EB=A6=AC=EC=A1=B8=EB=B2=84=20=EC=A7=84=ED=96=89=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=95=A9=EC=B9=98=EA=B8=B0(coalescing)=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=AB=20=EC=9E=AC=EC=83=9D=20=EC=A7=80=EC=97=B0=20~2?= =?UTF-8?q?=EC=B4=88=20=EB=8B=A8=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/src/classes/LavalinkManager.ts | 16 +++++++++++ bot/src/events/messageCreate.ts | 2 ++ resolver/ytresolver.py | 46 ++++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index c7c32e1..1751315 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -76,6 +76,22 @@ export class LavalinkManager { return !!this.shoukaku.options.nodeResolver(this.shoukaku.nodes); } + /** + * 유튜브 URL을 음성 접속 전에 미리 ytresolver에 걸어둔다(fire-and-forget). + * 리졸버가 진행 중 요청을 합쳐주므로(coalescing), 음성 접속(~2초) 동안 리졸브(~2.5초)가 + * 겹쳐 진행돼 실제 재생 시점엔 캐시 적중 → 첫 재생 지연이 크게 줄어든다. + * resolvePlayable이 쓰는 캐시 키(info.uri = https://www.youtube.com/watch?v=ID)와 맞추기 위해 + * 표준 watch URL로 변환해 요청한다. + */ + public prewarmYoutubeUrl(raw: string) { + try { + const m = raw.match(/(?:youtu\.be\/|[?&]v=|\/shorts\/|\/embed\/|\/live\/)([A-Za-z0-9_-]{11})/); + if (!m) return; + const canonical = `https://www.youtube.com/watch?v=${m[1]}`; + fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(canonical)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {}); + } catch { /* prewarm 실패는 무시 (실제 재생 시 정상 경로로 리졸브) */ } + } + getPlayer(guildId: string) { return this.players.get(guildId); } diff --git a/bot/src/events/messageCreate.ts b/bot/src/events/messageCreate.ts index 268b3a2..8be6973 100644 --- a/bot/src/events/messageCreate.ts +++ b/bot/src/events/messageCreate.ts @@ -51,6 +51,8 @@ async function handleMessage(message: Message): Promise { const gdb = DB.guild.get(message.guild.id); if (!gdb?.channel_id) return; if (gdb.channel_id !== message.channel.id) return; + // 유튜브 URL이면 음성 접속 전에 미리 리졸브를 걸어 첫 재생 지연을 줄인다(리졸버가 진행분을 합쳐줌). + lavalinkManager.prewarmYoutubeUrl(message.content); let player = lavalinkManager.getPlayer(message.guild.id); const voiceChannel = getVoiceChannel(message.member); if (!player) { diff --git a/resolver/ytresolver.py b/resolver/ytresolver.py index 727ec5a..d6845a9 100644 --- a/resolver/ytresolver.py +++ b/resolver/ytresolver.py @@ -27,6 +27,7 @@ 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시간 유효) @@ -53,12 +54,7 @@ def first_url(out): return lines[0] if lines else None -def resolve(target): - now = time.time() - with LOCK: - hit = CACHE.get(target) - if hit and hit[1] > now: - return hit[0] +def _do_resolve(target): try: out = run_ytdlp(target, use_cookies=False) url = first_url(out) @@ -69,13 +65,45 @@ def resolve(target): except subprocess.TimeoutExpired: print("[result] TIMEOUT", file=sys.stderr, flush=True) return None - ok = bool(url and url.startswith("http")) - if not ok: + 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, now + TTL) + 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