Skip to content

Instantly share code, notes, and snippets.

@DamianEdwards
Created April 21, 2026 01:12
Show Gist options
  • Select an option

  • Save DamianEdwards/11e0f5404ecf8657ece821bad1e582fd to your computer and use it in GitHub Desktop.

Select an option

Save DamianEdwards/11e0f5404ecf8657ece821bad1e582fd to your computer and use it in GitHub Desktop.
Discovers the captions feed URL for a YouTube video by fetching the watch page, extracting the Innertube API key, calling the player API, selecting a caption track, and producing a fmt=json3 timedtext URL. It also does a quick fetch of that URL to confirm the captions feed is reachable.
#!/usr/bin/env dotnet
// This script takes a YouTube video ID and discovers the video's captions feed URL.
//
// It:
// - fetches the YouTube watch page for the video
// - extracts the INNERTUBE_API_KEY from the page HTML
// - calls YouTube's internal player API to retrieve caption track metadata
// - selects an English caption track when available, otherwise falls back to the first track
// - reads the track's baseUrl and ensures it requests JSON captions (fmt=json3)
// - prints the final timedtext/captions URL
// - optionally fetches that URL once to confirm it is reachable
//
// Usage:
// dotnet new console -n YtTimedTextUrl
// (replace Program.cs with this file)
// dotnet run -- <VIDEO_ID>
#:property PublishAot=false
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
if (args.Length == 0)
{
Console.WriteLine("Usage: dotnet run -- <YouTubeVideoId>");
return;
}
var videoId = args[0].Trim();
if (string.IsNullOrWhiteSpace(videoId))
{
Console.WriteLine("Invalid video ID.");
return;
}
Console.WriteLine($"Video ID: {videoId}");
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.TryAddWithoutValidation(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
// 1. Fetch the watch page HTML to get the INNERTUBE_API_KEY
var watchUrl = $"https://www.youtube.com/watch?v={videoId}";
Console.WriteLine($"Fetching watch page: {watchUrl}");
string watchHtml;
try
{
watchHtml = await httpClient.GetStringAsync(watchUrl);
}
catch (Exception ex)
{
Console.WriteLine($"ERROR: Failed to fetch watch page: {ex.Message}");
return;
}
// 2. Extract INNERTUBE_API_KEY from the HTML
var apiKeyMatch = Regex.Match(
watchHtml,
"\"INNERTUBE_API_KEY\":\"(?<key>[^\"]+)\"",
RegexOptions.Compiled);
if (!apiKeyMatch.Success)
{
Console.WriteLine("ERROR: Could not find INNERTUBE_API_KEY in watch page HTML.");
return;
}
var apiKey = apiKeyMatch.Groups["key"].Value;
Console.WriteLine($"INNERTUBE_API_KEY: {apiKey}");
Console.WriteLine();
// 3. Call youtubei/v1/player to get player JSON (which includes captionTracks)
var playerUrl = $"https://www.youtube.com/youtubei/v1/player?key={apiKey}";
Console.WriteLine($"Calling player API: {playerUrl}");
var payload = new
{
context = new
{
client = new
{
hl = "en",
clientName = "WEB",
clientVersion = "2.20250101.00.00" // any reasonably current version usually works
}
},
videoId = videoId
};
var jsonBody = JsonSerializer.Serialize(payload);
var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
string playerJson;
try
{
var resp = await httpClient.PostAsync(playerUrl, content);
resp.EnsureSuccessStatusCode();
playerJson = await resp.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
Console.WriteLine($"ERROR: Failed to call youtubei player API: {ex.Message}");
return;
}
// 4. Parse JSON and find captions.playerCaptionsTracklistRenderer.captionTracks
using var doc = JsonDocument.Parse(playerJson);
var root = doc.RootElement;
if (!root.TryGetProperty("captions", out var captionsEl))
{
Console.WriteLine("No 'captions' section found. This video likely has no captions.");
return;
}
if (!captionsEl.TryGetProperty("playerCaptionsTracklistRenderer", out var tracklistEl))
{
Console.WriteLine("No 'playerCaptionsTracklistRenderer' found. No exposed caption tracks.");
return;
}
if (!tracklistEl.TryGetProperty("captionTracks", out var captionTracksEl) ||
captionTracksEl.ValueKind != JsonValueKind.Array ||
captionTracksEl.GetArrayLength() == 0)
{
Console.WriteLine("No 'captionTracks' array found or it is empty. No caption tracks available.");
return;
}
JsonElement? selectedTrack = null;
// Prefer an English track if available, otherwise take the first
foreach (var track in captionTracksEl.EnumerateArray())
{
var isEn = track.TryGetProperty("languageCode", out var langEl)
&& string.Equals(langEl.GetString(), "en", StringComparison.OrdinalIgnoreCase);
if (isEn)
{
selectedTrack = track;
break;
}
}
if (selectedTrack == null)
{
// No English track, just take first
selectedTrack = captionTracksEl[0];
}
JsonElement simpleTextEl= default;
var trackEl = selectedTrack.Value;
var hasName = trackEl.TryGetProperty("name", out var nameEl)
&& nameEl.TryGetProperty("simpleText", out simpleTextEl);
var trackName = hasName ? simpleTextEl.GetString() : "(unnamed)";
var hasLang = trackEl.TryGetProperty("languageCode", out var langCodeEl);
var langCode = hasLang ? langCodeEl.GetString() : "(unknown)";
if (!trackEl.TryGetProperty("baseUrl", out var baseUrlEl))
{
Console.WriteLine("Selected caption track has no baseUrl. Cannot build timedtext URL.");
return;
}
var baseUrl = baseUrlEl.GetString();
if (string.IsNullOrWhiteSpace(baseUrl))
{
Console.WriteLine("Selected caption track has empty baseUrl. Cannot build timedtext URL.");
return;
}
Console.WriteLine($"Selected track: \"{trackName}\" (lang={langCode})");
Console.WriteLine($"Base URL from API:");
Console.WriteLine(baseUrl);
Console.WriteLine();
// 5. Ensure fmt=json3 is present
string finalUrl;
if (baseUrl.Contains("fmt=", StringComparison.OrdinalIgnoreCase))
{
finalUrl = baseUrl;
}
else
{
var separator = baseUrl.Contains("?") ? "&" : "?";
finalUrl = baseUrl + separator + "fmt=json3";
}
Console.WriteLine("Timedtext JSON captions URL:");
Console.WriteLine(finalUrl);
Console.WriteLine();
// 6. Optional: quick sanity test
try
{
Console.WriteLine("Testing timedtext URL (HEAD-like check)...");
var resp = await httpClient.GetAsync(finalUrl);
Console.WriteLine($"HTTP {(int)resp.StatusCode} {resp.StatusCode}");
}
catch (Exception ex)
{
Console.WriteLine($"WARNING: Failed to fetch timedtext URL: {ex.Message}");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment