Collapsed history snapshot — represents the cumulative state of TubeSage through 1.2.21. Earlier commit history (covering versions 1.2.0 through 1.2.20) was rewritten into this single root commit to remove tracking of a personal deploy script that referenced local filesystem paths. The current release content is unchanged. See manifest.json for the plugin version. Older tags (1.2.0 through 1.2.20) have been retired. Features in this release line: - YouTube transcript extraction (ScrapeCreators API + local fallbacks) - LLM-driven summarisation (OpenAI, Anthropic, Google, Ollama) - Per-provider always-visible API key rows - Selected-provider model controls with registry-aware overrides - Custom-model parameter overrides with synchronous panel refresh - Max tokens field with blur-commit and reset-on-blank - Settings de-pollution migration on plugin load - Summary-callout split for multi-section model output - Security patches: fast-xml-parser >=5.7.0, uuid >=14.0.0
38 KiB
Restoring a “local directed” YouTube transcript fetch in a TypeScript/Obsidian codebase
Executive summary
Your existing “local transcript” method broke because it depends on private youtubei endpoints (/player, /get_transcript) that are now returning HTTP 400 FAILED_PRECONDITION in your logs, making that path brittle and hard to “patch” reliably without chasing integrity/session requirements. fileciteturn0file0
The most robust local approach is to pivot to what the watch page already exposes: parse the /watch?v=VIDEO_ID HTML for the ytInitialPlayerResponse JSON object and read captions.playerCaptionsTracklistRenderer.captionTracks[].baseUrl. Multiple independent implementations and fixes in the wild rely on this exact technique. citeturn1view2turn4view1turn7view0
Once you have baseUrl, fetch captions via YouTube’s timedtext endpoint using fmt=json3 (or fmt=vtt if you prefer WebVTT). The json3 payload is a structured event stream (wireMagic: "pb3", events[].tStartMs, events[].dDurationMs, events[].segs[].utf8) that can be parsed into your TranscriptSegment[]. citeturn3search3turn12search5turn4view0
This report provides (a) a concrete implementation plan and (b) a drop‑in patch for src/youtube-transcript.ts to make fetchTranscript() reliable again, with explicit handling for language selection, translation (tlang), format parameter pitfalls (replace, don’t append), rate‑limiting, fallbacks, user‑visible error messaging, and tests.
What broke and why the old method is brittle
Your project notes show two historic “local” phases: (1) WEB next → get_transcript and (2) ANDROID player → captionTracks[].baseUrl → timedtext. Both now fail with FAILED_PRECONDITION ("Precondition check failed.") as of 2026‑03‑05. fileciteturn0file0
This failure pattern aligns with wider ecosystem evidence that YouTube frequently changes preconditions for internal API clients; for example, downloader and proxy projects report “precondition check failed” issues tied to client versions, A/B tests, or newly required tokens. citeturn11search0turn11search8turn11search2
The implication is pragmatic: treat youtubei/* as experimental and default to approaches that do not depend on internal request integrity. Your own findings already recommend moving to watch‑page parsing and timedtext downloads (and making internal endpoints opt‑in). fileciteturn0file0
A second operational reality is throttling: raw page/API fetches can hit 429 Too Many Requests, and many tools work around this with deliberate slowing/backoff. citeturn2search2turn10view0
Watch-page captions extraction design
Core idea: fetch the watch HTML, extract ytInitialPlayerResponse, read caption tracks, choose a track, then fetch captions from the track’s baseUrl in json3.
Example objects you will parse
ytInitialPlayerResponse (minimal sketch):
{
"playabilityStatus": { "status": "OK" },
"captions": {
"playerCaptionsTracklistRenderer": {
"captionTracks": [
{
"baseUrl": "https://www.youtube.com/api/timedtext?...&lang=en&v=VIDEO_ID&fmt=srv3&...",
"languageCode": "en",
"vssId": ".en",
"kind": "asr",
"isTranslatable": true
}
]
}
}
}
The object path captions.playerCaptionsTracklistRenderer.captionTracks[0].baseUrl is widely used as the entry point for downloading captions. citeturn1view2turn4view1turn7view0
Field names like baseUrl, vssId, languageCode, and optional kind are consistent with multiple open implementations and type definitions. citeturn4view0turn4view1turn0search22
json3 timedtext response shape (minimal sketch):
{
"wireMagic": "pb3",
"events": [
{ "tStartMs": 80, "dDurationMs": 3119, "segs": [ { "utf8": "hey" }, { "utf8": " everybody" } ] }
]
}
The wireMagic: "pb3" marker and events[].segs[].utf8 text fragments (with per‑event timing`) are documented by real payload examples and consumer code. citeturn3search3turn12search5turn4view0
Robust extraction from watch HTML
Many “quick scripts” do brittle string splits on ytInitialPlayerResponse = ... and terminate on ;var or ;</script>. citeturn4view1turn4view2turn7view0
For a plugin you ship, the robust pattern is:
- Locate the marker:
ytInitialPlayerResponse(handlevar ytInitialPlayerResponse =andytInitialPlayerResponse =variants). citeturn7view0turn4view1 - From the first
{after the marker, extract a balanced JSON object using brace counting while respecting strings/escapes (so nested objects don’t break and}inside strings is ignored). This is an implementation technique (not YouTube‑specific), but it directly addresses the brittleness shown in split‑based examples. citeturn4view1turn7view0
If you already parse ytInitialPlayerResponse for metadata, extend that code to also read captionTracks. Your own notes say you already parse it for metadata; this change is a small increment. fileciteturn0file0
Language selection heuristics that work in practice
Empirically (and in multiple reference scripts/libraries):
kind === "asr"indicates auto‑generated captions. citeturn1view1turn12search20turn4view1vssIdoften encodes track type and language; examples show leading.for “regular” anda.for auto‑generated (a.en). citeturn4view0turn4view1- If the desired language isn’t available, you can request machine translation via
tlang=<targetLanguage>on the timedtext URL (this is used by practical scripts; treat the result as machine translation quality). citeturn4view1turn9search1
A pragmatic heuristic for a note‑taking plugin:
- Prefer manual captions in requested language (exact match on BCP‑47, then base language match).
- Else prefer manual captions in any language then use
tlangto translate to requested base language (e.g.,enforen-GB). citeturn4view1turn9search1 - Else prefer auto captions in requested language. citeturn1view1turn4view1
- Else pick the “best available” track (manual first, then auto) and optionally translate. citeturn4view1turn1view1
Query parameters that matter
fmt: You should request fmt=json3 if you want structured segment timing and easy parsing. citeturn3search3turn4view1turn4view0
However, multiple sources warn that you must replace an existing fmt=srv3 rather than append another &fmt=... because duplicates may cause the first fmt to win. citeturn2search1turn4view0turn1view1
tlang: Add tlang=<lang> only when you are intentionally requesting a translation. citeturn4view1turn9search1
Alternative formats: timedtext can return WebVTT (useful for compatibility) via fmt=vtt, and other formats exist in tooling outputs. citeturn0search1turn2search4turn12search14
Signature/cipher handling for caption URLs
In most implementations that read captionTracks, the captions URL is provided as a working baseUrl you can request directly. citeturn1view2turn4view1turn7view0
If you encounter a track that provides a cipher string (rare for captions, common for streaming formats), then:
- If the cipher contains an already‑deciphered
sig/signature, you can reconstruct the URL by appending it. - If it contains an encrypted
s=signature, full deciphering requires parsing YouTube player JS (high complexity, brittle; generally outside a “lightweight Obsidian plugin” scope). Evidence from downloader tooling shows signature extraction is a moving target and can fail when preconditions change. citeturn11search8turn2search10turn0search10
The plan below implements “best effort” support for cipher strings that already include sig, and provides clear error surfaces when deciphering would be required.
Rate limiting and fail-fast policy
Given the repeated evidence of throttling and precondition failures, your plugin should:
- Retry only transient failures (timeouts, selected 5xx, and 429 with
Retry-After). citeturn2search2turn11search12turn10view0 - Fail fast (no retries) on deterministic failures like
FAILED_PRECONDITIONfrom internal endpoints. citeturn11search8turn11search0turn0file0
Step-by-step implementation plan and drop-in TypeScript patch
Implementation plan
Step one: implement a first-class “watch-page captions” method
fetchWatchHtml(videoId)to GET/watch?v=...with a stable browser UA andAccept-Language: en-GB. citeturn4view1turn7view0- If the HTML indicates consent interstitial or missing player JSON, retry once with a
CONSENT=YES+1cookie (best-effort). citeturn8search1 extractInitialPlayerResponse(html)using brace-balanced extraction. (This replaces brittle split tricks.) citeturn7view0turn4view1- Read
captionTracksand select withpickTrack(tracks, requestedLang). citeturn1view2turn4view1turn0search22 - Build a final caption URL:
- replace existing
fmtwithjson3(don’t append duplicates). citeturn2search1turn4view0 - add
tlangonly if you decided to translate. citeturn4view1turn9search1
- replace existing
fetchCaptionTrack(url)and parsejson3events into segments. citeturn3search3turn12search5
Step two: add fallbacks
- Timedtext list fallback: call
https://www.youtube.com/api/timedtext?type=list&v=VIDEO_IDto enumerate tracks if the watch page didn’t exposecaptionTracks. citeturn12search1 youtubeifallback behind an explicit experimental toggle (default off), fail fast onFAILED_PRECONDITION, as your own notes recommend. fileciteturn0file0- Third-party Supadata fallback when configured (unchanged conceptually; just make messaging accurate). fileciteturn0file0
Step three: improve UX/error reporting Record which methods were attempted and only mention those in user-visible output (your notes call out current inaccurate messaging). fileciteturn0file0
Mermaid decision flow for fetchTranscript
flowchart TD
A[fetchTranscript(videoId, lang)] --> B[GET /watch HTML]
B -->|consent/page incomplete| B2[Retry with CONSENT cookie]
B --> C[Extract ytInitialPlayerResponse]
C -->|captionTracks present| D[Pick best caption track]
C -->|no captionTracks| E[Timedtext type=list fallback]
D --> F[Build URL: fmt=json3 + optional tlang]
F --> G[GET timedtext JSON3]
G --> H[Parse events -> TranscriptSegment[]]
E -->|track found| F
E -->|none| I[Optional: experimental youtubei fallback]
I -->|success| H
I -->|FAILED_PRECONDITION/blocked| J[Optional: Supadata fallback]
J -->|success| H
J -->|fail| K[Surface clear error + attempted methods]
Drop-in TypeScript patch
This patch assumes a typical Obsidian plugin pattern:
- Your network layer is
obsidianFetch(...)wrappingrequestUrl. fileciteturn0file0 - You already have (or can add) a
TranscriptSegmenttype and a class/namespace containingfetchTranscript().
You will likely need to adjust imports and return types to match your existing code, but the functions below are intentionally “drop‑in”: fetchWatchHtml, extractInitialPlayerResponse, pickTrack, and fetchCaptionTrack are explicitly provided.
// src/youtube-transcript.ts
// Drop-in patch: prefer watch-page captionTracks -> timedtext json3, with robust parsing and fallbacks.
//
// Notes:
// - Do not append fmt=json3 if fmt already exists; replace it.
// - tlang is optional; use only when you want machine translation.
// - youtubei endpoints should be behind an explicit experimental toggle.
type TranscriptSegment = {
text: string;
start: number; // seconds
duration: number; // seconds
};
type CaptionTrack = {
baseUrl?: string;
languageCode?: string; // e.g. "en", "en-GB"
vssId?: string; // e.g. ".en", "a.en"
kind?: string; // "asr" means auto-generated
isTranslatable?: boolean;
name?: { simpleText?: string; runs?: Array<{ text?: string }> };
// Some implementations may also encounter cipher-style fields.
signatureCipher?: string;
};
type PlayerResponse = {
playabilityStatus?: { status?: string; reason?: string };
captions?: {
playerCaptionsTracklistRenderer?: {
captionTracks?: CaptionTrack[];
translationLanguages?: Array<{ languageCode?: string; languageName?: any }>;
};
};
};
type FetchTranscriptOptions = {
lang?: string; // preferred language, e.g. "en-GB"
enableExperimentalYoutubei?: boolean; // default false
enableTimedtextListFallback?: boolean; // default true
enableSupadataFallback?: boolean; // existing behaviour
debug?: boolean;
};
class YoutubeTranscriptError extends Error {
public readonly code:
| "NO_CAPTIONS"
| "UNPLAYABLE"
| "CONSENT_REQUIRED"
| "RATE_LIMITED"
| "NETWORK"
| "PARSE"
| "EXPERIMENTAL_BLOCKED"
| "UNKNOWN";
public readonly attempts: string[];
constructor(code: YoutubeTranscriptError["code"], message: string, attempts: string[] = []) {
super(message);
this.code = code;
this.attempts = attempts;
}
}
// You already have this in your codebase (per your notes).
// Adjust signature to your fetch shim.
async function obsidianFetchText(url: string, opts: { headers?: Record<string, string>; timeoutMs?: number } = {}) {
// Replace with your actual shim (requestUrl wrapper).
// Must return: { status: number, headers: Record<string,string>, text: string }
throw new Error("obsidianFetchText not wired");
}
async function obsidianFetchJson(url: string, opts: { headers?: Record<string, string>; timeoutMs?: number } = {}) {
// Replace with your actual shim.
throw new Error("obsidianFetchJson not wired");
}
export class YouTubeTranscriptExtractor {
private static readonly WATCH_BASE = "https://www.youtube.com/watch?v=";
private static readonly TIMEDTEXT_LIST = "https://www.youtube.com/api/timedtext?type=list&v=";
// A stable browser UA is used by many extractor fixes.
private static readonly USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36";
public async fetchTranscript(videoIdOrUrl: string, opts: FetchTranscriptOptions = {}): Promise<TranscriptSegment[]> {
const attempts: string[] = [];
const requestedLang = (opts.lang ?? "en-GB").trim();
const videoId = this.extractVideoId(videoIdOrUrl);
// 1) Prefer watch-page captions (local directed)
try {
attempts.push("watch-page");
const html = await fetchWatchHtml(videoId, { lang: requestedLang, debug: !!opts.debug });
const player = extractInitialPlayerResponse(html);
const playStatus = player?.playabilityStatus?.status;
if (playStatus && playStatus !== "OK") {
// UNPLAYABLE, LOGIN_REQUIRED, AGE_RESTRICTED, etc.
throw new YoutubeTranscriptError(
"UNPLAYABLE",
`Video is not playable without additional access (playabilityStatus=${playStatus}).`,
attempts
);
}
const tracks = player?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
if (!tracks.length) {
throw new YoutubeTranscriptError("NO_CAPTIONS", "No captionTracks found in ytInitialPlayerResponse.", attempts);
}
const trackPick = pickTrack(tracks, requestedLang);
const finalUrl = buildCaptionUrl(trackPick, requestedLang);
attempts.push("timedtext-json3");
const segments = await fetchCaptionTrack(finalUrl, { debug: !!opts.debug });
if (!segments.length) {
throw new YoutubeTranscriptError("NO_CAPTIONS", "Caption download succeeded but produced 0 segments.", attempts);
}
return segments;
} catch (err) {
if (opts.debug) console.warn("watch-page method failed:", err);
// continue fallbacks
}
// 2) Timedtext list fallback (public-ish)
if (opts.enableTimedtextListFallback !== false) {
try {
attempts.push("timedtext-list");
const tracks = await fetchTimedtextTrackList(videoId);
if (tracks.length) {
const trackPick = pickTrack(tracks, requestedLang);
const finalUrl = buildCaptionUrl(trackPick, requestedLang);
attempts.push("timedtext-json3");
const segments = await fetchCaptionTrack(finalUrl, { debug: !!opts.debug });
if (segments.length) return segments;
}
} catch (err) {
if (opts.debug) console.warn("timedtext-list fallback failed:", err);
}
}
// 3) Experimental youtubei fallback (default off)
if (opts.enableExperimentalYoutubei) {
try {
attempts.push("youtubei-experimental");
// Wire to your existing youtubei implementation, but fail-fast on FAILED_PRECONDITION.
// const segments = await this.fetchViaYoutubei(videoId, requestedLang);
// if (segments.length) return segments;
} catch (err: any) {
// If it's FAILED_PRECONDITION, do NOT retry.
throw new YoutubeTranscriptError(
"EXPERIMENTAL_BLOCKED",
"YouTube blocked internal API (FAILED_PRECONDITION). Try watch-page mode or Supadata.",
attempts
);
}
}
// 4) Supadata fallback (if configured) – keep existing behaviour
// attempts.push("supadata");
// if (opts.enableSupadataFallback) return await this.fetchViaSupadata(...)
throw new YoutubeTranscriptError(
"NO_CAPTIONS",
`Could not obtain captions. Attempted: ${attempts.join(", ")}.`,
attempts
);
}
private extractVideoId(input: string): string {
// Keep your existing implementation if you already have one.
// This is intentionally permissive and mirrors common patterns.
const trimmed = input.trim();
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
// Try URL parsing first
try {
const u = new URL(trimmed);
if (u.hostname === "youtu.be") {
const id = u.pathname.replace("/", "");
if (/^[a-zA-Z0-9_-]{11}$/.test(id)) return id;
}
const v = u.searchParams.get("v");
if (v && /^[a-zA-Z0-9_-]{11}$/.test(v)) return v;
// /shorts/ID or /embed/ID
const m = u.pathname.match(/\/(shorts|embed)\/([a-zA-Z0-9_-]{11})/);
if (m?.[2]) return m[2];
} catch {
// fall through
}
// Regex fallback
const m = trimmed.match(/(?:v=|\/)([a-zA-Z0-9_-]{11})(?:\b|$)/);
if (m?.[1]) return m[1];
throw new YoutubeTranscriptError("PARSE", "Unable to extract YouTube video ID from input.", []);
}
}
// --- watch page fetch + consent handling ---
async function fetchWatchHtml(
videoId: string,
opts: { lang: string; debug: boolean }
): Promise<string> {
const url = `${YouTubeTranscriptExtractor.WATCH_BASE}${videoId}&hl=${encodeURIComponent(opts.lang)}`;
const headersBase: Record<string, string> = {
"User-Agent": YouTubeTranscriptExtractor.USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-GB,en;q=0.9",
};
// First attempt without consent cookie
const res1 = await obsidianFetchText(url, { headers: headersBase, timeoutMs: 15000 });
if (res1.status === 429) {
throw new YoutubeTranscriptError("RATE_LIMITED", "YouTube returned 429 while fetching watch HTML.", ["watch-page"]);
}
if (looksLikeConsentPage(res1.text)) {
// Best-effort retry with a minimal CONSENT cookie.
// NOTE: This may not work in all regions or in the future.
const headers2 = { ...headersBase, "Cookie": "CONSENT=YES+1" };
const res2 = await obsidianFetchText(url, { headers: headers2, timeoutMs: 15000 });
if (looksLikeConsentPage(res2.text)) {
throw new YoutubeTranscriptError("CONSENT_REQUIRED", "YouTube consent page blocked transcript extraction.", ["watch-page"]);
}
return res2.text;
}
return res1.text;
}
function looksLikeConsentPage(html: string): boolean {
// Heuristics: consent host or typical consent interstitial markers.
const h = html.toLowerCase();
return h.includes("consent.youtube.com") || h.includes("before you continue") || h.includes("consent.google.com");
}
// --- ytInitialPlayerResponse extraction (brace-balanced) ---
function extractInitialPlayerResponse(html: string): PlayerResponse {
// Try a few marker variants seen in the wild.
const markers = [
"var ytInitialPlayerResponse =",
"ytInitialPlayerResponse =",
];
for (const m of markers) {
const idx = html.indexOf(m);
if (idx === -1) continue;
const startBrace = html.indexOf("{", idx);
if (startBrace === -1) continue;
const jsonText = extractBalancedJsonObject(html, startBrace);
try {
return JSON.parse(jsonText) as PlayerResponse;
} catch (e: any) {
throw new YoutubeTranscriptError("PARSE", `Failed to parse ytInitialPlayerResponse JSON (${e?.message ?? "unknown"}).`, ["watch-page"]);
}
}
throw new YoutubeTranscriptError("PARSE", "ytInitialPlayerResponse not found in watch HTML.", ["watch-page"]);
}
function extractBalancedJsonObject(text: string, startIndex: number): string {
// Brace counting with string/escape awareness.
let depth = 0;
let inString = false;
let escape = false;
for (let i = startIndex; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escape) {
escape = false;
} else if (ch === "\\") {
escape = true;
} else if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
return text.slice(startIndex, i + 1);
}
}
}
throw new YoutubeTranscriptError("PARSE", "Unterminated JSON object while extracting ytInitialPlayerResponse.", ["watch-page"]);
}
// --- caption track selection ---
function pickTrack(tracks: CaptionTrack[], requestedLang: string): CaptionTrack {
const normReq = normaliseLangTag(requestedLang);
const reqBase = normReq.split("-")[0];
const scored = tracks.map((t) => {
const lang = normaliseLangTag(t.languageCode ?? "");
const vss = (t.vssId ?? "").toLowerCase();
const isAuto = (t.kind ?? "").toLowerCase() === "asr" || vss.startsWith("a.");
const isManual = !isAuto;
let score = 0;
// Exact language match
if (lang === normReq) score += 100;
// Base language match (en matches en-GB, etc.)
if (lang && reqBase && lang.split("-")[0] === reqBase) score += 60;
// Manual preferred
if (isManual) score += 30;
else score += 10;
// vssId hints: ".en" (manual) vs "a.en" (auto)
if (vss === `.${reqBase}`) score += 20;
if (vss === `a.${reqBase}`) score += 10;
// Prefer translatable tracks if we need fallback translation later
if (t.isTranslatable) score += 5;
return { t, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.t ?? tracks[0];
}
function normaliseLangTag(tag: string): string {
// Normalise "en_GB" -> "en-gb" and trim.
return tag.trim().replace("_", "-").toLowerCase();
}
// --- caption URL construction (fmt/tlang + cipher handling) ---
function buildCaptionUrl(track: CaptionTrack, requestedLang: string): string {
let baseUrl = track.baseUrl ?? "";
// Best-effort: handle cipher-style URLs if baseUrl is missing.
if (!baseUrl && track.signatureCipher) {
baseUrl = tryBuildUrlFromSignatureCipher(track.signatureCipher);
}
if (!baseUrl) {
throw new YoutubeTranscriptError("PARSE", "Caption track did not contain a usable baseUrl.", ["watch-page"]);
}
const u = new URL(baseUrl);
// fmt must be replaced, not duplicated.
u.searchParams.delete("fmt");
u.searchParams.set("fmt", "json3");
// Translation: only add tlang if we don't have the requested language directly.
const trackLang = normaliseLangTag(track.languageCode ?? "");
const reqBase = normaliseLangTag(requestedLang).split("-")[0];
const trackBase = trackLang.split("-")[0];
if (reqBase && trackBase && reqBase !== trackBase) {
// Use base language for tlang to maximise acceptance (pragmatic).
u.searchParams.set("tlang", reqBase);
} else {
u.searchParams.delete("tlang");
}
return u.toString();
}
function tryBuildUrlFromSignatureCipher(signatureCipher: string): string {
// signatureCipher is typically a querystring: "url=...&sp=sig&sig=..."
const p = new URLSearchParams(signatureCipher);
const url = p.get("url");
if (!url) return "";
const sp = p.get("sp") ?? "signature";
const sig = p.get("sig") ?? p.get("signature");
if (sig) {
const u = new URL(url);
u.searchParams.set(sp, sig);
return u.toString();
}
// Encrypted 's' would require deciphering player JS. We fail with a clear message.
if (p.get("s")) {
throw new YoutubeTranscriptError(
"PARSE",
"Caption URL requires signature deciphering (signatureCipher.s present). Not supported in local mode.",
["watch-page"]
);
}
return url;
}
// --- timedtext json3 fetch + parse ---
async function fetchCaptionTrack(
url: string,
opts: { debug: boolean }
): Promise<TranscriptSegment[]> {
const headers: Record<string, string> = {
"User-Agent": YouTubeTranscriptExtractor.USER_AGENT,
"Accept-Language": "en-GB,en;q=0.9",
};
const json = await obsidianFetchJson(url, { headers, timeoutMs: 15000 });
// Adjust according to your shim: some return {status, json}, some return json only.
const data = (json as any)?.json ?? json;
if (!data || data.wireMagic !== "pb3") {
// Some cases return XML if fmt wasn't applied correctly.
throw new YoutubeTranscriptError("PARSE", "Unexpected caption payload (expected json3/pb3).", ["timedtext-json3"]);
}
const events = Array.isArray(data.events) ? data.events : [];
const segments: TranscriptSegment[] = [];
for (const ev of events) {
if (!ev || !Array.isArray(ev.segs) || typeof ev.tStartMs !== "number") continue;
const textRaw = ev.segs.map((s: any) => (typeof s?.utf8 === "string" ? s.utf8 : "")).join("");
const text = cleanCaptionText(decodeHtmlEntities(textRaw));
if (!text) continue;
const start = ev.tStartMs / 1000;
const duration = typeof ev.dDurationMs === "number" ? ev.dDurationMs / 1000 : 0;
segments.push({ text, start, duration });
}
return segments;
}
function decodeHtmlEntities(input: string): string {
// Lightweight decoder (no DOM dependency).
// Expand as needed.
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, "\"")
.replace(/'/g, "'")
.replace(/'/g, "'");
}
function cleanCaptionText(s: string): string {
// Keep this conservative; callers can do heavier post-processing.
return s.replace(/\s+/g, " ").trim();
}
// --- timedtext type=list fallback (XML) ---
async function fetchTimedtextTrackList(videoId: string): Promise<CaptionTrack[]> {
const url = `${YouTubeTranscriptExtractor.TIMEDTEXT_LIST}${videoId}`;
const res = await obsidianFetchText(url, {
headers: {
"User-Agent": YouTubeTranscriptExtractor.USER_AGENT,
"Accept-Language": "en-GB,en;q=0.9",
},
timeoutMs: 15000,
});
if (res.status === 429) {
throw new YoutubeTranscriptError("RATE_LIMITED", "YouTube returned 429 while fetching timedtext track list.", ["timedtext-list"]);
}
if (res.status >= 400) {
throw new YoutubeTranscriptError("NETWORK", `Timedtext tracklist request failed (HTTP ${res.status}).`, ["timedtext-list"]);
}
// Parse XML without DOM libs: a minimal regex parse for <track ... /> elements.
const tracks: CaptionTrack[] = [];
const re = /<track\b([^>]*)\/>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(res.text)) !== null) {
const attrs = m[1];
const langCode = matchXmlAttr(attrs, "lang_code");
const name = matchXmlAttr(attrs, "name");
const kind = matchXmlAttr(attrs, "kind"); // "asr" appears here for auto captions in many cases
// Build a timedtext URL similar to what baseUrl would point to.
// NOTE: name may need URL encoding.
if (!langCode) continue;
const timedtextUrl = new URL("https://www.youtube.com/api/timedtext");
timedtextUrl.searchParams.set("v", videoId);
timedtextUrl.searchParams.set("lang", langCode);
if (name) timedtextUrl.searchParams.set("name", name);
if (kind) timedtextUrl.searchParams.set("kind", kind);
tracks.push({
baseUrl: timedtextUrl.toString(),
languageCode: langCode,
kind,
// crude vssId approximation (matches common conventions)
vssId: (kind === "asr" ? `a.${langCode}` : `.${langCode}`),
isTranslatable: true,
});
}
return tracks;
}
function matchXmlAttr(attrs: string, name: string): string | undefined {
const re = new RegExp(`${name}="([^"]*)"`);
const m = attrs.match(re);
return m?.[1];
}
Key implementation choices in this patch are grounded in observed behaviour and reference implementations:
ytInitialPlayerResponseis extracted from a script within the watch HTML and used to locatecaptionTracks[].baseUrl. citeturn7view0turn4view1turn1view2- Auto-generated tracks are identified via
kind: "asr"and/orvssIdconventions such asa.en. citeturn1view1turn4view0turn4view1 fmt=json3is used to getwireMagic: "pb3"+eventsand parse segments. citeturn3search3turn12search5turn4view0- Existing
fmtis replaced rather than appended because duplicates can lead to XML output and parsing failures. citeturn2search1turn4view0 type=listtimedtext fallback is a known endpoint used to enumerate caption tracks. citeturn12search1turn12search13
Example HTTP requests/responses you can use while debugging
Fetch watch HTML and confirm ytInitialPlayerResponse exists:
curl -L \
-H "User-Agent: Mozilla/5.0" \
-H "Accept-Language: en-GB,en;q=0.9" \
"https://www.youtube.com/watch?v=VIDEO_ID&hl=en-GB"
The “watch-page captions” approach consists of parsing that HTML for ytInitialPlayerResponse and reading captionTracks[].baseUrl. citeturn1view2turn7view0turn4view1
Fetch captions in JSON3 (preferred):
curl -L \
-H "User-Agent: Mozilla/5.0" \
"https://www.youtube.com/api/timedtext?v=VIDEO_ID&lang=en&fmt=json3"
JSON3’s wireMagic: "pb3" and events structure is documented by real examples and consumer code. citeturn3search3turn12search5turn4view0
Fetch captions as WebVTT (optional alternative):
curl -L \
"https://www.youtube.com/api/timedtext?v=VIDEO_ID&lang=en&fmt=vtt"
Appending fmt=vtt to a caption URL (or timedtext URL) is a known way to retrieve WebVTT captions. citeturn0search1turn12search14
List tracks (fallback):
curl -L "https://www.youtube.com/api/timedtext?type=list&v=VIDEO_ID"
The type=list endpoint is commonly referenced as a way to fetch caption track metadata. citeturn12search1
Suggested tests and edge cases
The goal is to test your parser invariants (HTML extraction, URL rewriting, JSON3 parsing) without relying on live YouTube calls in CI.
Unit tests
Balanced JSON extraction
- Input: a fixture HTML snippet containing
var ytInitialPlayerResponse = { ... };with nested braces and string escapes. - Assert:
extractInitialPlayerResponse()returns an object, and no truncation occurs when braces appear inside strings. This hardens you against brittle split hacks seen in quick fixes. citeturn4view1turn7view0
Track picking
- Provide fixture
captionTrackswith:- manual
en, manualfr, autoen(kind: "asr"), anden-GBvariants
- manual
- Assert:
pickTrack(..., "en-GB")chooses manualen-GBif present, otherwise manualen, otherwise autoen. Conventions used by reference scripts indicate.envsa.endistinctions. citeturn4view1turn4view0turn1view1
URL rewriting
- Input URLs:
...&fmt=srv3&......&fmt=srv3&fmt=vtt&...
- Assert: result contains exactly one
fmt=json3. This guards the known duplicate‑fmtpitfall. citeturn2search1turn4view0
JSON3 parsing
- Fixture JSON with
wireMagic: "pb3",eventsincluding:- normal segments
- events with
segs: [{"utf8":"\n"}] - events with no
segs
- Assert: you skip empties and compute
start/durationfrom ms fields. Real JSON3 examples show the timing fields andsegs[].utf8pattern. citeturn3search3turn12search5
Timedtext list XML parsing
- Fixture XML like:
<track lang_code="en" name="English" /><track lang_code="en" kind="asr" />
- Assert: you produce
CaptionTrack[]and setkindappropriately. The existence oftype=listandkind=asrusage is documented in references. citeturn12search1turn12search6turn12search20
Integration tests (recommended as opt-in)
Live internet tests are inherently flaky due to throttling, locale consent gates, and page changes. Evidence from tools shows rate limiting and intermittent failures are normal. citeturn2search2turn10view0
If you still want integration tests:
- Gate them behind an environment variable (e.g.,
RUN_YOUTUBE_LIVE_TESTS=1). - Use a small set of known public video IDs that you periodically refresh manually.
- Assert only broad invariants: “segments length > 0” and “first segment start is a number”.
Edge cases to explicitly support or surface as clear errors
- No captions:
captionTracksmissing or empty → user-facing “No captions available for this video.” (common). fileciteturn0file0 - Unplayable / restricted:
playabilityStatus.status != "OK"(e.g., login/age restricted) → explain that local extraction can’t proceed without authentication. fileciteturn0file0 - Consent interstitial: if watch HTML is a consent page, retry with consent cookie; if still blocked, show a consent-specific error. Using a
CONSENT=YES+1cookie is a known workaround in scraping contexts. citeturn8search1 - 429 throttling: surface “rate limited” and advise the user to retry later; do not spin retries. Rate limiting is repeatedly observed in tooling. citeturn2search2turn10view0
- fmt duplication: ensure replacing
fmtis mandatory; otherwise you can get XML and break JSON parsing. citeturn2search1turn4view0 - Machine translation: if you use
tlang, consider appending a short note in the UI/metadata that this transcript is machine-translated. Scripts and official APIs both treattlangas machine translation. citeturn9search1turn4view1
Migration checklist and method comparison
Migration checklist
- Implement the watch‑page
ytInitialPlayerResponse → captionTracks[].baseUrlpath as the default “local directed” route. fileciteturn0file0turn1view2 - Replace any
youtubei-derived caption retrieval as the default; keep it only as an explicit experimental toggle and fail fast onFAILED_PRECONDITION. fileciteturn0file0turn11search8 - Ensure
fmtis replaced (not appended) when requestingjson3. citeturn2search1turn4view0 - Add timedtext
type=listfallback to handle cases where watch HTML does not expose caption tracks. citeturn12search1 - Fix user messaging to only list fallbacks that actually ran (your notes call out the current misleading Supadata claim). fileciteturn0file0
- Add unit tests for: player response extraction, URL rewriting, JSON3 parsing, and track selection. citeturn3search3turn4view1
Comparison table of methods
The “reliability” column below is a practical engineering judgement based on the evidence that (a) internal endpoints are frequently blocked by changing integrity requirements, while (b) timedtext URLs and watch-page parsing are common in working implementations; it is not an official guarantee. citeturn11search0turn11search8turn1view2turn4view1turn12search1
| Method | Reliability | Complexity | Auth/headers needed | When to use |
|---|---|---|---|---|
Watch-page parsing (/watch → ytInitialPlayerResponse → captionTracks[].baseUrl) |
High (best local default) | Medium (HTML parsing + JSON extraction) | Browser UA; may need consent cookie in some locales citeturn8search1turn7view0 | Default “local directed” transcript acquisition citeturn1view2turn4view1 |
Timedtext list + download (/api/timedtext?type=list → fetch chosen track) |
Medium–High (good fallback) | Medium (XML parsing + URL construction) | Usually none beyond UA; can still be blocked/rate-limited citeturn12search1turn2search2 | Fallback when watch HTML lacks captionTracks citeturn12search1 |
Private internal API (youtubei/v1/player, youtubei/v1/get_transcript) |
Low (brittle) | High (client context/version/integrity) | Special headers; can fail with FAILED_PRECONDITION citeturn11search8turn0file0 |
Experimental toggle only; diagnostics; last resort fileciteturn0file0 |
| Supadata (third-party) | High if service is up | Low in code, higher in dependencies | API key + network to third-party | Reliable fallback for users willing to use a service fileciteturn0file0 |
Note on official APIs
The official YouTube Data API can list and download caption tracks, but captions.download requires OAuth and permission to edit the video, so it is not a general public transcript solution for arbitrary videos. citeturn9search1turn9search11turn9search2