Compare commits

...

5 Commits

Author SHA1 Message Date
5811c16c20 perf(search): 유튜브뮤직 검색과 ytsearch 폴백을 병렬 실행 (실험 브랜치)
측정으로 확인한 낭비: YoutubeMusic.getSearchUrl 적중률이 6/10(평균 548ms)이라,
빗나가는 40% 에서는 YTM 을 끝까지 기다린 뒤 다시 ytsearch(~0.5초)를 도는 순차 대기가
통째로 버려지고 있었다.

변경: resolveQuery 기본 경로에서 ytsearch 폴백 검색을 먼저 띄워두고 YTM 결과를 기다린다.
  - YTM 적중  -> 폴백 결과는 버리고 기존과 동일하게 동작(Lavalink 검색 1회가 헛돌 뿐)
  - YTM 빗나감 -> 이미 진행 중인 폴백 결과를 그대로 사용(추가 대기 0)
  - 버려지는 폴백 promise 는 catch 를 달아 unhandledRejection 방지
  - " Topic" 중복 부가 방지 조건 추가

실측 A/B(YTM 이 빗나가는 3곡, 검색+해석+리졸브 구간):
  기존 1500 / 1688 / 1391ms  ->  신규 1224 / 1180 / 1399ms
  재생은 3곡 모두 성공, 관찰 중 멈춤 0.
남은 병목: 음성접속 핸드셰이크(0.5~1.3초)는 디스코드 왕복이라 단축 여지가 거의 없다.
2026-09-18 20:02:16 +09:00
fed948ca02 Merge branch 'master' into experiment/potoken-direct 2026-09-18 19:54:46 +09:00
8ab5b84fa7 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초, 남은 병목은 음성접속·유튜브뮤직 검색
2026-09-18 19:54:27 +09:00
79f6201f85 perf(resolver): 유튜브 내부 API(VISIONOS) 직접 호출을 0순위로 적용 — 리졸브 0.3초대
기존 상주 yt-dlp(1.7~2.3초) 앞에 내부 player API 직접 호출을 두고, 실패 시 기존 경로로
자동 폴백한다. visitorData 는 리졸버가 스스로 발급/캐시(6시간)하고 만료 시 재발급하므로
봇과의 별도 배선이 필요 없다.

경로 우선순위: 내부API(0.3초) -> 상주 yt-dlp -> yt-dlp+쿠키(연령제한)

실측(운영 적용 후):
  일반 유튜브 URL 리졸브 274~559ms (적용 전 1.7~2.3초)
  연령제한 영상은 내부API가 LOGIN_REQUIRED 를 주어 쿠키 경로로 폴백(6~8초, 정상 재생)

재생 검증(운영 리졸버 + 운영 Lavalink, 전 케이스 성공/끊김 0):
  짧은영상 18초 824ms | 일반곡 1646ms | 해외곡 2098ms | 스포티파이 2522ms
  플레이리스트 2722ms | 곡전환 2230ms(전환 6.0초)
  연령제한 4건(사용자 제공): 1453 / 1522 / 7653 / 8209ms 전부 재생 성공
  32분 연속재생: position 5,180 -> 1,920,160ms, 멈춤 0/383
리졸버 경로 통계(테스트 1시간): 내부API 9 / 상주lib 폴백 0 / 쿠키 폴백 3(연령제한)

롤백: /opt/ytresolver.py.bak-20260918-153001 로 교체 후 systemctl restart ytresolver
2026-09-18 16:32:09 +09:00
2e6c956265 ops(resolver): 상주 yt-dlp 리졸버 적용본을 저장소에 기록
운영(.5 /opt/ytresolver.py)에 적용한 내용을 형상관리한다. 기존에는 저장소 밖에만
존재해 변경 이력이 남지 않았다.

변경 핵심:
- yt-dlp zipapp 을 sys.path 에 넣어 import, YoutubeDL 인스턴스를 프로세스에 상주.
  요청마다 발생하던 프로세스 기동 비용(실측 0.40s)을 제거.
- player_client=default 단독 사용(기존 default,web_safari,tv 는 첫 번째에서 이미
  성공해 나머지 2개 시도 시간이 낭비됐음).
- 라이브러리 호출 실패 시 기존 subprocess 경로(익명 -> 쿠키)로 자동 폴백해 안전망 유지.

실측: 리졸브 4.2~4.7s -> 1.7~2.3s / 첫 소리 4.9s -> 2.6s
검증: 운영 리졸버+운영 Lavalink 로 90초 재생, 끊김 0/17. itag 251 동일, loadType=track.
롤백: /opt/ytresolver.py.bak-20260917-154054 로 교체 후 systemctl restart ytresolver
2026-09-17 15:44:43 +09:00
5 changed files with 185 additions and 18 deletions

View File

