mirror of
https://github.com/rmccorkl/TubeSage.git
synced 2026-07-22 06:45:31 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
33eeaedb8f
commit
45b2baa4ba
1 changed files with 136 additions and 0 deletions
|
|
@ -18,6 +18,15 @@ const isRecord = (value: unknown): value is UnknownRecord => {
|
|||
|
||||
const isString = (value: unknown): value is string => typeof value === 'string';
|
||||
|
||||
// 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';
|
||||
|
||||
// Add the CaptionTrack type at file level, outside of any method
|
||||
/**
|
||||
* Interface for YouTube caption tracks
|
||||
|
|
@ -71,6 +80,9 @@ export class YouTubeTranscriptExtractor {
|
|||
// 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;
|
||||
// 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;
|
||||
// Fallback client version if extraction fails (updated to current version)
|
||||
private static readonly FALLBACK_CLIENT_VERSION = '2.20260128.05.00';
|
||||
|
||||
|
|
@ -1300,6 +1312,130 @@ export class YouTubeTranscriptExtractor {
|
|||
return { segments, metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<UnknownRecord> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TranscriptResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse captions from a caption track baseUrl using json3 format
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue