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:
2026-09-18 19:54:27 +09:00
parent 79f6201f85
commit 8ab5b84fa7
4 changed files with 166 additions and 278 deletions

View File

@@ -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초)이다.
---