yt-sub-mcp
by YaleDevUni
README.md
# yt-sub-mcp
유튜브 자막을 읽는 MCP 서버.
YouTube 웹 UI의 "스크립트 표시" 패널이 쓰는 내부 엔드포인트(`youtubei/v1/get_panel`)를 사용합니다. 흔히 쓰이는 `/api/timedtext` 경로와 달리 **레이트리밋에 걸리지 않고**, 4시간짜리 영상도 요청 한 번에 전체 자막이 옵니다. 왜 이 경로를 쓰게 됐는지는 [문제 해결 과정](#문제-해결-과정)에 적어뒀습니다.
- 인증 불필요 — 쿠키도, API 키 발급도, 로그인도 없음
- 외부 바이너리 없음 — yt-dlp도 ffmpeg도 안 씁니다
- 영상당 요청 2회, 응답은 SQLite에 영속 캐싱
## 설치
```bash
git clone https://github.com/<you>/yt-sub-mcp.git
cd yt-sub-mcp
uv sync
```
Claude Code에 등록:
```bash
claude mcp add yt-sub -- uv --directory /absolute/path/to/yt-sub-mcp run yt-sub-mcp
```
Claude Desktop이라면 `claude_desktop_config.json`에:
```json
{
"mcpServers": {
"yt-sub": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/yt-sub-mcp", "run", "yt-sub-mcp"]
}
}
}
```
## 도구
### `list_subtitle_tracks(video)`
사용 가능한 자막 언어 목록. 수동 제작인지 자동 생성인지 구분해서 보여줍니다.
```
2 track(s) for jNQXAC9IVRw:
de German (manual, translatable)
en English (manual, translatable)
```
### `get_subtitles(video, lang, timestamps, start_time, end_time, max_chars, refresh)`
자막 본문.
| 인자 | 기본값 | 설명 |
|---|---|---|
| `video` | — | URL 또는 11자 ID |
| `lang` | `"en"` | 우선순위 순 콤마 구분 (`"ko,en"`) |
| `timestamps` | `false` | 블록마다 시작 시각 표시 |
| `start_time` / `end_time` | — | 초 단위 구간 |
| `max_chars` | `20000` | 문자 예산 |
| `refresh` | `false` | 캐시 우회 |
```
Korean (ko), manual | length 20:03 | excerpt 4:56-5:44
[4:56] 아무튼 그 중에 3명의 동방박사가 등장하는 장면 아시죠? ...
```
### `search_subtitles(video, query, lang, context, max_hits)`
특정 표현이 나오는 지점을 타임스탬프·딥링크와 함께 반환합니다. 전체를 읽는 것보다 훨씬 쌉니다.
```
5 match(es) for 'creativity' in iG9CE55wbtY, in 4 passage(s):
[0:48] ... the extraordinary evidence of human creativity in all of ...
https://www.youtube.com/watch?v=iG9CE55wbtY&t=48s
```
`video`는 `watch?v=`, `youtu.be`, `shorts`, `embed`, `live`, 그리고 맨 ID를 모두 받습니다.
## 토큰 예산
1시간짜리 영상 자막은 대략 15k 토큰입니다. 그래서:
- 출력은 `max_chars`에서 잘리고 **이어볼 타임스탬프**를 함께 반환합니다. 그 값을 `start_time`으로 넣으면 이어집니다.
- 평문은 ~90단어 문단, 타임스탬프 모드는 ~30초 구간으로 병합해 렌더링합니다. 큐마다 타임스탬프를 붙이면 토큰이 3배가 됩니다.
- `timestamps`는 기본 꺼짐. 인용이나 링크가 필요할 때만 켜세요.
- 권장 흐름: `search_subtitles`로 위치를 찾고 → 그 구간만 `get_subtitles`로 읽기.
## 캐시
메모리 LRU(32) 앞단 + SQLite 영속 계층(500) 2단 구조입니다.
실측: 19분 영상 첫 요청 1,301ms → 이후 프로세스에서 0.7ms.
자막은 `[text, start, duration]` 삼중항 JSON을 zlib 압축해 저장합니다(427큐 25KB → 11KB, 43%).
| 환경변수 | 기본값 | 용도 |
|---|---|---|
| `YT_SUB_MCP_CACHE` | `1` | `0`이면 디스크 계층 끔 |
| `YT_SUB_MCP_CACHE_DIR` | 아래 | 캐시 위치 |
| `YT_SUB_MCP_CACHE_TTL_DAYS` | `30` | 만료. `0`이면 무기한 |
기본 경로는 macOS `~/Library/Caches/yt-sub-mcp/`, 그 외 `$XDG_CACHE_HOME/yt-sub-mcp/`.
**캐시는 기능을 죽이지 않습니다.** 디스크가 차거나 DB를 열 수 없으면 로그만 남기고 메모리 전용으로 degrade합니다.
## 문제 해결 과정
이 프로젝트의 설계는 대부분 실패에서 나왔습니다. 기록해둡니다.
### 1. 처음엔 `youtube-transcript-api`로 만들었다
가장 흔한 선택입니다. 잘 돌아갔습니다 — **개발 도중 IP가 차단되기 전까지는.**
```
HTTP 429 Too Many Requests on /api/timedtext
```
약 25분간 도구 호출 20여 회 만에 걸렸습니다. 라이브러리는 이걸 `IpBlocked`라 부르지만 실제로는 단순 429, 즉 볼륨 기반 쿨다운입니다.
### 2. 차단의 범위를 좁혔다
호출당 HTTP 요청을 계측해보니:
```
list_subtitle_tracks = 2 요청 (watch 페이지 → INNERTUBE player)
get_subtitles (cold) = 3 요청 (위 2개 + /api/timedtext)
```
그리고 차단 중에도 watch 페이지와 InnerTube player는 **정상 응답**했습니다. 즉 **429는 `/api/timedtext` 한 엔드포인트에만** 걸립니다.
### 3. yt-dlp로 갈아타려다 접었다
유지보수가 활발하고 PO token·쿠키를 지원하니 답일 것 같았습니다. 그런데 yt-dlp도 player 응답에서 받은 **timedtext URL을 그대로 씁니다.** 같은 429를 맞았습니다.
클라이언트 로테이션도 시험했습니다. 12종 중 추출에 성공한 둘 모두:
```
android 추출 OK | timedtext 429
android_vr 추출 OK | timedtext 429
```
제한이 URL을 발급한 클라이언트가 아니라 IP에 걸려 있으니 당연한 결과였습니다. 마이그레이션은 취소했습니다.
### 4. `get_transcript`를 찾았지만 죽어 있었다
"InnerTube 스크립트 API는 레이트리밋이 느슨하다"는 자료가 여럿 있었습니다. protobuf params를 직접 만들어 YouTube가 watch 페이지에 심어둔 값과 대조했더니 **바이트 단위로 일치**했는데도 전부 400이었습니다.
```
player (control) HTTP 200
next (control) HTTP 200
get_transcript HTTP 400
get_transcript+bad HTTP 400 ← 쓰레기 params와 동일 응답
```
정상 params와 쓰레기 params의 응답이 같다는 건 본문 검증 이전에 거부된다는 뜻입니다. `get_transcript`는 폐기된 엔드포인트였고, 그걸 소개한 자료들이 낡은 것이었습니다.
### 5. 브라우저 캡처가 답을 줬다
실제 "스크립트 표시" 버튼을 누른 요청을 보니 엔드포인트가 교체돼 있었습니다:
```
POST /youtubei/v1/get_panel?prettyPrint=false
{ "panelId": "PAmodern_transcript_view", "params": "qgkPCgtNcXFXeFpMeldRTRgC" }
```
params를 디코딩하니 `field 149 { 1: video_id, 3: 2 }`, 이게 전부였습니다. 기존 `get_transcript`보다 훨씬 단순합니다.
### 6. 검증: timedtext가 429인 상태에서 전부 성공
| | `/api/timedtext` | `/youtubei/v1/get_panel` |
|---|---|---|
| 레이트리밋 | 25~40요청 후 429 | **25요청 / 425분 분량 / 5초 — 무차단** |
| 인증 | 불필요 | 불필요 (`logged_in=0` 확인) |
| 영상당 요청 | 3 | 2 |
| 4.4시간 영상 | — | 1요청, 2.6MB, 2053큐 완전 |
| 타임스탬프 | ms + duration | 초 단위, end 없음 |
| 큐 수 (20분 TED) | 427 | 163 (읽기 좋게 병합됨) |
위 측정은 **모두 timedtext가 429로 막힌 상태에서** 나왔습니다. 두 엔드포인트가 별도 예산이라는 뜻입니다.
### 남은 트레이드오프
**타임스탬프가 초 단위이고 end 시각이 없습니다.** 응답 어디에도 ms 필드가 없습니다. 각 큐는 다음 큐가 시작할 때까지, 마지막 큐는 영상 끝까지로 길이를 추론합니다. 표시가 `M:SS`이고 딥링크가 `&t=Ns`라 실질 손실은 작습니다.
**없는 언어를 요청하면 조용히 기본 트랙으로 폴백합니다.** 응답에 언어 표시가 전혀 없어서, `hl`을 던지기 전에 player 응답으로 트랙 목록을 먼저 확인하고 없으면 거부합니다. 영상당 요청이 2회인 이유가 이것입니다.
**비공식 내부 엔드포인트입니다.** 언제든 바뀔 수 있습니다 — `get_transcript`가 그렇게 죽었습니다. 다만 watch 페이지에서 params를 재추출하는 방법을 알고 있어 복구 경로가 있고, `ProviderChain`이 대체 소스를 끼울 자리를 남겨둡니다.
## 구조
```
providers/base.py SubtitleProvider 프로토콜 + ProviderChain
providers/get_panel.py 유일한 provider — protobuf params, 파싱, 트랙 선택
errors.py provider 중립 에러 + retryable 플래그
models.py provider 중립 타입
formatting.py 병합 / 구간 슬라이싱 / 문자 예산
cache.py 메모리 LRU + SQLite + 합성 스토어
urls.py URL·ID 파싱
server.py MCP 도구
```
`models.py` 타입과 `errors.py` 예외만 provider 경계를 넘습니다. 백엔드 고유 예외는 provider 안에서 번역됩니다.
provider가 하나뿐이라 `ProviderChain`은 지금 사실상 이음새입니다. 남겨둔 이유는 재시도 정책(`SubtitleError.retryable`)을 소유하고 있고, 두 번째 소스(timedtext 리더, 자막 없는 영상용 Whisper)를 도구 코드 변경 없이 끼울 자리이기 때문입니다.
### 폴백 정책
| 상황 | 에러 | 다음 provider 시도 |
|---|---|---|
| 429 / 연령 제한 / 로그인 요구 | `AccessBlocked` | ✅ |
| 요청 언어 없음 | `LanguageNotAvailable` | ✅ |
| 네트워크·파싱 오류 | `ProviderFailure` | ✅ |
| 영상 삭제/비공개 | `VideoNotFound` | ❌ |
| 자막이 꺼져 있음 | `SubtitlesDisabled` | ❌ |
영구 실패에 다른 provider를 태워봐야 같은 답이므로 즉시 중단합니다.
## 개발
```bash
uv sync --dev
uv run pytest
uv run ruff check .
uv run ruff format .
```
서버가 실제로 뜨는지 확인:
```bash
uv run python scripts/smoke_stdio.py
```
테스트는 **전부 오프라인**입니다. `conftest.py`가 디스크 캐시를 끄고(개발자의 실제 캐시를 건드리지 않도록), provider 테스트는 캡처한 응답을 씁니다. CI에서 네트워크 호출이 생기면 테스트가 퇴행한 것입니다.
CI는 Linux/macOS × Python 3.12/3.13에서 lint·format·test를 돌리고, 별도 job이 stdio로 서버를 띄워 도구 목록을 확인합니다.
## 라이선스
MIT
TDQS
A4.3/5.0
Scored across 3 tools
Disambiguation5/5
Each tool has a clear, distinct role: list tracks, read subtitles, and search within them. There is no overlap or ambiguity between the three operations.
Naming Consistency5/5
All tool names follow a consistent verb_noun pattern (list_, get_, search_). The naming is uniform and predictable, with no mixed conventions.
Tool Count5/5
Three tools is well-scoped for a YouTube subtitles server, covering the essential operations without unnecessary bloat. Each tool serves a necessary function.
Completeness4/5
The core operations of listing, reading, and searching subtitles are all present. A minor gap is the lack of an explicit way to select a specific subtitle track by language or ID, as get_subtitles appears to auto-pick human-written captions.
Maintenance
ActivitySlowing
ResponsivenessNo issues