Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 179 additions & 120 deletions lib/__tests__/youtube-transcript-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@ import test from 'node:test';
import assert from 'node:assert/strict';

import {
buildCaptionTrackCandidates,
extractCaptionTracksFromWatchHtml,
fetchYouTubeTranscript,
transformCaptionJsonToSegments,
transformCaptionXmlToSegments,
TranscriptProviderError,
} from '../youtube-transcript-provider';

function withMockFetch(
Expand All @@ -21,124 +18,177 @@ function withMockFetch(
});
}

test('extractCaptionTracksFromWatchHtml returns caption tracks from player response', () => {
const html = `
<html>
<body>
<script>
var ytInitialPlayerResponse = {"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://example.com/en","languageCode":"en","name":{"simpleText":"English"}},{"baseUrl":"https://example.com/fr-auto","languageCode":"fr","name":{"simpleText":"Francais"},"kind":"asr"}]}}};
</script>
</body>
</html>
`;

const tracks = extractCaptionTracksFromWatchHtml(html);

assert.deepEqual(tracks, [
{
baseUrl: 'https://example.com/en',
languageCode: 'en',
kind: undefined,
name: 'English',
},
{
baseUrl: 'https://example.com/fr-auto',
languageCode: 'fr',
kind: 'asr',
name: 'Francais',
// Minimal YouTube watch page HTML with INNERTUBE_API_KEY embedded
// (our provider scrapes this before calling InnerTube)
const FAKE_WATCH_PAGE = `
<html><body><script>
ytcfg.set({"INNERTUBE_API_KEY":"AIzaFakeKey","INNERTUBE_CLIENT_VERSION":"2.20250326","VISITOR_DATA":"fakeVisitor"});
</script></body></html>
`;

test('fetchYouTubeTranscript returns transcript when Android client succeeds', async () => {
await withMockFetch(
async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();

// Page scrape request
if (url.includes('youtube.com/watch')) {
return new Response(FAKE_WATCH_PAGE);
}

// InnerTube player request — return caption tracks
if (url.includes('/youtubei/v1/player')) {
return new Response(JSON.stringify({
playabilityStatus: { status: 'OK' },
captions: {
playerCaptionsTracklistRenderer: {
captionTracks: [
{
baseUrl: 'https://captions.test/en',
languageCode: 'en',
name: { simpleText: 'English' },
},
{
baseUrl: 'https://captions.test/fr',
languageCode: 'fr',
name: { simpleText: 'Francais' },
kind: 'asr',
},
],
},
},
}));
}

// Caption track fetch — return XML with <p> format (milliseconds)
if (url.startsWith('https://captions.test/en')) {
return new Response(`<?xml version="1.0"?><timedtext><body>
<p t="420" d="4200">hello &amp; welcome</p>
<p t="5100" d="1500">&#39;quoted&#39;</p>
</body></timedtext>`);
}

throw new Error(`Unexpected fetch URL: ${url}`);
},
]);
});
async () => {
const result = await fetchYouTubeTranscript('video123');

test('buildCaptionTrackCandidates prioritizes requested language and manual tracks', () => {
const tracks = [
{ baseUrl: 'https://example.com/en-auto', languageCode: 'en', kind: 'asr', name: 'English auto' },
{ baseUrl: 'https://example.com/fr-auto', languageCode: 'fr', kind: 'asr', name: 'Francais auto' },
{ baseUrl: 'https://example.com/fr', languageCode: 'fr', kind: undefined, name: 'Francais' },
{ baseUrl: 'https://example.com/de', languageCode: 'de', kind: undefined, name: 'Deutsch' },
];

const candidates = buildCaptionTrackCandidates(tracks, 'fr');

assert.deepEqual(
candidates.map((track) => track.baseUrl),
[
'https://example.com/fr',
'https://example.com/fr-auto',
'https://example.com/en-auto',
'https://example.com/de',
]
assert.ok(result, 'Should return a result');
assert.equal(result.language, 'en');
assert.deepEqual(result.availableLanguages, ['en', 'fr']);
assert.equal(result.segments.length, 2);
assert.equal(result.segments[0].text, 'hello & welcome');
assert.equal(result.segments[0].start, 0.42);
assert.equal(result.segments[0].duration, 4.2);
assert.equal(result.segments[1].text, "'quoted'");
}
);
});

test('transformCaptionJsonToSegments decodes entities and ignores empty events', () => {
const segments = transformCaptionJsonToSegments({
events: [
{
tStartMs: 1500,
dDurationMs: 2500,
segs: [{ utf8: 'Hello &amp; ' }, { utf8: 'welcome' }],
},
{
tStartMs: 4000,
dDurationMs: 1000,
},
{
tStartMs: 5000,
dDurationMs: 1250,
segs: [{ utf8: '&#39;quoted&#39;' }],
},
],
});
test('fetchYouTubeTranscript prefers requested language', async () => {
await withMockFetch(
async (input) => {
const url = typeof input === 'string' ? input : input.toString();

assert.deepEqual(segments, [
{
text: 'Hello & welcome',
start: 1.5,
duration: 2.5,
},
{
text: "'quoted'",
start: 5,
duration: 1.25,
if (url.includes('youtube.com/watch')) {
return new Response(FAKE_WATCH_PAGE);
}

if (url.includes('/youtubei/v1/player')) {
return new Response(JSON.stringify({
playabilityStatus: { status: 'OK' },
captions: {
playerCaptionsTracklistRenderer: {
captionTracks: [
{
baseUrl: 'https://captions.test/en',
languageCode: 'en',
name: { simpleText: 'English' },
},
{
baseUrl: 'https://captions.test/fr',
languageCode: 'fr',
name: { simpleText: 'Francais' },
},
],
},
},
}));
}

// Should request French since we asked for it
if (url.startsWith('https://captions.test/fr')) {
return new Response(`<?xml version="1.0"?><timedtext><body>
<p t="0" d="1000">bonjour</p>
</body></timedtext>`);
}

if (url.startsWith('https://captions.test/en')) {
return new Response(`<?xml version="1.0"?><timedtext><body>
<p t="0" d="1000">hello</p>
</body></timedtext>`);
}

throw new Error(`Unexpected fetch URL: ${url}`);
},
]);
async () => {
const result = await fetchYouTubeTranscript('video123', 'fr');

assert.ok(result);
assert.equal(result.language, 'fr');
assert.equal(result.segments[0].text, 'bonjour');
}
);
});

test('transformCaptionXmlToSegments parses youtube timedtext xml', () => {
const xml = `<?xml version="1.0" encoding="utf-8" ?><transcript><text start="0.42" dur="4.2">hello &amp; welcome</text><text start="5.1" dur="1.5">&#39;quoted&#39;</text></transcript>`;
test('fetchYouTubeTranscript returns null when video has no captions', async () => {
await withMockFetch(
async (input) => {
const url = typeof input === 'string' ? input : input.toString();

const segments = transformCaptionXmlToSegments(xml);
if (url.includes('youtube.com/watch')) {
return new Response(FAKE_WATCH_PAGE);
}

assert.deepEqual(segments, [
{
text: 'hello & welcome',
start: 0.42,
duration: 4.2,
},
{
text: "'quoted'",
start: 5.1,
duration: 1.5,
if (url.includes('/youtubei/v1/player')) {
// No captions object at all
return new Response(JSON.stringify({
playabilityStatus: { status: 'OK' },
}));
}

throw new Error(`Unexpected fetch URL: ${url}`);
},
]);
async () => {
const result = await fetchYouTubeTranscript('video123');
assert.equal(result, null);
}
);
});

test('fetchYouTubeTranscript preserves an explicitly requested language', async () => {
test('fetchYouTubeTranscript tries next client when one is rate-limited', async () => {
let innerTubeCallCount = 0;

await withMockFetch(
async (input) => {
const url = typeof input === 'string' ? input : input.toString();

if (url.includes('youtube.com/watch')) {
return new Response(FAKE_WATCH_PAGE);
}

if (url.includes('/youtubei/v1/player')) {
innerTubeCallCount++;
// First call (Android) returns 429 rate limit
if (innerTubeCallCount === 1) {
return new Response('Too Many Requests', { status: 429 });
}
// Second call (Web) succeeds
return new Response(JSON.stringify({
playabilityStatus: { status: 'OK' },
captions: {
playerCaptionsTracklistRenderer: {
captionTracks: [
{
baseUrl: 'https://captions.test/fr',
languageCode: 'fr',
name: { simpleText: 'Francais' },
},
{
baseUrl: 'https://captions.test/en',
languageCode: 'en',
Expand All @@ -150,39 +200,37 @@ test('fetchYouTubeTranscript preserves an explicitly requested language', async
}));
}

if (url === 'https://captions.test/fr') {
return new Response('<transcript><text start="0" dur="1">bonjour</text></transcript>');
}

if (url === 'https://captions.test/en') {
return new Response('<transcript><text start="0" dur="800">hello</text></transcript>');
if (url.startsWith('https://captions.test/en')) {
return new Response(`<?xml version="1.0"?><timedtext><body>
<p t="0" d="1000">hello from fallback</p>
</body></timedtext>`);
}

throw new Error(`Unexpected fetch URL: ${url}`);
},
async () => {
const result = await fetchYouTubeTranscript('video123', 'fr', 1200);

assert.equal(result?.language, 'fr');
assert.deepEqual(result?.availableLanguages, ['fr', 'en']);
assert.deepEqual(result?.segments, [
{
text: 'bonjour',
start: 0,
duration: 1,
},
]);
const result = await fetchYouTubeTranscript('video123');

assert.ok(result, 'Should succeed via fallback client');
assert.equal(result.segments[0].text, 'hello from fallback');
// Should have tried at least 2 InnerTube calls (Android failed, Web succeeded)
assert.ok(innerTubeCallCount >= 2, `Expected >= 2 InnerTube calls, got ${innerTubeCallCount}`);
}
);
});

test('fetchYouTubeTranscript throws when caption tracks exist but all fetches fail', async () => {
test('fetchYouTubeTranscript parses legacy <text> XML format', async () => {
await withMockFetch(
async (input) => {
const url = typeof input === 'string' ? input : input.toString();

if (url.includes('youtube.com/watch')) {
return new Response(FAKE_WATCH_PAGE);
}

if (url.includes('/youtubei/v1/player')) {
return new Response(JSON.stringify({
playabilityStatus: { status: 'OK' },
captions: {
playerCaptionsTracklistRenderer: {
captionTracks: [
Expand All @@ -197,14 +245,25 @@ test('fetchYouTubeTranscript throws when caption tracks exist but all fetches fa
}));
}

if (url === 'https://captions.test/en') {
return new Response('', { status: 500 });
// Return legacy XML format (seconds, <text> tags)
if (url.startsWith('https://captions.test/en')) {
return new Response(`<?xml version="1.0"?>
<transcript>
<text start="0.42" dur="4.2">hello &amp; welcome</text>
<text start="5.1" dur="1.5">goodbye</text>
</transcript>`);
}

throw new Error(`Unexpected fetch URL: ${url}`);
},
async () => {
await assert.rejects(() => fetchYouTubeTranscript('video123'));
const result = await fetchYouTubeTranscript('video123');

assert.ok(result);
assert.equal(result.segments.length, 2);
assert.equal(result.segments[0].text, 'hello & welcome');
assert.equal(result.segments[0].start, 0.42);
assert.equal(result.segments[0].duration, 4.2);
}
);
});
Loading
Loading