perf(재생지연): 유튜브 URL 음성접속 전 프리워밍 + 리졸버 진행요청 합치기(coalescing)로 첫 재생 지연 ~2초 단축
This commit is contained in:
@@ -76,6 +76,22 @@ export class LavalinkManager {
|
|||||||
return !!this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
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) {
|
getPlayer(guildId: string) {
|
||||||
return this.players.get(guildId);
|
return this.players.get(guildId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ async function handleMessage(message: Message): Promise<void> {
|
|||||||
const gdb = DB.guild.get(message.guild.id);
|
const gdb = DB.guild.get(message.guild.id);
|
||||||
if (!gdb?.channel_id) return;
|
if (!gdb?.channel_id) return;
|
||||||
if (gdb.channel_id !== message.channel.id) return;
|
if (gdb.channel_id !== message.channel.id) return;
|
||||||
|
// 유튜브 URL이면 음성 접속 전에 미리 리졸브를 걸어 첫 재생 지연을 줄인다(리졸버가 진행분을 합쳐줌).
|
||||||
|
lavalinkManager.prewarmYoutubeUrl(message.content);
|
||||||
let player = lavalinkManager.getPlayer(message.guild.id);
|
let player = lavalinkManager.getPlayer(message.guild.id);
|
||||||
const voiceChannel = getVoiceChannel(message.member);
|
const voiceChannel = getVoiceChannel(message.member);
|
||||||
if (!player) {
|
if (!player) {
|
||||||
|
|||||||
@@ -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")
|
COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt")
|
||||||
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
|
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
|
||||||
CACHE = {}
|
CACHE = {}
|
||||||
|
INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing)
|
||||||
LOCK = threading.Lock()
|
LOCK = threading.Lock()
|
||||||
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
|
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
|
||||||
|
|
||||||
@@ -53,12 +54,7 @@ def first_url(out):
|
|||||||
return lines[0] if lines else None
|
return lines[0] if lines else None
|
||||||
|
|
||||||
|
|
||||||
def resolve(target):
|
def _do_resolve(target):
|
||||||
now = time.time()
|
|
||||||
with LOCK:
|
|
||||||
hit = CACHE.get(target)
|
|
||||||
if hit and hit[1] > now:
|
|
||||||
return hit[0]
|
|
||||||
try:
|
try:
|
||||||
out = run_ytdlp(target, use_cookies=False)
|
out = run_ytdlp(target, use_cookies=False)
|
||||||
url = first_url(out)
|
url = first_url(out)
|
||||||
@@ -69,13 +65,45 @@ def resolve(target):
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print("[result] TIMEOUT", file=sys.stderr, flush=True)
|
print("[result] TIMEOUT", file=sys.stderr, flush=True)
|
||||||
return None
|
return None
|
||||||
ok = bool(url and url.startswith("http"))
|
if not (url and url.startswith("http")):
|
||||||
if not ok:
|
|
||||||
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
|
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
|
||||||
return None
|
return None
|
||||||
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
|
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
|
||||||
with LOCK:
|
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
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user