diff --git a/docs/superpowers/plans/2026-05-18-transcript-fallback-ios-player.md b/docs/superpowers/plans/2026-05-18-transcript-fallback-ios-player.md new file mode 100644 index 0000000..ff2f38a --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-transcript-fallback-ios-player.md @@ -0,0 +1,516 @@ +# Transcript Fallback: iOS InnerTube Player Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the four broken free local transcript-extraction methods in `src/youtube-transcript.ts` with a single working method built on YouTube's InnerTube iOS player API. + +**Architecture:** The transcript fallback ladder in `YouTubeTranscriptExtractor.fetchTranscript` becomes three rungs — ScrapeCreators paid API, the new iOS InnerTube player method, Supadata paid API. The iOS player response carries both caption tracks and `videoDetails`, so it also replaces the watch-page HTML scrape that `getVideoMetadata` used. All watch-page / ANDROID / MWEB / WEB-`get_transcript` code and its helpers are deleted. + +**Tech Stack:** TypeScript (strict), esbuild, the `obsidianFetch` cross-platform HTTP shim. Single file: `src/youtube-transcript.ts`. + +--- + +## Important context for the implementer + +- **No unit-test framework exists in this repo**, and `src/youtube-transcript.ts` calls live YouTube endpoints that cannot be exercised in CI. There are therefore **no "write a failing test" steps**. The verification gate for every task is `npm run build` (tsc strict + esbuild) and `npm run lint` (must report `0 errors, 0 warnings`). +- **Hard project rule: never add an `eslint-disable` comment anywhere.** If lint complains, fix the code. +- TypeScript and the project's ESLint config do **not** flag an unused *private class method*. That is why Task 1 can add `fetchViaIosPlayer` before Task 2 wires it in — the build stays green. +- The tasks are ordered so the build compiles cleanly after every commit: Task 1 adds new code, Task 2 switches callers to it, Task 3 deletes the now-unreferenced old code. +- Existing file-level helpers you will reuse: `isRecord(value): value is Record`, `isString(value): value is string`, the type alias `UnknownRecord = Record`, the logger `transcriptLogger`, `getSafeErrorMessage`, and the `obsidianFetch` shim. +- Existing methods you will reuse and must NOT change: `pickBestTrack(tracks, requestedLang)` → returns `{ track: CaptionTrack; useTlang: boolean } | null`; `fetchCaptionTrack(baseUrl, tlang?, userAgent?)` → `Promise`. +- Existing interfaces you will reuse: `CaptionTrack` (`languageCode: string; kind?: string; baseUrl?: string; vssId?: string; isTranslatable?: boolean`), `TranscriptOptions`, `TranscriptMetadata` (`title?: string; author?: string`), `TranscriptResult` (`segments: TranscriptSegment[]; metadata: TranscriptMetadata`). + +## File Structure + +- Modify: `src/youtube-transcript.ts` — the only file changed. It currently holds the whole `YouTubeTranscriptExtractor` class. The plan adds two methods + four module constants + two statics, rewires three call sites, and removes the obsolete methods/helpers. Net effect: the file shrinks substantially. + +--- + +### Task 1: Add the iOS InnerTube player method + +**Files:** +- Modify: `src/youtube-transcript.ts` + +This task adds new code only. Nothing calls `fetchViaIosPlayer` yet — that is Task 2. The build stays green because TypeScript does not flag unused private methods. + +- [ ] **Step 1: Add the four module-level constants** + +In `src/youtube-transcript.ts`, the file begins with imports, then `type UnknownRecord = ...`, `isRecord`, and `isString` (the `isString` line is `const isString = (value: unknown): value is string => typeof value === 'string';`). Immediately **after** the `isString` line and **before** the `// Add the CaptionTrack type at file level` comment, insert: + +```ts +// YouTube InnerTube iOS player endpoint. +// See docs/superpowers/specs/2026-05-18-transcript-fallback-ios-player-design.md +// The iOS client still returns working caption track URLs without PO tokens, +// while the Android/MWEB/WEB clients stopped doing so in early 2026. +const INNERTUBE_API_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'; +const INNERTUBE_PLAYER_URL = `https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}`; +const IOS_USER_AGENT = 'com.google.ios.youtube/20.10.38 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X)'; +const IOS_CLIENT_VERSION = '20.10.38'; +``` + +- [ ] **Step 2: Add the iOS player cache statics** + +In the `YouTubeTranscriptExtractor` class, find the existing static field block near the top of the class: + +```ts + private static cookieStore: string = ''; + // Cache YouTube config after first extraction to avoid repeated HTML fetches within the same video request + private static cachedConfig: YouTubeConfig | null = null; + private static cachedVideoId: string | null = null; +``` + +Immediately **after** the `private static cachedVideoId: string | null = null;` line, add: + +```ts + // Cache the iOS player API response per video to avoid a redundant POST within one request + private static cachedPlayerData: UnknownRecord | null = null; + private static cachedPlayerVideoId: string | null = null; +``` + +(Leave `cookieStore`, `cachedConfig`, `cachedVideoId` in place for now — Task 3 removes them.) + +- [ ] **Step 3: Add the `fetchIosPlayerData` helper method** + +In `src/youtube-transcript.ts`, find the method `fetchViaScrapeCreators` and its closing brace (it ends with `return { segments, metadata };` followed by a line containing only ` }`). Immediately **after** that closing brace, insert this method: + +```ts + /** + * Perform the YouTube InnerTube iOS player API call and return the parsed JSON. + * The iOS client currently still exposes working caption track URLs and videoDetails. + * The response is cached per videoId to avoid a redundant POST within one request. + */ + private static async fetchIosPlayerData(videoId: string, options: TranscriptOptions): Promise { + if (this.cachedPlayerData && this.cachedPlayerVideoId === videoId) { + transcriptLogger.debug('Using cached iOS player data'); + return this.cachedPlayerData; + } + + const lang = options.lang || 'en'; + const country = options.country || 'US'; + + const body = JSON.stringify({ + context: { + client: { + clientName: 'IOS', + clientVersion: IOS_CLIENT_VERSION, + hl: lang, + gl: country + } + }, + videoId + }); + + transcriptLogger.debug(`iOS Player API: requesting player data for ${videoId}`); + + const response = await obsidianFetch(INNERTUBE_PLAYER_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': IOS_USER_AGENT + }, + body + }); + + if (!response.ok) { + throw new Error(`iOS Player API error: HTTP ${response.status}`); + } + + const data = await response.json() as UnknownRecord; + + const playabilityStatus = data.playabilityStatus; + if (isRecord(playabilityStatus)) { + const status = playabilityStatus.status; + const reason = isString(playabilityStatus.reason) ? playabilityStatus.reason : undefined; + if (status === 'ERROR') { + throw new Error(reason || 'Video unavailable'); + } + if (status === 'LOGIN_REQUIRED') { + throw new Error('This video requires login to view'); + } + if (status === 'UNPLAYABLE') { + throw new Error(reason || 'Video is unplayable'); + } + } + + this.cachedPlayerData = data; + this.cachedPlayerVideoId = videoId; + return data; + } +``` + +- [ ] **Step 4: Add the `fetchViaIosPlayer` method** + +Immediately **after** the `fetchIosPlayerData` method you just added (after its closing ` }`), insert: + +```ts + /** + * iOS InnerTube player method: the working local transcript fallback. + * Fetches the iOS player response, picks a caption track, downloads and parses it. + */ + private static async fetchViaIosPlayer(videoId: string, options: TranscriptOptions): Promise { + const lang = options.lang || 'en'; + + const data = await this.fetchIosPlayerData(videoId, options); + + // Metadata from videoDetails + const videoDetails = isRecord(data.videoDetails) ? data.videoDetails : undefined; + const metadata: TranscriptMetadata = { + title: videoDetails && isString(videoDetails.title) ? videoDetails.title : undefined, + author: videoDetails && isString(videoDetails.author) ? videoDetails.author : undefined + }; + + // Caption tracks + const captions = (data as { + captions?: { + playerCaptionsTracklistRenderer?: { + captionTracks?: Array<{ + baseUrl?: string; + languageCode?: string; + kind?: string; + vssId?: string; + isTranslatable?: boolean; + }>; + }; + }; + }).captions?.playerCaptionsTracklistRenderer?.captionTracks; + + if (!captions || !Array.isArray(captions) || captions.length === 0) { + throw new Error('No captions available for this video'); + } + + transcriptLogger.debug(`iOS Player API: found ${captions.length} caption tracks`); + + const tracks: CaptionTrack[] = captions.map(track => ({ + languageCode: track.languageCode || '', + kind: track.kind, + baseUrl: track.baseUrl, + vssId: track.vssId, + isTranslatable: track.isTranslatable + })); + + const selection = this.pickBestTrack(tracks, lang); + if (!selection || !selection.track.baseUrl) { + throw new Error('No suitable caption track with baseUrl found'); + } + + const { track, useTlang } = selection; + const trackBaseUrl = track.baseUrl as string; // Guaranteed non-null by the check above + transcriptLogger.debug(`iOS Player API: selected track lang=${track.languageCode}, kind=${track.kind || 'manual'}, useTlang=${useTlang}`); + + const tlang = useTlang ? lang : undefined; + const segments = await this.fetchCaptionTrack(trackBaseUrl, tlang, IOS_USER_AGENT); + + transcriptLogger.debug(`iOS Player API: successfully extracted ${segments.length} segments`); + return { segments, metadata }; + } +``` + +- [ ] **Step 5: Verify the build is clean** + +Run: `npm run build` +Expected: exits 0, no TypeScript errors. + +- [ ] **Step 6: Verify lint is clean** + +Run: `npm run lint` +Expected: `0 errors, 0 warnings`. + +- [ ] **Step 7: Commit** + +```bash +git add src/youtube-transcript.ts +git commit -m "$(cat <<'EOF' +feat: add iOS InnerTube player transcript method + +Add fetchIosPlayerData and fetchViaIosPlayer, built from the YouTube +iOS player approach. Not yet wired into the fallback ladder. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 2: Rewire the fallback ladder and metadata lookup to the iOS method + +**Files:** +- Modify: `src/youtube-transcript.ts` + +This task switches `fetchTranscript` to the new three-rung ladder and re-points `getVideoMetadata` at the iOS player call. After this task the four old methods are still defined but no longer called — that is fine, the build stays green; Task 3 deletes them. + +- [ ] **Step 1: Replace the video-ID cache-reset block in `fetchTranscript`** + +In `fetchTranscript`, find this block near the start of the method: + +```ts + // Clear cached config when video changes to prevent stale title/metadata from a previous request + if (this.cachedVideoId !== videoId) { + this.cachedConfig = null; + this.cachedVideoId = videoId; + transcriptLogger.debug(`New video ID detected (${videoId}), cleared config cache`); + } +``` + +Replace it with: + +```ts + // Clear cached player data when the video changes to prevent stale metadata + if (this.cachedPlayerVideoId !== videoId) { + this.cachedPlayerData = null; + this.cachedPlayerVideoId = videoId; + transcriptLogger.debug(`New video ID detected (${videoId}), cleared player cache`); + } +``` + +- [ ] **Step 2: Replace the fallback ladder body in `fetchTranscript`** + +In `fetchTranscript`, find the `try {` that opens with `const attempts: Array<{ method: string; error: string }> = [];` and runs through the six numbered method blocks (`[1]` ScrapeCreators … `[6]` Supadata) up to and including the `throw new Error(\`All transcript extraction methods failed ...\`);` line. Replace everything from `const attempts:` down to that `throw new Error(...)` line (the entire body inside that `try`, but NOT the `try {` line itself and NOT the `} catch (error) {` that follows) with: + +```ts + const attempts: Array<{ method: string; error: string }> = []; + let metadata: TranscriptMetadata = {}; + + // [1] ScrapeCreators paid API — run first when key is present (most reliable) + if (options.scrapcreatorsApiKey) { + try { + transcriptLogger.debug('ScrapeCreators API key present — attempting paid API first'); + const result = await this.fetchViaScrapeCreators(videoId, options); + transcriptLogger.debug(`ScrapeCreators API succeeded with ${result.segments.length} segments`); + return result; + } catch (err) { + const msg = getSafeErrorMessage(err); + transcriptLogger.debug('ScrapeCreators API failed, falling back to local method:', msg); + attempts.push({ method: 'ScrapeCreators API', error: msg }); + } + } + + // [2] iOS InnerTube player API — the working local method + try { + transcriptLogger.debug('Attempting local method: iOS InnerTube player API'); + const result = await this.fetchViaIosPlayer(videoId, options); + transcriptLogger.debug(`iOS player method succeeded with ${result.segments.length} segments`); + return result; + } catch (err) { + const msg = getSafeErrorMessage(err); + transcriptLogger.debug('iOS player method failed:', msg); + attempts.push({ method: 'iOS player', error: msg }); + // If the player call itself succeeded but captions were absent, the + // response (with videoDetails) is cached — recover metadata from it. + const cached = this.cachedPlayerData; + if (isRecord(cached) && isRecord(cached.videoDetails)) { + metadata = { + title: isString(cached.videoDetails.title) ? cached.videoDetails.title : undefined, + author: isString(cached.videoDetails.author) ? cached.videoDetails.author : undefined + }; + } + } + + // [3] Supadata paid API fallback (only if key is configured) + if (options.supadataApiKey) { + try { + transcriptLogger.debug('Local method failed — attempting Supadata paid API'); + const result = await this.fetchViaSupadata(videoId, options); + transcriptLogger.debug(`Supadata API succeeded with ${result.segments.length} segments`); + return result; + } catch (err) { + const msg = getSafeErrorMessage(err); + transcriptLogger.debug('Supadata API failed:', msg); + attempts.push({ method: 'Supadata API', error: msg }); + } + } + + // All methods failed + const attemptedMethods = attempts.map(a => a.method).join(', '); + const lastError = attempts[attempts.length - 1]?.error || 'Unknown error'; + transcriptLogger.error(`All transcript methods failed: ${attemptedMethods}`); + + if (metadata.title || metadata.author) { + transcriptLogger.debug('Returning metadata despite caption failure'); + return { + segments: [{ + text: `[TRANSCRIPT EXTRACTION FAILED: ${attemptedMethods} methods all failed. ${lastError}]`, + start: 0, + duration: 0 + }], + metadata + }; + } + + throw new Error(`All transcript extraction methods failed (${attemptedMethods}). Last error: ${lastError}`); +``` + +Leave the `} catch (error) {` block that follows (the CORS/network error mapping) exactly as it is. + +- [ ] **Step 3: Replace the body of `getVideoMetadata`** + +Find the `getVideoMetadata` method. Its current signature line is `static async getVideoMetadata(videoId: string): Promise {`. Replace the **entire method** (signature through its closing brace) with: + +```ts + /** + * Get video metadata (title, author) from the iOS player response. + * Used by the ScrapeCreators and Supadata paths, which return transcript text only. + * The iOS player response is cached per videoId, so this reuses a warm cache when present. + */ + static async getVideoMetadata(videoId: string): Promise { + try { + const data = await this.fetchIosPlayerData(videoId, {}); + const videoDetails = isRecord(data.videoDetails) ? data.videoDetails : undefined; + return { + title: videoDetails && isString(videoDetails.title) ? videoDetails.title : undefined, + author: videoDetails && isString(videoDetails.author) ? videoDetails.author : undefined + }; + } catch (error) { + transcriptLogger.error('Error fetching video metadata:', error); + return {}; + } + } +``` + +- [ ] **Step 4: Verify the build is clean** + +Run: `npm run build` +Expected: exits 0, no TypeScript errors. (The four old `fetchVia*` local methods and their helpers are now uncalled but still defined — TypeScript does not flag unused private methods, so this compiles.) + +- [ ] **Step 5: Verify lint is clean** + +Run: `npm run lint` +Expected: `0 errors, 0 warnings`. + +- [ ] **Step 6: Commit** + +```bash +git add src/youtube-transcript.ts +git commit -m "$(cat <<'EOF' +feat: route transcript fallback through the iOS player method + +fetchTranscript now uses a three-rung ladder (ScrapeCreators, iOS +player, Supadata). getVideoMetadata is sourced from the iOS player +response instead of the watch-page HTML scrape. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 3: Delete the obsolete methods, helpers, and statics + +**Files:** +- Modify: `src/youtube-transcript.ts` + +Everything removed here is now unreferenced (verified: `parseScrapeCreatorsTranscript` was used only inside `fetchViaWebScrapeCreators`; `getYouTubeConfig`/`fetchWatchPageHtml`/`extractJsonFromHtml` only by the deleted methods after Task 2). The `USER_AGENT` static is **kept** because `fetchCaptionTrackRaw` still uses it. + +- [ ] **Step 1: Delete the four obsolete transcript methods** + +Delete each of these methods in full (the leading JSDoc comment block through the method's closing brace). Locate them by their signature lines: + +1. `private static async fetchViaWatchPage(videoId: string, options: TranscriptOptions): Promise {` +2. `private static async fetchViaWebScrapeCreators(videoId: string, _options: TranscriptOptions): Promise {` +3. `private static async fetchViaPlayerApiMWEB(videoId: string, options: TranscriptOptions): Promise {` +4. `private static async fetchViaPlayerApiAndroid(videoId: string, options: TranscriptOptions): Promise {` + +- [ ] **Step 2: Delete the five obsolete helper methods** + +Delete each of these methods in full (JSDoc through closing brace), located by signature: + +1. `private static async getYouTubeConfig(videoId: string): Promise {` +2. `private static async fetchWatchPageHtml(watchUrl: string, extraCookie?: string): Promise {` +3. `private static extractJsonFromHtml(html: string, variableName: string): unknown {` +4. `private static extractMetadataFromNextData(nextData: unknown): TranscriptMetadata {` +5. `private static findTranscriptEndpoint(obj: unknown): string | null {` +6. `private static parseScrapeCreatorsTranscript(transcriptData: UnknownRecord): TranscriptSegment[] {` + +(That is six methods — delete all six.) + +- [ ] **Step 3: Delete the `YouTubeConfig` interface** + +Near the top of the file, delete this interface in full (JSDoc comment through closing brace): + +```ts +/** + * YouTube configuration extracted from watch page + */ +interface YouTubeConfig { + apiKey: string; + clientVersion: string; + visitorData: string | null; + captionTracks: CaptionTrack[]; + metadata: TranscriptMetadata; +} +``` + +- [ ] **Step 4: Delete the three obsolete class statics** + +In the `YouTubeTranscriptExtractor` static field block, delete these three lines: + +```ts + private static cookieStore: string = ''; +``` +```ts + // Cache YouTube config after first extraction to avoid repeated HTML fetches within the same video request + private static cachedConfig: YouTubeConfig | null = null; + private static cachedVideoId: string | null = null; +``` +```ts + // Fallback client version if extraction fails (updated to current version) + private static readonly FALLBACK_CLIENT_VERSION = '2.20260128.05.00'; +``` + +Keep `private static readonly USER_AGENT = ...` and the two `cachedPlayerData` / `cachedPlayerVideoId` statics added in Task 1. + +- [ ] **Step 5: Remove the dead `cookieStore` header spread in `fetchCaptionTrackRaw`** + +In the kept method `fetchCaptionTrackRaw`, the headers object ends with this line: + +```ts + ...(YouTubeTranscriptExtractor.cookieStore && { 'Cookie': YouTubeTranscriptExtractor.cookieStore }) +``` + +Delete that line. The line above it (`'DNT': '1',`) becomes the last header — make sure the resulting object is still valid (the trailing comma after `'DNT': '1'` is harmless and may stay). Do not change any other header in this method. + +- [ ] **Step 6: Verify the build is clean** + +Run: `npm run build` +Expected: exits 0, no TypeScript errors. In particular, no "cannot find name" errors — if any appear, a deletion removed something still referenced; re-check Steps 1-5. + +- [ ] **Step 7: Verify lint is clean** + +Run: `npm run lint` +Expected: `0 errors, 0 warnings`. No `eslint-disable` comments may be added to achieve this. + +- [ ] **Step 8: Commit** + +```bash +git add src/youtube-transcript.ts +git commit -m "$(cat <<'EOF' +refactor: remove broken watch-page/ANDROID/MWEB/WEB transcript methods + +Delete the four obsolete local transcript methods, their six helper +methods, the YouTubeConfig interface, and the now-unused statics. The +iOS player method added earlier is the sole local fallback. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Spec "New fallback ladder" (ScrapeCreators → iOS player → Supadata) → Task 2 Step 2. ✓ +- Spec "`fetchIosPlayerData` helper" (constants, POST, playability checks, per-video cache) → Task 1 Steps 1-3. ✓ +- Spec "`fetchViaIosPlayer` method" (videoDetails metadata, captionTracks, `pickBestTrack`, `fetchCaptionTrack` with iOS UA + tlang) → Task 1 Step 4. ✓ +- Spec "Changed: `getVideoMetadata`" (re-pointed to `fetchIosPlayerData`, try/catch → `{}`, short-circuit removed) → Task 2 Step 3. ✓ +- Spec "Deletions" (4 methods, 6 helpers, `YouTubeConfig`, statics `cachedConfig`/`cachedVideoId`/`FALLBACK_CLIENT_VERSION`/`cookieStore`, the `cookieStore` header spread) → Task 3 Steps 1-5. ✓ +- Spec "video-ID change clears the player cache" → Task 2 Step 1. ✓ +- Spec "Kept unchanged" (ScrapeCreators/Supadata, `pickBestTrack`, caption fetch/parse, `USER_AGENT`) → not touched by any task; Task 3 Step 4 explicitly keeps `USER_AGENT`. ✓ +- Spec "Testing" (build + lint clean, no eslint-disable) → every task ends with build + lint steps. ✓ +- No spec requirement is unaddressed. + +**2. Placeholder scan:** No TBD/TODO/vague steps. Every code step shows complete code; every command shows expected output. ✓ + +**3. Type consistency:** `fetchIosPlayerData` returns `Promise` and is consumed by `fetchViaIosPlayer` and `getVideoMetadata`, both treating the result via `isRecord`/`isString` guards on `data.videoDetails` / `data.playabilityStatus` / `data.captions`. `fetchViaIosPlayer` returns `Promise` and is called in `fetchTranscript`'s `[2]` block expecting `result.segments`. The cache statics `cachedPlayerData: UnknownRecord | null` / `cachedPlayerVideoId: string | null` are written in `fetchIosPlayerData`, reset in `fetchTranscript` Step 1, and read in `fetchTranscript` Step 2's catch block — names consistent throughout. `CaptionTrack` shape matches the existing interface. ✓