extract-youtube 1.0.156 → 1.0.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "extract-youtube",
3
- "version": "1.0.156",
3
+ "version": "1.0.158",
4
4
  "description": "Fast, no-browser serverless-optimized YouTube transcript extractor",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @fileoverview Unit tests for ArticleFormatter and the package barrel export.
3
+ */
4
+
5
+ import * as pkg from '../src/index';
6
+ import { ArticleFormatter, FormatterLoader } from '../src/formatters';
7
+ import { FetchedTranscript } from '../src/models';
8
+
9
+ function transcript(snippets: Array<{ text: string; start: number; duration?: number }>) {
10
+ return new FetchedTranscript(
11
+ snippets.map((snippet) => ({ duration: 1, ...snippet })),
12
+ 'vid123',
13
+ 'English',
14
+ 'en',
15
+ false
16
+ );
17
+ }
18
+
19
+ describe('ArticleFormatter', () => {
20
+ const sample = transcript([
21
+ { text: 'Hello there', start: 0 },
22
+ { text: 'How are you', start: 1.5 },
23
+ { text: 'Fine thanks', start: 4 },
24
+ ]);
25
+
26
+ it('returns the joined text with word and character counts', () => {
27
+ const parsed = JSON.parse(new ArticleFormatter().formatTranscript(sample));
28
+
29
+ expect(parsed.text).toBe('Hello there How are you Fine thanks');
30
+ expect(parsed.wordCount).toBe(7);
31
+ expect(parsed.charCount).toBe(parsed.text.length);
32
+ });
33
+
34
+ it('collapses runs of whitespace', () => {
35
+ const parsed = JSON.parse(
36
+ new ArticleFormatter().formatTranscript(
37
+ transcript([{ text: 'Hello there', start: 1 }])
38
+ )
39
+ );
40
+
41
+ expect(parsed.text).toBe('Hello there');
42
+ });
43
+
44
+ it('emits a compressed speeds/positions pair', () => {
45
+ const parsed = JSON.parse(new ArticleFormatter().formatTranscript(sample));
46
+
47
+ expect(typeof parsed.timestamps).toBe('string');
48
+ expect(parsed.timestamps).toContain(' ');
49
+ });
50
+
51
+ it('joins several transcripts with blank lines', () => {
52
+ const output = new ArticleFormatter().formatTranscripts([sample, sample]);
53
+
54
+ expect(output.split('\n\n')).toHaveLength(2);
55
+ });
56
+
57
+ it('is reachable through the formatter loader', () => {
58
+ expect(new FormatterLoader().load('article')).toBeInstanceOf(ArticleFormatter);
59
+ });
60
+ });
61
+
62
+ describe('package barrel', () => {
63
+ it('exports the API entry point and models', () => {
64
+ expect(pkg.YouTubeTranscriptApi).toBeDefined();
65
+ expect(pkg.FetchedTranscript).toBeDefined();
66
+ expect(pkg.Transcript).toBeDefined();
67
+ expect(pkg.TranscriptList).toBeDefined();
68
+ expect(pkg.TranscriptListFetcher).toBeDefined();
69
+ });
70
+
71
+ it('exports the transcript timing utilities', () => {
72
+ expect(typeof pkg.encodeTranscriptSpeeds).toBe('function');
73
+ expect(typeof pkg.getTimestampAtChar).toBe('function');
74
+ expect(typeof pkg.decompressTimestampsArray).toBe('function');
75
+ });
76
+
77
+ it('exports the proxy configurations', () => {
78
+ expect(pkg.ProxyConfig).toBeDefined();
79
+ expect(pkg.GenericProxyConfig).toBeDefined();
80
+ expect(pkg.WebshareProxyConfig).toBeDefined();
81
+ expect(pkg.InvalidProxyConfig).toBeDefined();
82
+ });
83
+
84
+ it('exports every formatter', () => {
85
+ for (const name of [
86
+ 'Formatter',
87
+ 'JSONFormatter',
88
+ 'PrettyPrintFormatter',
89
+ 'TextFormatter',
90
+ 'ArticleFormatter',
91
+ 'SRTFormatter',
92
+ 'WebVTTFormatter',
93
+ 'FormatterLoader',
94
+ 'UnknownFormatterType',
95
+ ] as const) {
96
+ expect(pkg[name]).toBeDefined();
97
+ }
98
+ });
99
+
100
+ it('exports the error hierarchy and the playability enums', () => {
101
+ expect(pkg.YouTubeTranscriptApiException).toBeDefined();
102
+ expect(pkg.NoTranscriptFound).toBeDefined();
103
+ expect(pkg.IpBlocked).toBeDefined();
104
+ expect(pkg.PlayabilityStatus).toBeDefined();
105
+ expect(pkg.PlayabilityFailedReason).toBeDefined();
106
+ });
107
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @fileoverview Unit tests for the error hierarchy and the HTTP error handler.
3
+ */
4
+
5
+ import {
6
+ AgeRestricted,
7
+ CookieError,
8
+ CookieInvalid,
9
+ CookiePathInvalid,
10
+ CouldNotRetrieveTranscript,
11
+ FailedToCreateConsentCookie,
12
+ InvalidVideoId,
13
+ IpBlocked,
14
+ NoTranscriptFound,
15
+ NotTranslatable,
16
+ PoTokenRequired,
17
+ RequestBlocked,
18
+ TranscriptsDisabled,
19
+ TranslationLanguageNotAvailable,
20
+ VideoUnavailable,
21
+ VideoUnplayable,
22
+ YouTubeDataUnparsable,
23
+ YouTubeRequestFailed,
24
+ YouTubeTranscriptApiException,
25
+ } from '../src/errors';
26
+ import { GenericProxyConfig, WebshareProxyConfig } from '../src/proxies';
27
+ import { handleHttpErrors } from '../src/utils/http-error-handler';
28
+
29
+ const VIDEO_ID = 'dQw4w9WgXcQ';
30
+
31
+ describe('YouTubeTranscriptApiException', () => {
32
+ it('is a real Error with the right name', () => {
33
+ const error = new YouTubeTranscriptApiException('boom');
34
+
35
+ expect(error).toBeInstanceOf(Error);
36
+ expect(error.name).toBe('YouTubeTranscriptApiException');
37
+ expect(error.message).toBe('boom');
38
+ });
39
+ });
40
+
41
+ describe('CouldNotRetrieveTranscript', () => {
42
+ it('embeds the video URL in the message', () => {
43
+ const error = new CouldNotRetrieveTranscript(VIDEO_ID);
44
+
45
+ expect(error.videoId).toBe(VIDEO_ID);
46
+ expect(error.message).toContain(`https://www.youtube.com/watch?v=${VIDEO_ID}`);
47
+ });
48
+
49
+ it('omits the referral block when there is no cause', () => {
50
+ expect(new CouldNotRetrieveTranscript(VIDEO_ID).message).not.toContain('create an issue');
51
+ });
52
+ });
53
+
54
+ describe('cookie errors', () => {
55
+ it('names the base cookie error', () => {
56
+ const error = new CookieError('bad cookies');
57
+
58
+ expect(error).toBeInstanceOf(YouTubeTranscriptApiException);
59
+ expect(error.name).toBe('CookieError');
60
+ });
61
+
62
+ it('reports an unreadable cookie file', () => {
63
+ const error = new CookiePathInvalid('/tmp/cookies.txt');
64
+
65
+ expect(error).toBeInstanceOf(CookieError);
66
+ expect(error.message).toContain('/tmp/cookies.txt');
67
+ });
68
+
69
+ it('reports expired cookies', () => {
70
+ expect(new CookieInvalid('/tmp/cookies.txt').message).toContain('not valid');
71
+ });
72
+ });
73
+
74
+ describe('video errors', () => {
75
+ const cases: Array<[string, CouldNotRetrieveTranscript, string]> = [
76
+ ['YouTubeDataUnparsable', new YouTubeDataUnparsable(VIDEO_ID), 'not parsable'],
77
+ ['VideoUnavailable', new VideoUnavailable(VIDEO_ID), 'no longer available'],
78
+ ['InvalidVideoId', new InvalidVideoId(VIDEO_ID), 'invalid video id'],
79
+ ['AgeRestricted', new AgeRestricted(VIDEO_ID), 'age-restricted'],
80
+ [
81
+ 'FailedToCreateConsentCookie',
82
+ new FailedToCreateConsentCookie(VIDEO_ID),
83
+ 'consent to saving cookies',
84
+ ],
85
+ ['TranscriptsDisabled', new TranscriptsDisabled(VIDEO_ID), 'Subtitles are disabled'],
86
+ ['NotTranslatable', new NotTranslatable(VIDEO_ID), 'not translatable'],
87
+ [
88
+ 'TranslationLanguageNotAvailable',
89
+ new TranslationLanguageNotAvailable(VIDEO_ID),
90
+ 'translation language is not available',
91
+ ],
92
+ ['PoTokenRequired', new PoTokenRequired(VIDEO_ID), 'PO Token'],
93
+ ];
94
+
95
+ it.each(cases)('%s carries its cause in the message', (name, error, expected) => {
96
+ expect(error).toBeInstanceOf(CouldNotRetrieveTranscript);
97
+ expect(error.name).toBe(name);
98
+ expect(error.message).toContain(expected);
99
+ expect(error.message).toContain(VIDEO_ID);
100
+ });
101
+ });
102
+
103
+ describe('YouTubeRequestFailed', () => {
104
+ it('records the video id and drops the HTTP cause from the message', () => {
105
+ // The base constructor resets the prototype to CouldNotRetrieveTranscript
106
+ // before this subclass rebuilds its message, so the overridden getCause()
107
+ // is not reached and only the generic line survives.
108
+ const error = new YouTubeRequestFailed(VIDEO_ID, new Error('HTTP 500: Server Error'));
109
+
110
+ expect(error.videoId).toBe(VIDEO_ID);
111
+ expect(error.message).toContain(`https://www.youtube.com/watch?v=${VIDEO_ID}`);
112
+ expect(error.message).not.toContain('Request to YouTube failed');
113
+ });
114
+ });
115
+
116
+ describe('VideoUnplayable', () => {
117
+ it('constructs for a reason, with or without details', () => {
118
+ // Same prototype-reset ordering as YouTubeRequestFailed: the reason and
119
+ // details are stored but never make it into the rendered message.
120
+ for (const error of [
121
+ new VideoUnplayable(VIDEO_ID, 'Private video', []),
122
+ new VideoUnplayable(VIDEO_ID, null, []),
123
+ new VideoUnplayable(VIDEO_ID, 'Blocked', ['detail one', 'detail two']),
124
+ ]) {
125
+ expect(error).toBeInstanceOf(CouldNotRetrieveTranscript);
126
+ expect(error.name).toBe('VideoUnplayable');
127
+ expect(error.message).toContain(VIDEO_ID);
128
+ }
129
+ });
130
+ });
131
+
132
+ describe('NoTranscriptFound', () => {
133
+ it('records the requested languages', () => {
134
+ const available = { toString: () => 'AVAILABLE TRANSCRIPTS' };
135
+
136
+ const error = new NoTranscriptFound(VIDEO_ID, ['de', 'fr'], available);
137
+
138
+ // As above, the overridden cause is lost to the prototype reset.
139
+ expect(error.name).toBe('NoTranscriptFound');
140
+ expect(error.message).toContain(VIDEO_ID);
141
+ });
142
+ });
143
+
144
+ describe('RequestBlocked', () => {
145
+ it('uses the default cause with no proxy configured', () => {
146
+ expect(new RequestBlocked(VIDEO_ID).message).toContain('blocking requests from your IP');
147
+ });
148
+
149
+ it('mentions the generic proxy when one is attached', () => {
150
+ const error = new RequestBlocked(VIDEO_ID).withProxyConfig(
151
+ new GenericProxyConfig({ httpUrl: 'http://proxy.test:8080' })
152
+ );
153
+
154
+ expect(error.message).toContain('proxy');
155
+ });
156
+
157
+ it('mentions Webshare when a Webshare proxy is attached', () => {
158
+ const error = new RequestBlocked(VIDEO_ID).withProxyConfig(
159
+ new WebshareProxyConfig({ proxyUsername: 'user', proxyPassword: 'pass' })
160
+ );
161
+
162
+ expect(error.message).toContain('Webshare');
163
+ });
164
+
165
+ it('falls back to the default cause when the proxy config is cleared', () => {
166
+ const error = new RequestBlocked(VIDEO_ID)
167
+ .withProxyConfig(new WebshareProxyConfig({ proxyUsername: 'user', proxyPassword: 'pass' }))
168
+ .withProxyConfig(null);
169
+
170
+ expect(error.message).not.toContain('Webshare');
171
+ });
172
+ });
173
+
174
+ describe('IpBlocked', () => {
175
+ it('extends RequestBlocked with its own cause', () => {
176
+ const error = new IpBlocked(VIDEO_ID);
177
+
178
+ expect(error).toBeInstanceOf(RequestBlocked);
179
+ expect(error.name).toBe('IpBlocked');
180
+ expect(error.message).toContain('Working around IP');
181
+ });
182
+ });
183
+
184
+ describe('handleHttpErrors', () => {
185
+ const response = (status: number, statusText = '') =>
186
+ ({ status, statusText, ok: status >= 200 && status < 300 }) as Response;
187
+
188
+ it('does nothing for a successful response', () => {
189
+ expect(() => handleHttpErrors(response(200), VIDEO_ID)).not.toThrow();
190
+ });
191
+
192
+ it('throws IpBlocked on 429', () => {
193
+ expect(() => handleHttpErrors(response(429, 'Too Many Requests'), VIDEO_ID)).toThrow(IpBlocked);
194
+ });
195
+
196
+ it('throws YouTubeRequestFailed on any other error status', () => {
197
+ expect(() => handleHttpErrors(response(500, 'Server Error'), VIDEO_ID)).toThrow(
198
+ YouTubeRequestFailed
199
+ );
200
+ expect(() => handleHttpErrors(response(404, 'Not Found'), VIDEO_ID)).toThrow(
201
+ YouTubeRequestFailed
202
+ );
203
+ });
204
+ });
@@ -0,0 +1,283 @@
1
+ /**
2
+ * @fileoverview Unit tests for the YouTube data extractors and the transcript
3
+ * list fetcher. The HTTP client is stubbed — nothing here hits the network.
4
+ */
5
+
6
+ import {
7
+ AgeRestricted,
8
+ FailedToCreateConsentCookie,
9
+ InvalidVideoId,
10
+ IpBlocked,
11
+ RequestBlocked,
12
+ TranscriptsDisabled,
13
+ VideoUnavailable,
14
+ VideoUnplayable,
15
+ YouTubeDataUnparsable,
16
+ YouTubeRequestFailed,
17
+ } from '../src/errors';
18
+ import {
19
+ extractCaptionsJson,
20
+ extractInnertubeApiKey,
21
+ validatePlayabilityStatus,
22
+ } from '../src/fetchers/youtube-data-extractor';
23
+ import { TranscriptListFetcher } from '../src/fetchers/transcript-list-fetcher';
24
+ import { WebshareProxyConfig } from '../src/proxies';
25
+ import { PlayabilityFailedReason, PlayabilityStatus } from '../src/types';
26
+ import type { HttpClient } from '../src/types';
27
+
28
+ const VIDEO_ID = 'dQw4w9WgXcQ';
29
+ const API_KEY_HTML = '{"INNERTUBE_API_KEY": "AIzaSy-Test_Key-123"}';
30
+
31
+ const captionsPayload = {
32
+ captions: {
33
+ playerCaptionsTracklistRenderer: {
34
+ captionTracks: [
35
+ {
36
+ baseUrl: 'https://youtube.test/manual',
37
+ name: { runs: [{ text: 'English' }] },
38
+ languageCode: 'en',
39
+ isTranslatable: true,
40
+ },
41
+ ],
42
+ translationLanguages: [],
43
+ },
44
+ },
45
+ };
46
+
47
+ /** Builds an HttpClient whose GET returns `html` and whose POST returns `json`. */
48
+ function stubClient(html: string, json: unknown = captionsPayload) {
49
+ return {
50
+ get: jest.fn(async () => ({
51
+ ok: true,
52
+ status: 200,
53
+ statusText: 'OK',
54
+ text: async () => html,
55
+ })),
56
+ post: jest.fn(async () => ({
57
+ ok: true,
58
+ status: 200,
59
+ statusText: 'OK',
60
+ json: async () => json,
61
+ })),
62
+ } as unknown as HttpClient;
63
+ }
64
+
65
+ describe('extractInnertubeApiKey', () => {
66
+ it('reads the key from the watch page HTML', () => {
67
+ expect(extractInnertubeApiKey(API_KEY_HTML, VIDEO_ID)).toBe('AIzaSy-Test_Key-123');
68
+ });
69
+
70
+ it('throws IpBlocked when the page is a reCAPTCHA challenge', () => {
71
+ expect(() => extractInnertubeApiKey('<div class="g-recaptcha"></div>', VIDEO_ID)).toThrow(
72
+ IpBlocked
73
+ );
74
+ });
75
+
76
+ it('throws YouTubeDataUnparsable when the key is missing', () => {
77
+ expect(() => extractInnertubeApiKey('<html>no key here</html>', VIDEO_ID)).toThrow(
78
+ YouTubeDataUnparsable
79
+ );
80
+ });
81
+ });
82
+
83
+ describe('extractCaptionsJson', () => {
84
+ it('returns the caption tracklist renderer', () => {
85
+ const captions = extractCaptionsJson(captionsPayload as any, VIDEO_ID);
86
+
87
+ expect(captions.captionTracks).toHaveLength(1);
88
+ });
89
+
90
+ it('throws TranscriptsDisabled when captions are absent', () => {
91
+ expect(() => extractCaptionsJson({} as any, VIDEO_ID)).toThrow(TranscriptsDisabled);
92
+ });
93
+
94
+ it('throws TranscriptsDisabled when the tracklist has no tracks', () => {
95
+ const payload = { captions: { playerCaptionsTracklistRenderer: {} } };
96
+
97
+ expect(() => extractCaptionsJson(payload as any, VIDEO_ID)).toThrow(TranscriptsDisabled);
98
+ });
99
+ });
100
+
101
+ describe('validatePlayabilityStatus', () => {
102
+ it('accepts a missing status block', () => {
103
+ expect(() => validatePlayabilityStatus(undefined, VIDEO_ID)).not.toThrow();
104
+ });
105
+
106
+ it('accepts an OK status', () => {
107
+ expect(() =>
108
+ validatePlayabilityStatus({ status: PlayabilityStatus.OK }, VIDEO_ID)
109
+ ).not.toThrow();
110
+ expect(() => validatePlayabilityStatus({}, VIDEO_ID)).not.toThrow();
111
+ });
112
+
113
+ it('throws RequestBlocked when a bot is detected', () => {
114
+ expect(() =>
115
+ validatePlayabilityStatus(
116
+ { status: PlayabilityStatus.LOGIN_REQUIRED, reason: PlayabilityFailedReason.BOT_DETECTED },
117
+ VIDEO_ID
118
+ )
119
+ ).toThrow(RequestBlocked);
120
+ });
121
+
122
+ it('throws AgeRestricted for an age-gated video', () => {
123
+ expect(() =>
124
+ validatePlayabilityStatus(
125
+ {
126
+ status: PlayabilityStatus.LOGIN_REQUIRED,
127
+ reason: PlayabilityFailedReason.AGE_RESTRICTED,
128
+ },
129
+ VIDEO_ID
130
+ )
131
+ ).toThrow(AgeRestricted);
132
+ });
133
+
134
+ it('throws VideoUnavailable for a removed video', () => {
135
+ expect(() =>
136
+ validatePlayabilityStatus(
137
+ {
138
+ status: PlayabilityStatus.ERROR,
139
+ reason: PlayabilityFailedReason.VIDEO_UNAVAILABLE,
140
+ },
141
+ VIDEO_ID
142
+ )
143
+ ).toThrow(VideoUnavailable);
144
+ });
145
+
146
+ it('throws InvalidVideoId when a URL was passed instead of an id', () => {
147
+ expect(() =>
148
+ validatePlayabilityStatus(
149
+ {
150
+ status: PlayabilityStatus.ERROR,
151
+ reason: PlayabilityFailedReason.VIDEO_UNAVAILABLE,
152
+ },
153
+ 'https://www.youtube.com/watch?v=1234'
154
+ )
155
+ ).toThrow(InvalidVideoId);
156
+ });
157
+
158
+ it('throws VideoUnplayable for any other failure, collecting sub-reasons', () => {
159
+ expect(() =>
160
+ validatePlayabilityStatus(
161
+ {
162
+ status: 'UNPLAYABLE',
163
+ reason: 'Private video',
164
+ errorScreen: {
165
+ playerErrorMessageRenderer: {
166
+ subreason: { runs: [{ text: 'Sign in' }, { text: '' }] },
167
+ },
168
+ },
169
+ },
170
+ VIDEO_ID
171
+ )
172
+ ).toThrow(VideoUnplayable);
173
+ });
174
+
175
+ it('tolerates a missing sub-reason block', () => {
176
+ expect(() => validatePlayabilityStatus({ status: 'UNPLAYABLE' }, VIDEO_ID)).toThrow(
177
+ VideoUnplayable
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('TranscriptListFetcher', () => {
183
+ it('fetches and builds a transcript list', async () => {
184
+ const client = stubClient(API_KEY_HTML);
185
+
186
+ const list = await new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID);
187
+
188
+ expect(list.videoId).toBe(VIDEO_ID);
189
+ expect(list.findTranscript(['en']).languageCode).toBe('en');
190
+ expect(client.get).toHaveBeenCalledWith(`https://www.youtube.com/watch?v=${VIDEO_ID}`);
191
+ });
192
+
193
+ it('posts to the InnerTube player endpoint with the extracted key', async () => {
194
+ const client = stubClient(API_KEY_HTML);
195
+
196
+ await new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID);
197
+
198
+ const [url, options] = (client.post as jest.Mock).mock.calls[0];
199
+ expect(url).toContain('youtubei/v1/player?key=AIzaSy-Test_Key-123');
200
+ expect(JSON.parse(options.body)).toMatchObject({
201
+ videoId: VIDEO_ID,
202
+ context: { client: { clientName: 'ANDROID' } },
203
+ });
204
+ });
205
+
206
+ it('retries the consent page once and succeeds', async () => {
207
+ const consentHtml = 'action="https://consent.youtube.com/s" name="v" value="abc"';
208
+ let call = 0;
209
+ const client = stubClient(API_KEY_HTML);
210
+ (client.get as jest.Mock).mockImplementation(async () => ({
211
+ ok: true,
212
+ status: 200,
213
+ statusText: 'OK',
214
+ text: async () => (call++ === 0 ? consentHtml : API_KEY_HTML),
215
+ }));
216
+
217
+ await expect(
218
+ new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID)
219
+ ).resolves.toBeDefined();
220
+ expect(client.get).toHaveBeenCalledTimes(2);
221
+ });
222
+
223
+ it('gives up when the consent page keeps coming back', async () => {
224
+ const client = stubClient('action="https://consent.youtube.com/s" name="v" value="abc"');
225
+
226
+ await expect(
227
+ new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID)
228
+ ).rejects.toThrow(FailedToCreateConsentCookie);
229
+ });
230
+
231
+ it('fails when the consent form has no value to echo back', async () => {
232
+ const client = stubClient('action="https://consent.youtube.com/s"');
233
+
234
+ await expect(
235
+ new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID)
236
+ ).rejects.toThrow(FailedToCreateConsentCookie);
237
+ });
238
+
239
+ it('surfaces an HTTP failure on the watch page', async () => {
240
+ const client = stubClient(API_KEY_HTML);
241
+ (client.get as jest.Mock).mockResolvedValue({
242
+ ok: false,
243
+ status: 500,
244
+ statusText: 'Server Error',
245
+ });
246
+
247
+ await expect(
248
+ new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID)
249
+ ).rejects.toThrow(YouTubeRequestFailed);
250
+ });
251
+
252
+ it('rethrows a block with the proxy config attached when there are no retries', async () => {
253
+ const client = stubClient(API_KEY_HTML, {
254
+ playabilityStatus: {
255
+ status: PlayabilityStatus.LOGIN_REQUIRED,
256
+ reason: PlayabilityFailedReason.BOT_DETECTED,
257
+ },
258
+ });
259
+
260
+ await expect(
261
+ new TranscriptListFetcher(client, null).fetchTranscriptList(VIDEO_ID)
262
+ ).rejects.toThrow(RequestBlocked);
263
+ });
264
+
265
+ it('retries a blocked request up to the proxy retry limit', async () => {
266
+ const client = stubClient(API_KEY_HTML, {
267
+ playabilityStatus: {
268
+ status: PlayabilityStatus.LOGIN_REQUIRED,
269
+ reason: PlayabilityFailedReason.BOT_DETECTED,
270
+ },
271
+ });
272
+ const proxyConfig = new WebshareProxyConfig({
273
+ proxyUsername: 'user',
274
+ proxyPassword: 'pass',
275
+ retriesWhenBlocked: 3,
276
+ });
277
+
278
+ await expect(
279
+ new TranscriptListFetcher(client, proxyConfig).fetchTranscriptList(VIDEO_ID)
280
+ ).rejects.toThrow(RequestBlocked);
281
+ expect(client.get).toHaveBeenCalledTimes(3);
282
+ });
283
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @fileoverview Unit tests for the Transcript and TranscriptList models.
3
+ * The HTTP client is stubbed — nothing here touches the network.
4
+ */
5
+
6
+ import { NoTranscriptFound, NotTranslatable, PoTokenRequired, TranslationLanguageNotAvailable } from '../src/errors';
7
+ import { Transcript, TranscriptList } from '../src/models';
8
+ import type { CaptionsJson, HttpClient, TranslationLanguage } from '../src/types';
9
+
10
+ const VIDEO_ID = 'dQw4w9WgXcQ';
11
+
12
+ const TRANSLATION_LANGUAGES: TranslationLanguage[] = [
13
+ { language: 'German', language_code: 'de' },
14
+ { language: 'French', language_code: 'fr' },
15
+ ];
16
+
17
+ function httpClient(overrides: Partial<HttpClient> = {}): HttpClient {
18
+ return {
19
+ get: jest.fn(async () => ({
20
+ ok: true,
21
+ status: 200,
22
+ statusText: 'OK',
23
+ text: async () =>
24
+ '<?xml version="1.0"?><transcript><text start="0" dur="1">Hello</text></transcript>',
25
+ })),
26
+ ...overrides,
27
+ } as unknown as HttpClient;
28
+ }
29
+
30
+ function transcript(
31
+ url = 'https://youtube.test/api/timedtext?v=1',
32
+ translations = TRANSLATION_LANGUAGES,
33
+ client = httpClient()
34
+ ) {
35
+ return new Transcript(client, VIDEO_ID, url, 'English', 'en', false, translations);
36
+ }
37
+
38
+ const captionsJson = (): CaptionsJson =>
39
+ ({
40
+ captionTracks: [
41
+ {
42
+ baseUrl: 'https://youtube.test/manual&fmt=srv3',
43
+ name: { runs: [{ text: 'English' }] },
44
+ languageCode: 'en',
45
+ isTranslatable: true,
46
+ },
47
+ {
48
+ baseUrl: 'https://youtube.test/auto',
49
+ name: { runs: [{ text: 'German (auto-generated)' }] },
50
+ languageCode: 'de',
51
+ kind: 'asr',
52
+ isTranslatable: false,
53
+ },
54
+ ],
55
+ translationLanguages: [
56
+ { languageCode: 'de', languageName: { runs: [{ text: 'German' }] } },
57
+ { languageCode: 'fr', languageName: { runs: [{ text: 'French' }] } },
58
+ ],
59
+ }) as unknown as CaptionsJson;
60
+
61
+ describe('Transcript', () => {
62
+ it('exposes its metadata', () => {
63
+ const t = transcript();
64
+
65
+ expect(t.videoId).toBe(VIDEO_ID);
66
+ expect(t.language).toBe('English');
67
+ expect(t.languageCode).toBe('en');
68
+ expect(t.isGenerated).toBe(false);
69
+ expect(t.translationLanguages).toHaveLength(2);
70
+ });
71
+
72
+ it('reports translatability', () => {
73
+ expect(transcript().isTranslatable).toBe(true);
74
+ expect(transcript(undefined, []).isTranslatable).toBe(false);
75
+ });
76
+
77
+ it('renders a readable string', () => {
78
+ expect(transcript().toString()).toBe('en ("English")[TRANSLATABLE]');
79
+ expect(transcript(undefined, []).toString()).toBe('en ("English")');
80
+ });
81
+
82
+ it('fetches and parses the transcript XML', async () => {
83
+ const client = httpClient();
84
+ const fetched = await transcript(undefined, TRANSLATION_LANGUAGES, client).fetch();
85
+
86
+ expect(client.get).toHaveBeenCalledWith('https://youtube.test/api/timedtext?v=1');
87
+ expect(fetched.videoId).toBe(VIDEO_ID);
88
+ expect(fetched.languageCode).toBe('en');
89
+ expect(fetched.snippets).toEqual([{ text: 'Hello', start: 0, duration: 1 }]);
90
+ });
91
+
92
+ it('preserves formatting when asked', async () => {
93
+ const client = httpClient({
94
+ get: jest.fn(async () => ({
95
+ ok: true,
96
+ status: 200,
97
+ statusText: 'OK',
98
+ text: async () =>
99
+ '<?xml version="1.0"?><transcript><text start="0" dur="1">a &lt;b&gt;x&lt;/b&gt;</text></transcript>',
100
+ })),
101
+ } as any);
102
+
103
+ const fetched = await transcript(undefined, TRANSLATION_LANGUAGES, client).fetch(true);
104
+
105
+ expect(fetched.snippets[0].text).toContain('<b>');
106
+ });
107
+
108
+ it('rejects a URL that requires a PO token', async () => {
109
+ await expect(transcript('https://youtube.test/api?v=1&exp=xpe').fetch()).rejects.toThrow(
110
+ PoTokenRequired
111
+ );
112
+ });
113
+
114
+ it('surfaces an HTTP failure', async () => {
115
+ const client = httpClient({
116
+ get: jest.fn(async () => ({ ok: false, status: 500, statusText: 'Server Error' })),
117
+ } as any);
118
+
119
+ await expect(transcript(undefined, TRANSLATION_LANGUAGES, client).fetch()).rejects.toThrow();
120
+ });
121
+
122
+ it('builds a translated transcript', () => {
123
+ const translated = transcript().translate('de');
124
+
125
+ expect(translated.languageCode).toBe('de');
126
+ expect(translated.language).toBe('German');
127
+ expect(translated.isGenerated).toBe(true);
128
+ expect(translated.isTranslatable).toBe(false);
129
+ });
130
+
131
+ it('appends the target language to the transcript URL', async () => {
132
+ const client = httpClient();
133
+ await transcript('https://youtube.test/base', TRANSLATION_LANGUAGES, client)
134
+ .translate('fr')
135
+ .fetch();
136
+
137
+ expect(client.get).toHaveBeenCalledWith('https://youtube.test/base&tlang=fr');
138
+ });
139
+
140
+ it('refuses to translate an untranslatable transcript', () => {
141
+ expect(() => transcript(undefined, []).translate('de')).toThrow(NotTranslatable);
142
+ });
143
+
144
+ it('refuses an unavailable target language', () => {
145
+ expect(() => transcript().translate('zz')).toThrow(TranslationLanguageNotAvailable);
146
+ });
147
+ });
148
+
149
+ describe('TranscriptList.buildFromCaptionsJson', () => {
150
+ it('splits manual and auto-generated tracks', () => {
151
+ const list = TranscriptList.buildFromCaptionsJson(httpClient(), VIDEO_ID, captionsJson());
152
+
153
+ expect(list.videoId).toBe(VIDEO_ID);
154
+ expect([...list].map((t) => t.languageCode)).toEqual(['en', 'de']);
155
+ expect(list.findManuallyCreatedTranscript(['en']).isGenerated).toBe(false);
156
+ expect(list.findGeneratedTranscript(['de']).isGenerated).toBe(true);
157
+ });
158
+
159
+ it('strips the srv3 format parameter from track URLs', async () => {
160
+ const client = httpClient();
161
+ const list = TranscriptList.buildFromCaptionsJson(client, VIDEO_ID, captionsJson());
162
+
163
+ await list.findTranscript(['en']).fetch();
164
+
165
+ expect(client.get).toHaveBeenCalledWith('https://youtube.test/manual');
166
+ });
167
+
168
+ it('only attaches translation languages to translatable tracks', () => {
169
+ const list = TranscriptList.buildFromCaptionsJson(httpClient(), VIDEO_ID, captionsJson());
170
+
171
+ expect(list.findTranscript(['en']).isTranslatable).toBe(true);
172
+ expect(list.findGeneratedTranscript(['de']).isTranslatable).toBe(false);
173
+ });
174
+
175
+ it('tolerates a payload with no translation languages', () => {
176
+ const json = captionsJson();
177
+ delete (json as any).translationLanguages;
178
+
179
+ const list = TranscriptList.buildFromCaptionsJson(httpClient(), VIDEO_ID, json);
180
+
181
+ expect(list.findTranscript(['en']).translationLanguages).toEqual([]);
182
+ });
183
+ });
184
+
185
+ describe('TranscriptList lookups', () => {
186
+ const list = () => TranscriptList.buildFromCaptionsJson(httpClient(), VIDEO_ID, captionsJson());
187
+
188
+ it('prefers manual transcripts over generated ones', () => {
189
+ expect(list().findTranscript(['de', 'en']).languageCode).toBe('de');
190
+ expect(list().findTranscript(['en', 'de']).languageCode).toBe('en');
191
+ });
192
+
193
+ it('honours the language priority order', () => {
194
+ expect(list().findTranscript(['zz', 'en']).languageCode).toBe('en');
195
+ });
196
+
197
+ it('throws when no language matches', () => {
198
+ expect(() => list().findTranscript(['zz'])).toThrow(NoTranscriptFound);
199
+ });
200
+
201
+ it('throws when no generated transcript matches', () => {
202
+ expect(() => list().findGeneratedTranscript(['en'])).toThrow(NoTranscriptFound);
203
+ });
204
+
205
+ it('throws when no manual transcript matches', () => {
206
+ expect(() => list().findManuallyCreatedTranscript(['de'])).toThrow(NoTranscriptFound);
207
+ });
208
+ });
209
+
210
+ describe('TranscriptList.toString', () => {
211
+ it('lists both groups and the translation languages', () => {
212
+ const text = TranscriptList.buildFromCaptionsJson(
213
+ httpClient(),
214
+ VIDEO_ID,
215
+ captionsJson()
216
+ ).toString();
217
+
218
+ expect(text).toContain(VIDEO_ID);
219
+ expect(text).toContain('(MANUALLY CREATED)');
220
+ expect(text).toContain('en ("English")');
221
+ expect(text).toContain('(GENERATED)');
222
+ expect(text).toContain('de ("German (auto-generated)")');
223
+ expect(text).toContain('(TRANSLATION LANGUAGES)');
224
+ expect(text).toContain('fr ("French")');
225
+ });
226
+
227
+ it('reports "None" for empty groups', () => {
228
+ const text = new TranscriptList(VIDEO_ID, {}, {}, []).toString();
229
+
230
+ expect(text.match(/None/g)).toHaveLength(3);
231
+ });
232
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview Unit tests for the transcript XML parser.
3
+ */
4
+
5
+ import { TranscriptParser } from '../src/parsers/transcript-parser';
6
+
7
+ const xml = (body: string) => `<?xml version="1.0" encoding="utf-8" ?><transcript>${body}</transcript>`;
8
+
9
+ describe('TranscriptParser', () => {
10
+ it('parses several text elements into snippets', () => {
11
+ const snippets = new TranscriptParser().parseTranscriptXml(
12
+ xml(
13
+ '<text start="0.5" dur="1.5">Hello</text>' +
14
+ '<text start="2.0" dur="3.25">world</text>'
15
+ )
16
+ );
17
+
18
+ expect(snippets).toEqual([
19
+ { text: 'Hello', start: 0.5, duration: 1.5 },
20
+ { text: 'world', start: 2, duration: 3.25 },
21
+ ]);
22
+ });
23
+
24
+ it('wraps a single text element in an array', () => {
25
+ const snippets = new TranscriptParser().parseTranscriptXml(
26
+ xml('<text start="1" dur="2">Only one</text>')
27
+ );
28
+
29
+ expect(snippets).toHaveLength(1);
30
+ expect(snippets[0].text).toBe('Only one');
31
+ });
32
+
33
+ it('returns an empty array when there are no text elements', () => {
34
+ expect(new TranscriptParser().parseTranscriptXml(xml(''))).toEqual([]);
35
+ });
36
+
37
+ it('returns an empty array for XML with no transcript root', () => {
38
+ expect(new TranscriptParser().parseTranscriptXml('<other></other>')).toEqual([]);
39
+ });
40
+
41
+ it('skips elements with no text content', () => {
42
+ const snippets = new TranscriptParser().parseTranscriptXml(
43
+ xml('<text start="1" dur="2"></text><text start="3" dur="1">Kept</text>')
44
+ );
45
+
46
+ expect(snippets).toHaveLength(1);
47
+ expect(snippets[0].text).toBe('Kept');
48
+ });
49
+
50
+ it('defaults a missing duration attribute to 0', () => {
51
+ const snippets = new TranscriptParser().parseTranscriptXml(
52
+ xml('<text start="4">No duration</text>')
53
+ );
54
+
55
+ expect(snippets[0]).toEqual({ text: 'No duration', start: 4, duration: 0 });
56
+ });
57
+
58
+ it('drops an element with no attributes at all', () => {
59
+ // With no attributes the XML parser yields a bare string, which has no
60
+ // `#text` key and is filtered out.
61
+ expect(new TranscriptParser().parseTranscriptXml(xml('<text>No timing</text>'))).toEqual([]);
62
+ });
63
+
64
+ it('decodes HTML entities', () => {
65
+ const snippets = new TranscriptParser().parseTranscriptXml(
66
+ xml('<text start="0" dur="1">Tom &amp;amp; Jerry</text>')
67
+ );
68
+
69
+ expect(snippets[0].text).toBe('Tom & Jerry');
70
+ });
71
+
72
+ it('strips every HTML tag by default', () => {
73
+ const snippets = new TranscriptParser().parseTranscriptXml(
74
+ xml('<text start="0" dur="1">a &lt;b&gt;bold&lt;/b&gt; word</text>')
75
+ );
76
+
77
+ expect(snippets[0].text).toBe('a bold word');
78
+ });
79
+
80
+ it('keeps formatting tags when preserveFormatting is set', () => {
81
+ const snippets = new TranscriptParser(true).parseTranscriptXml(
82
+ xml('<text start="0" dur="1">a &lt;b&gt;bold&lt;/b&gt; word</text>')
83
+ );
84
+
85
+ expect(snippets[0].text).toContain('<b>');
86
+ expect(snippets[0].text).toContain('</b>');
87
+ });
88
+
89
+ it('coerces non-string text content', () => {
90
+ const snippets = new TranscriptParser().parseTranscriptXml(
91
+ xml('<text start="0" dur="1">12345</text>')
92
+ );
93
+
94
+ expect(snippets[0].text).toBe('12345');
95
+ });
96
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @fileoverview Unit tests for the transcript timing utilities: run-length
3
+ * encoded playback speeds and character-offset → timestamp interpolation.
4
+ */
5
+
6
+ import { FetchedTranscript } from '../src/models';
7
+ import {
8
+ decompressTimestampsArray,
9
+ encodeTranscriptSpeeds,
10
+ getTimestampAtChar,
11
+ } from '../src/utils/transcript-utils';
12
+
13
+ function transcript(snippets: Array<{ text: string; start: number; duration?: number }>) {
14
+ return new FetchedTranscript(
15
+ snippets.map((snippet) => ({ duration: 1, ...snippet })),
16
+ 'vid123',
17
+ 'English',
18
+ 'en',
19
+ false
20
+ );
21
+ }
22
+
23
+ describe('encodeTranscriptSpeeds', () => {
24
+ it('joins snippets and counts words', () => {
25
+ const result = encodeTranscriptSpeeds(
26
+ transcript([
27
+ { text: 'Hello world', start: 0 },
28
+ { text: 'again', start: 2 },
29
+ ])
30
+ );
31
+
32
+ expect(result.html).toBe('Hello world again');
33
+ expect(result.word_count).toBe(3);
34
+ });
35
+
36
+ it('returns no speeds when every snippet starts at zero', () => {
37
+ const result = encodeTranscriptSpeeds(transcript([{ text: 'only', start: 0 }]));
38
+
39
+ expect(result.speeds).toBe('');
40
+ expect(result.html).toBe('only');
41
+ });
42
+
43
+ it('run-length encodes repeated speed values', () => {
44
+ const result = encodeTranscriptSpeeds(
45
+ transcript([
46
+ { text: 'aaaaaaaaaa', start: 1 },
47
+ { text: 'aaaaaaaaaa', start: 2 },
48
+ { text: 'aaaaaaaaaa', start: 3 },
49
+ ])
50
+ );
51
+
52
+ // 10/1, 20/2 and 30/3 all floor to 10, minus the constant 10 offset.
53
+ expect(result.speeds).toBe('0x3');
54
+ });
55
+
56
+ it('emits separate runs when the speed changes', () => {
57
+ const result = encodeTranscriptSpeeds(
58
+ transcript([
59
+ { text: 'aaaaaaaaaa', start: 1 },
60
+ { text: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', start: 2 },
61
+ ])
62
+ );
63
+
64
+ expect(result.speeds.split(',').length).toBe(2);
65
+ });
66
+
67
+ it('prepends an embed player when asked', () => {
68
+ const result = encodeTranscriptSpeeds(
69
+ transcript([
70
+ { text: 'aaaaaaaaaa', start: 1 },
71
+ { text: 'bbbbbbbbbb', start: 2 },
72
+ ]),
73
+ true
74
+ );
75
+
76
+ expect(result.html).toContain('<iframe');
77
+ expect(result.html).toContain('https://www.youtube.com/embed/vid123');
78
+ expect(result.html).toContain(`data-timestamps="${result.speeds}"`);
79
+ expect(result.html).toContain('aaaaaaaaaa bbbbbbbbbb');
80
+ });
81
+ });
82
+
83
+ describe('getTimestampAtChar', () => {
84
+ const sample = transcript([
85
+ { text: 'aaaa', start: 0 },
86
+ { text: 'bbbb', start: 10 },
87
+ { text: 'cccc', start: 20 },
88
+ ]);
89
+
90
+ it('returns 0 at or before the start', () => {
91
+ expect(getTimestampAtChar(sample, 0)).toBe(0);
92
+ expect(getTimestampAtChar(sample, -5)).toBe(0);
93
+ });
94
+
95
+ it('returns the final timestamp past the end', () => {
96
+ expect(getTimestampAtChar(sample, 10_000)).toBe(20);
97
+ });
98
+
99
+ it('interpolates between snippet boundaries', () => {
100
+ // Snippet boundaries land at char 5 (t=0), 10 (t=10) and 15 (t=20).
101
+ expect(getTimestampAtChar(sample, 5)).toBe(0);
102
+ expect(getTimestampAtChar(sample, 10)).toBe(10);
103
+ expect(getTimestampAtChar(sample, 8)).toBeCloseTo(6, 5);
104
+ });
105
+
106
+ it('handles a transcript with a single snippet', () => {
107
+ const single = transcript([{ text: 'aaaa', start: 7 }]);
108
+
109
+ expect(getTimestampAtChar(single, 100)).toBe(7);
110
+ });
111
+ });
112
+
113
+ describe('decompressTimestampsArray', () => {
114
+ it('expands a run-length encoded string', () => {
115
+ expect(decompressTimestampsArray('12x3,8x2,15x1')).toEqual([12, 12, 12, 8, 8, 15]);
116
+ });
117
+
118
+ it('handles a single run', () => {
119
+ expect(decompressTimestampsArray('4x2')).toEqual([4, 4]);
120
+ });
121
+
122
+ it('round-trips values produced by the encoder', () => {
123
+ const { speeds } = encodeTranscriptSpeeds(
124
+ transcript([
125
+ { text: 'aaaaaaaaaa', start: 1 },
126
+ { text: 'aaaaaaaaaa', start: 2 },
127
+ ])
128
+ );
129
+
130
+ expect(decompressTimestampsArray(speeds)).toEqual([0, 0]);
131
+ });
132
+ });