@@ -9,7 +9,7 @@
|----------|------|------|
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) |
| [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) |
| [`resolver/`](resolver) | `ytresolver.py` — yt-dlp 기반 오디오 URL 리졸버(+systemd 유닛). 봇이 연령제한/SABR 우회에 사용. | 아래 + bot/README §1·2 |
| [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 |
| [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
## 전체 아키텍처
@@ -20,7 +20,10 @@
│ ▲ └ youtube-plugin(일반)
│ │ 재생 직전 리졸브
▼ │
ytresolver(.5:8779) ── yt-dlp(+쿠키) ──▶ 직접 오디오 URL
ytresolver(.5:8779) ─┬─ ① 유튜브 내부 API(VISIONOS) ~0.3초
│ ├─ ② yt-dlp 상주 호출 ~1.7초
│ └─ ③ yt-dlp + 쿠키(연령제한) ~6~8초
│ ──▶ 직접 오디오 URL
│ (Lavalink http 소스로 재생)
│
┌───────── Redis(.7) Pub/Sub + Key ─────────┐ ← 제어/상태 버스
@@ -31,8 +34,16 @@
```
- **봇**과 **대시보드**는 직접 연결되지 않고 **Redis**로 느슨하게 연동된다(대시보드는 Redis만 있으면 봇과 통신).
- 봇은 유튜브 연령제한 영상을 Lavalink만으로는 못 틀어서, `resolver/`가 쿠키로 직접 오디오 URL을 뽑아 우회한다.
- 유튜브가 스트리밍을 SABR 방식으로 바꾼 뒤 Lavalink의 youtube-plugin(1.18.2)만으로는 실재생이 막힌다.
그래서 `resolver/`가 재생 직전에 **직접 오디오 URL**을 뽑아 주고, 봇은 그 URL을 Lavalink http 소스로 재생한다.
- 리졸버는 3단계로 폴백한다. ①이 대부분을 처리하고, 연령제한처럼 로그인이 필요한 영상만 ③으로 내려간다.
1. **유튜브 내부 API(VISIONOS 클라이언트)** — player API 한 번으로 오디오 직링크 획득(~0.3초).
`visitorData`는 리졸버가 스스로 발급·캐시(6시간)하고 만료 시 자동 재발급한다.
2. **yt-dlp 상주 호출** — zipapp을 import해 `YoutubeDL` 인스턴스를 재사용(프로세스 기동 비용 제거, ~1.7초).
3. **yt-dlp + 쿠키** — 연령제한(로그인 필요) 영상 전용 경로(~6~8초).
- 봇 서버(.5)와 Lavalink(.6)가 같은 공인 IP라, .5에서 뽑은 스트림 URL을 .6이 그대로 재생한다.
- 첫 곡 재생 지연은 음성채널 접속과 곡 해석을 **병렬**로 돌리고, 곡이 정해지는 즉시 리졸브를 **예열**해 줄였다
(`bot/src/classes/LavalinkManager.ts`의 `resolveQuery`/`prewarmTrack`).
## 빠르게 보기

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

View File

@@ -121,10 +121,22 @@ export class LavalinkManager {
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
// 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
// (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
let searchText: string;
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
if (isUrl) searchText = text;
else if (flags.has('p')) searchText = (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
else searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
else {
const fallbackText = `ytsearch:${text}` + (flags.has('o') ? "" : " Topic");
pendingFallback = node.rest.resolve(fallbackText);
pendingFallback.catch(() => {}); // 적중해서 버려질 때 unhandledRejection 방지
const ytm = await YoutubeMusic.getSearchUrl(text).catch(() => null);
searchText = ytm ?? fallbackText;
if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음
}
// 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
// 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
@@ -136,8 +148,9 @@ export class LavalinkManager {
searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
}
}
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
const result = await node.rest.resolve(searchText);
if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
// 위에서 미리 띄워둔 폴백 검색이 있으면 그 결과를 그대로 쓴다(중복 요청 방지).
const result = pendingFallback ? await pendingFallback : await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
return null;

View File

@@ -13,9 +13,9 @@ services:
- SELFTEST_GUILD_ID=1352269198297923648
- SELFTEST_VOICE_CHANNEL_ID=1352269198914621465
- SELFTEST_TEXT_CHANNEL_ID=1421342055774289980
- SELFTEST_QUERY=https://www.youtube.com/watch?v=E7EOjkGVmyo
- SELFTEST_WATCH_SEC=1920
- SELFTEST_QUERY=aespa Supernova
- SELFTEST_WATCH_SEC=15
- YTRESOLVER_ENABLED=true
- YTRESOLVER_URL=http://192.168.10.5:8780
- YTRESOLVER_URL=http://192.168.10.5:8779
volumes:
- ./testdb:/db

View File

@@ -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)