1import { fetch as ufetch, ProxyAgent } from 'undici';
2
3const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36';
4const WEB = { clientName: 'WEB', clientVersion: '2.20250925.01.00', hl: 'en', gl: 'US' };
5
6const PLAYER_CLIENTS = [
7 { clientName: 'ANDROID', clientVersion: '20.10.38', androidSdkVersion: 34, hl: 'en', gl: 'US', ua: 'com.google.android.youtube/20.10.38 (Linux; U; Android 14) gzip' },
8 { clientName: 'IOS', clientVersion: '20.10.4', deviceModel: 'iPhone16,2', hl: 'en', gl: 'US', ua: 'com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X)' },
9];
10
11export class BlockedError extends Error {}
12
13
14
15
16
17export class YouTubeClient {
18 constructor({ getProxyUrl, log }) {
19 this.getProxyUrl = getProxyUrl;
20 this.useProxy = false;
21 this.log = log;
22 this.visitorData = null;
23 }
24
25 async _fetch(url, init, attempt = 0) {
26 let dispatcher;
27 if (this.useProxy && this.getProxyUrl) {
28 const proxyUrl = await this.getProxyUrl();
29 if (proxyUrl) dispatcher = new ProxyAgent(proxyUrl);
30 }
31 try {
32 const res = await ufetch(url, { ...init, dispatcher, signal: AbortSignal.timeout(30000) });
33 if (res.status === 429 || res.status === 403) throw new BlockedError(`HTTP ${res.status}`);
34 if (res.status >= 500) throw new Error(`HTTP ${res.status}`);
35 return res;
36 } catch (err) {
37 if (err instanceof BlockedError) {
38 if (!this.useProxy && this.getProxyUrl) {
39 this.log.info('YouTube is rate-limiting direct requests; switching to proxy.');
40 this.useProxy = true;
41 return this._fetch(url, init, attempt);
42 }
43 if (attempt < 4) return this._fetch(url, init, attempt + 1);
44 throw err;
45 }
46 if (attempt < 3) {
47 await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
48 return this._fetch(url, init, attempt + 1);
49 }
50 throw err;
51 } finally {
52 dispatcher?.close().catch(() => {});
53 }
54 }
55
56 async innertube(endpoint, body, client = WEB) {
57 const { ua, ...clientCtx } = client;
58 const ctx = { ...clientCtx, ...(this.visitorData && client === WEB ? { visitorData: this.visitorData } : {}) };
59 const res = await this._fetch(`https://www.youtube.com/youtubei/v1/${endpoint}?prettyPrint=false`, {
60 method: 'POST',
61 headers: { 'content-type': 'application/json', 'user-agent': ua || UA, 'accept-language': 'en-US,en;q=0.9' },
62 body: JSON.stringify({ context: { client: ctx }, ...body }),
63 });
64 const json = await res.json();
65 if (client === WEB && !this.visitorData) this.visitorData = json.responseContext?.visitorData ?? null;
66 return json;
67 }
68
69 async text(url) {
70 const res = await this._fetch(url, { headers: { 'user-agent': UA, 'accept-language': 'en-US,en;q=0.9' } });
71 return res.text();
72 }
73
74
75 async player(videoId) {
76 let last;
77 for (let round = 0; round < 3; round++) {
78 for (const client of PLAYER_CLIENTS) {
79 const d = await this.innertube('player', { videoId, contentCheckOk: true, racyCheckOk: true }, client);
80 const status = d.playabilityStatus?.status;
81 last = d;
82 if (status === 'OK' || d.captions) return d;
83 const reason = `${d.playabilityStatus?.reason || ''} ${d.playabilityStatus?.messages?.join(' ') || ''}`;
84 if (status === 'LOGIN_REQUIRED' && /bot|confirm/i.test(reason)) {
85 if (!this.useProxy && this.getProxyUrl) {
86 this.log.info('YouTube asked for a bot check; switching to proxy.');
87 this.useProxy = true;
88 }
89 continue;
90 }
91 if (status === 'ERROR' || status === 'UNPLAYABLE' || status === 'LOGIN_REQUIRED' || status === 'AGE_CHECK_REQUIRED') {
92
93 continue;
94 }
95 }
96 if (!this.useProxy) break;
97 }
98 return last;
99 }
100}
101
102
103
104export function parseVideoId(s) {
105 const t = String(s).trim();
106 if (/^[\w-]{11}$/.test(t)) return t;
107 const m = t.match(/(?:v=|youtu\.be\/|\/shorts\/|\/embed\/|\/live\/|\/v\/)([\w-]{11})/);
108 return m ? m[1] : null;
109}
110
111export function parsePlaylistId(s) {
112 const t = String(s).trim();
113 const m = t.match(/[?&]list=([\w-]+)/);
114 if (m) return m[1];
115 if (/^(PL|UU|OL|FL|RD|LL)[\w-]{10,}$/.test(t)) return t;
116 return null;
117}
118
119
120export async function resolveChannelId(yt, s) {
121 const t = String(s).trim();
122 const direct = t.match(/(UC[\w-]{22})/);
123 if (direct) return direct[1];
124 let url = t;
125 if (t.startsWith('@')) url = `https://www.youtube.com/${t}`;
126 else if (!/^https?:/.test(t)) url = `https://www.youtube.com/@${t.replace(/^@/, '')}`;
127 const d = await yt.innertube('navigation/resolve_url', { url });
128 const id = d.endpoint?.browseEndpoint?.browseId;
129 if (id?.startsWith('UC')) return id;
130 throw new Error(`Could not resolve channel "${s}"`);
131}
132
133const TAB_PARAMS = { videos: 'EgZ2aWRlb3PyBgQKAjoA', shorts: 'EgZzaG9ydHPyBgUKA5oBAA%3D%3D', streams: 'EgdzdHJlYW1z8gYECgJ6AA%3D%3D' };
134
135const idsIn = (s) => [...s.matchAll(/"(?:videoId|contentId)":"([\w-]{11})"/g)].map((m) => m[1]);
136const gridToken = (s) => s.match(/"continuationItemRenderer":\{.{0,600}?"continuationCommand":\{"token":"([^"]+)"/)?.[1] ?? null;
137
138async function collectPaged(yt, firstBody, { max, filter }) {
139 const out = new Set();
140 let d = await yt.innertube('browse', firstBody);
141 for (let page = 0; page < 500; page++) {
142 const s = JSON.stringify(d);
143 for (const id of filter ? filter(s) : idsIn(s)) {
144 out.add(id);
145 if (out.size >= max) return [...out];
146 }
147 const tok = gridToken(s);
148 if (!tok) break;
149 d = await yt.innertube('browse', { continuation: tok });
150 }
151 return [...out];
152}
153
154export async function listChannelVideos(yt, channelId, { tab = 'videos', max = 100 }) {
155
156 return collectPaged(yt, { browseId: channelId, params: TAB_PARAMS[tab] || TAB_PARAMS.videos }, { max });
157}
158
159export async function listPlaylistVideos(yt, playlistId, { max = 100 }) {
160 const re = new RegExp(`"watchEndpoint":\\{"videoId":"([\\w-]{11})","playlistId":"${playlistId.replace(/[-]/g, '\\-')}"`, 'g');
161 return collectPaged(yt, { browseId: `VL${playlistId}` }, { max, filter: (s) => [...s.matchAll(re)].map((m) => m[1]) });
162}
163
164export async function searchVideos(yt, query, { max = 20 }) {
165 const out = new Set();
166 let d = await yt.innertube('search', { query, params: 'EgIQAQ%3D%3D' });
167 for (let page = 0; page < 50; page++) {
168 const s = JSON.stringify(d);
169 for (const m of s.matchAll(/"videoRenderer":\{"videoId":"([\w-]{11})"/g)) {
170 out.add(m[1]);
171 if (out.size >= max) return [...out];
172 }
173 const tok = s.match(/"continuationCommand":\{"token":"([^"]+)","request":"CONTINUATION_REQUEST_TYPE_SEARCH"/)?.[1];
174 if (!tok) break;
175 d = await yt.innertube('search', { continuation: tok });
176 }
177 return [...out];
178}
179
180
181
182const trackName = (t) => t.name?.simpleText || t.name?.runs?.map((r) => r.text).join('') || t.languageCode;
183
184
185
186
187
188export function pickTrack(tracks, { languages = [], preferManual = true, fallbackToAny = true }) {
189 if (!tracks?.length) return null;
190 const langs = languages.map((l) => l.toLowerCase());
191 const isAuto = (t) => t.kind === 'asr';
192 const matches = (t, l) => t.languageCode.toLowerCase() === l || t.languageCode.toLowerCase().startsWith(`${l}-`);
193 const sorted = [...tracks].sort((a, b) => (preferManual ? Number(isAuto(a)) - Number(isAuto(b)) : 0));
194 for (const l of langs) {
195 const t = sorted.find((x) => matches(x, l));
196 if (t) return t;
197 }
198 if (langs.length && !fallbackToAny) return null;
199 return sorted[0];
200}
201
202export async function fetchTranscript(yt, track) {
203 const url = track.baseUrl.replace(/&fmt=[^&]*/g, '') + '&fmt=json3';
204 const body = await yt.text(url);
205 if (!body) throw new Error('Empty transcript response');
206 const d = JSON.parse(body);
207 const segments = [];
208 for (const e of d.events || []) {
209 if (!e.segs) continue;
210 const text = e.segs.map((s) => s.utf8).join('').replace(/\s+/g, ' ').trim();
211 if (!text) continue;
212 segments.push({ start: +(e.tStartMs / 1000).toFixed(3), duration: +((e.dDurationMs || 0) / 1000).toFixed(3), text });
213 }
214 return segments;
215}
216
217export function describeTracks(tracks) {
218 return (tracks || []).map((t) => ({ languageCode: t.languageCode, name: trackName(t), isAutoGenerated: t.kind === 'asr', isTranslatable: t.isTranslatable !== false }));
219}
220
221
222
223const pad = (n, w = 2) => String(Math.floor(n)).padStart(w, '0');
224function stamp(sec, sep) {
225 const ms = Math.round((sec % 1) * 1000);
226 return `${pad(sec / 3600)}:${pad((sec % 3600) / 60)}:${pad(sec % 60)}${sep}${pad(ms, 3)}`;
227}
228const endOf = (seg, next) => (seg.duration ? seg.start + seg.duration : next ? next.start : seg.start + 2);
229
230export function toSrt(segs) {
231 return segs.map((s, i) => `${i + 1}\n${stamp(s.start, ',')} --> ${stamp(endOf(s, segs[i + 1]), ',')}\n${s.text}\n`).join('\n');
232}
233
234export function toVtt(segs) {
235 return `WEBVTT\n\n${segs.map((s, i) => `${stamp(s.start, '.')} --> ${stamp(endOf(s, segs[i + 1]), '.')}\n${s.text}\n`).join('\n')}`;
236}
237
238export function toPlainText(segs) {
239
240 let out = '';
241 segs.forEach((s, i) => {
242 const prev = segs[i - 1];
243 const gap = prev ? s.start - endOf(prev, s) : 0;
244 if (prev) out += gap > 1.5 && /[.!?]["')\]]?$/.test(prev.text) ? '\n\n' : ' ';
245 out += s.text;
246 });
247 return out.trim();
248}
249
250export function toTimestampedText(segs) {
251 return segs.map((s) => `[${stamp(s.start, '.').slice(0, 8)}] ${s.text}`).join('\n');
252}