Created
April 21, 2026 01:12
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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