I'm building Linkee, a small app where you paste a link — an article, a YouTube video, a Facebook reel — and get a short AI summary you can read or listen to. I'm a manufacturing engineer, not a software developer, and I built it with AI coding tools. So when things broke, I had to understand why before I could fix them.
YouTube transcripts broke twice. Here are both problems and the ~60 lines of TypeScript that solved them.
Problem 1: it worked on my laptop, then died in production
My first version used a library that reads YouTube's captions directly. On my laptop it worked every time. The moment the backend went live on a cloud server, every request failed.
The reason is simple once you know it: YouTube blocks requests coming from datacenter IP addresses. A home connection looks like a person; a cloud server looks like a scraper. No amount of retrying fixes that.
The fix was to stop fetching captions myself and use a transcript API that has its own way in. I use Supadata: one HTTPS call with a video URL, and you get the transcript back as plain text.
One parameter matters a lot for your bill: mode. The default is auto — if a video has no captions, Supadata generates a transcript from the audio, which is billed per minute of video. I found out when four videos used up my whole free monthly quota. With mode=native you only ever get captions that already exist, at a flat 1 credit per video. Videos without captions simply return "not available", and I show the user a message instead.
Problem 2: English videos came back in Arabic
A few weeks later a user — me, testing — saved an English video and the "full text" view showed a transcript in Arabic.
Nothing was wrong with the API. Without a lang parameter, Supadata returns the first available caption track. Big channels ship captions translated into many languages, and ar sorts first alphabetically. So the transcript was a translation, not what the speaker said.
Asking for lang=en everywhere isn't the answer either: a Dutch news broadcast should give a Dutch transcript, not an English translation of it.
What I actually needed was to know which language is spoken in the video — and YouTube can tell you that:
-
videos.listreturnsdefaultAudioLanguage, the language the uploader set for the audio track. It costs 1 quota unit. - When the uploader didn't set it,
captions.listlists the caption tracks. YouTube's auto-generated track (trackKind: "asr") is always created from the speech itself, so its language is the spoken language. It costs 50 units.
The free YouTube Data API quota is 10,000 units a day, so this is effectively free.
The code
Node 18+ (it uses the built-in fetch). You need a Supadata API key and a YouTube Data API v3 key from Google Cloud — restrict the latter to "YouTube Data API v3" so it can't be used for anything else.
const SUPADATA_API_KEY = process.env.SUPADATA_API_KEY!;
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY!;
/** "en-US" -> "en": caption tracks use bare ISO 639-1 codes. */
function primaryLanguage(tag?: string | null): string | null {
const code = tag?.trim().split(/[-_]/)[0]?.toLowerCase();
return code && /^[a-z]{2,3}$/.test(code) ? code : null;
}
/** The language spoken in the video, according to YouTube itself. */
async function spokenLanguage(videoId: string): Promise<string | null> {
const api = (path: string) =>
fetch(`https://www.googleapis.com/youtube/v3/${path}&key=${YOUTUBE_API_KEY}`).then((r) =>
r.ok ? r.json() : null
);
// 1 unit: the language the uploader declared for the audio.
const video = await api(`videos?part=snippet&id=${videoId}`);
const declared = primaryLanguage(video?.items?.[0]?.snippet?.defaultAudioLanguage);
if (declared) return declared;
// 50 units: auto-generated ("asr") captions are always in the spoken language.
const captions = await api(`captions?part=snippet&videoId=${videoId}`);
const asr = captions?.items?.find((c: any) => c.snippet?.trackKind?.toLowerCase() === "asr");
return primaryLanguage(asr?.snippet?.language);
}
/** Long videos are processed as a job: poll it for up to a minute. */
async function pollJob(jobId: string): Promise<string> {
const deadline = Date.now() + 60_000;
for (let attempt = 0; Date.now() < deadline; attempt++) {
if (attempt > 0) await new Promise((r) => setTimeout(r, Math.min(2000, 300 * attempt)));
const res = await fetch(`https://api.supadata.ai/v1/transcript/${jobId}`, {
headers: { "x-api-key": SUPADATA_API_KEY },
});
if (!res.ok) continue;
const job = await res.json();
if (job.status === "failed" || job.error) throw new Error(`Transcript job failed: ${job.error}`);
if (typeof job.content === "string" && job.content.trim()) return job.content;
}
throw new Error("Transcript job timed out");
}
export async function getTranscript(videoId: string): Promise<{ text: string; lang: string }> {
const lang = (await spokenLanguage(videoId)) ?? "en";
const url = new URL("https://api.supadata.ai/v1/transcript");
url.searchParams.set("url", `https://www.youtube.com/watch?v=${videoId}`);
url.searchParams.set("text", "true"); // plain text instead of timed chunks
url.searchParams.set("mode", "native"); // existing captions only: 1 credit, never AI generation
url.searchParams.set("lang", lang); // the speaker's language, not the first track
const res = await fetch(url, { headers: { "x-api-key": SUPADATA_API_KEY } });
if (res.status === 202) {
const { jobId } = await res.json();
return { text: await pollJob(jobId), lang };
}
// 402 = out of credits, 429 = rate limited: tell your users it's on your side, not their video.
if (!res.ok) throw new Error(`Supadata ${res.status}: ${await res.text()}`);
const data = await res.json(); // { content, lang, availableLangs }
return { text: data.content, lang: data.lang ?? lang };
}
A few details worth copying:
-
langfalls back gracefully. If the video has no track in the requested language, Supadata returns its first available one instead of failing — so the worst case is the old behaviour, never an error. - Out of credits is not "no subtitles". Report 402/429 as a temporary problem on your side. Otherwise users try three more videos, conclude your app is broken, and leave.
- Keep the YouTube key on the server. It's a server-side environment variable here, never shipped to the browser.
The result
With both fixes in place, an English video gives an English transcript, a Dutch news broadcast gives a Dutch one, a German one gives German — even on channels that carry a dozen translated tracks. Each video costs one Supadata credit and usually one YouTube quota unit.
In Linkee the transcript then goes to the summarizer, which writes the summary in whatever language the user picked — so you can save a Dutch video and read (or listen to) the summary in English. It's free during the beta; I'd genuinely like to hear what breaks for you.
If you're building anything on top of YouTube content, Supadata saved me a lot of time.
Top comments (0)