From 25de499931f5ddc31b658be414dcd7fbefca9872 Mon Sep 17 00:00:00 2001 From: CK <86251706+altCK129@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:12:35 +0530 Subject: [PATCH] Update youtubeExtractor.ts --- src/services/youtubeExtractor.ts | 1093 ++++++++++++++++-------------- 1 file changed, 603 insertions(+), 490 deletions(-) diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts index c967f123..7d34ee37 100644 --- a/src/services/youtubeExtractor.ts +++ b/src/services/youtubeExtractor.ts @@ -1,640 +1,753 @@ import { logger } from '../utils/logger'; +import { Platform } from 'react-native'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface InnertubeFormat { - itag: number; + itag?: number; url?: string; - signatureCipher?: string; - mimeType: string; - bitrate: number; + mimeType?: string; + bitrate?: number; + averageBitrate?: number; width?: number; height?: number; - contentLength?: string; - quality: string; + fps?: number; + quality?: string; qualityLabel?: string; audioQuality?: string; audioSampleRate?: string; audioChannels?: number; approxDurationMs?: string; - lastModified?: string; - projectionType?: string; initRange?: { start: string; end: string }; indexRange?: { start: string; end: string }; } -interface InnertubeStreamingData { - formats: InnertubeFormat[]; - adaptiveFormats: InnertubeFormat[]; +interface StreamingData { + formats?: InnertubeFormat[]; + adaptiveFormats?: InnertubeFormat[]; + hlsManifestUrl?: string; expiresInSeconds?: string; } -interface InnertubePlayerResponse { - streamingData?: InnertubeStreamingData; +interface PlayerResponse { + streamingData?: StreamingData; videoDetails?: { - videoId: string; - title: string; - lengthSeconds: string; + videoId?: string; + title?: string; + lengthSeconds?: string; isLive?: boolean; - isLiveDvr?: boolean; }; playabilityStatus?: { - status: string; + status?: string; reason?: string; }; } -export interface ExtractedStream { +interface StreamCandidate { + client: string; + priority: number; url: string; - quality: string; // e.g. "720p", "480p" - mimeType: string; // e.g. "video/mp4" - itag: number; - hasAudio: boolean; - hasVideo: boolean; + score: number; + height: number; + fps: number; + ext: 'mp4' | 'webm' | 'm4a' | 'other'; + initRange?: { start: string; end: string }; + indexRange?: { start: string; end: string }; bitrate: number; + audioSampleRate?: string; + mimeType: string; + itag?: number; +} + +interface HlsVariant { + url: string; + width: number; + height: number; + bandwidth: number; +} + +export interface TrailerPlaybackSource { + videoUrl: string; // best video (may be muxed, DASH video-only, HLS, or progressive) + audioUrl: string | null; // separate audio URL if adaptive, null if muxed/HLS + quality: string; + isMuxed: boolean; // true if videoUrl already contains audio + isDash: boolean; // true if we should build a DASH manifest + durationSeconds: number; } export interface YouTubeExtractionResult { - streams: ExtractedStream[]; - bestStream: ExtractedStream | null; + source: TrailerPlaybackSource; videoId: string; title?: string; - durationSeconds?: number; } // --------------------------------------------------------------------------- -// Constants +// Client definitions // --------------------------------------------------------------------------- -// Innertube client configs. -// Note: ?key= param was deprecated by YouTube in mid-2023 and is no longer sent. -// -// IMPORTANT: As of late 2024, YouTube requires Proof of Origin (PO) tokens for -// most clients (ANDROID, IOS, WEB). Without a PO token, the player API returns -// format URLs but segment fetches get HTTP 403. The ANDROID_VR client is currently -// the only client that bypasses PO token requirements and gives full format access -// without authentication. Keep it first in the client list. -// -// Reference: https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide -const INNERTUBE_URL = 'https://www.youtube.com/youtubei/v1/player'; +interface ClientDef { + key: string; + id: string; + version: string; + userAgent: string; + context: Record; + priority: number; +} -// ANDROID_VR (Oculus Quest) — bypasses PO token requirement, full format access. -// This is the primary client. clientVersion from yt-dlp as of 2025. -const ANDROID_VR_CLIENT_CONTEXT = { - client: { - clientName: 'ANDROID_VR', - clientVersion: '1.60.19', - deviceMake: 'Oculus', - deviceModel: 'Quest 3', - androidSdkVersion: 32, - userAgent: - 'com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip', - hl: 'en', - gl: 'US', +const PREFERRED_ADAPTIVE_CLIENT = 'android_vr'; + +const CLIENTS: ClientDef[] = [ + { + key: 'android_vr', + id: '28', + version: '1.62.27', + userAgent: 'com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip', + context: { + clientName: 'ANDROID_VR', + clientVersion: '1.62.27', + deviceMake: 'Oculus', + deviceModel: 'Quest 3', + osName: 'Android', + osVersion: '12L', + platform: 'MOBILE', + androidSdkVersion: 32, + hl: 'en', + gl: 'US', + }, + priority: 0, }, -}; - -// TV client — no PO token needed, good format availability. -const TV_CLIENT_CONTEXT = { - client: { - clientName: 'TVHTML5', - clientVersion: '7.20250422.19.00', - userAgent: - 'Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/538.1 (KHTML, like Gecko) Version/6.0 TV Safari/538.1', - hl: 'en', - gl: 'US', + { + key: 'android', + id: '3', + version: '20.10.38', + userAgent: 'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US) gzip', + context: { + clientName: 'ANDROID', + clientVersion: '20.10.38', + osName: 'Android', + osVersion: '14', + platform: 'MOBILE', + androidSdkVersion: 34, + hl: 'en', + gl: 'US', + }, + priority: 1, }, -}; - -// Web Embedded — fallback for embeddable content, may need PO token for segments. -const WEB_EMBEDDED_CONTEXT = { - client: { - clientName: 'WEB_EMBEDDED_PLAYER', - clientVersion: '2.20250422.01.00', - hl: 'en', - gl: 'US', + { + key: 'ios', + id: '5', + version: '20.10.1', + userAgent: 'com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)', + context: { + clientName: 'IOS', + clientVersion: '20.10.1', + deviceModel: 'iPhone16,2', + osName: 'iPhone', + osVersion: '17.4.0.21E219', + platform: 'MOBILE', + hl: 'en', + gl: 'US', + }, + priority: 2, }, - thirdParty: { - embedUrl: 'https://www.youtube.com', - }, -}; - -// --------------------------------------------------------------------------- -// Itag reference tables -// --------------------------------------------------------------------------- - -// Muxed (video+audio in one file). -// iOS AVPlayer can ONLY use these. Max quality YouTube provides is 720p (itag 22), -// but it is often absent on modern videos, leaving 360p (itag 18) as the fallback. -const PREFERRED_MUXED_ITAGS = [ - 22, // 720p MP4 (video+audio) - 18, // 360p MP4 (video+audio) - 59, // 480p MP4 (video+audio) — rare - 78, // 480p MP4 (video+audio) — rare ]; -// Adaptive video-only itags, best quality first (MP4 preferred over WebM). -// Used for DASH on Android only. -const ADAPTIVE_VIDEO_ITAGS_RANKED = [ - 137, // 1080p MP4 video-only - 248, // 1080p WebM video-only - 136, // 720p MP4 video-only - 247, // 720p WebM video-only - 135, // 480p MP4 video-only - 244, // 480p WebM video-only - 134, // 360p MP4 video-only - 243, // 360p WebM video-only -]; - -// Adaptive audio-only itags, best quality first (AAC preferred over Opus). -// Used for DASH on Android only. -const ADAPTIVE_AUDIO_ITAGS_RANKED = [ - 141, // 256kbps AAC - 140, // 128kbps AAC ← most common - 251, // 160kbps Opus - 250, // 70kbps Opus - 249, // 50kbps Opus -]; - -const REQUEST_TIMEOUT_MS = 12000; +const REQUEST_TIMEOUT_MS = 20000; +const DEFAULT_UA = 'Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'; +const DEFAULT_HEADERS: Record = { + 'accept-language': 'en-US,en;q=0.9', + 'user-agent': DEFAULT_UA, +}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function extractVideoId(input: string): string | null { +function parseVideoId(input: string): string | null { if (!input) return null; - - // Already a bare video ID (11 chars, alphanumeric + _ -) - if (/^[A-Za-z0-9_-]{11}$/.test(input.trim())) { - return input.trim(); - } - + const trimmed = input.trim(); + if (/^[A-Za-z0-9_-]{11}$/.test(trimmed)) return trimmed; try { - const url = new URL(input); - - // youtu.be/VIDEO_ID - if (url.hostname === 'youtu.be') { + const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`); + const host = url.hostname.toLowerCase(); + if (host.endsWith('youtu.be')) { const id = url.pathname.slice(1).split('/')[0]; - if (id && /^[A-Za-z0-9_-]{11}$/.test(id)) return id; + if (/^[A-Za-z0-9_-]{11}$/.test(id)) return id; } - - // youtube.com/watch?v=VIDEO_ID const v = url.searchParams.get('v'); if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return v; - - // youtube.com/embed/VIDEO_ID or /shorts/VIDEO_ID - const pathMatch = url.pathname.match(/\/(embed|shorts|v)\/([A-Za-z0-9_-]{11})/); - if (pathMatch) return pathMatch[2]; + const m = url.pathname.match(/\/(embed|shorts|live|v)\/([A-Za-z0-9_-]{11})/); + if (m) return m[2]; } catch { - // Not a valid URL — try regex fallback - const match = input.match(/[?&]v=([A-Za-z0-9_-]{11})/); - if (match) return match[1]; + const m = trimmed.match(/[?&]v=([A-Za-z0-9_-]{11})/); + if (m) return m[1]; } - return null; } -function parseMimeType(mimeType: string): { container: string; codecs: string } { - // e.g. 'video/mp4; codecs="avc1.64001F, mp4a.40.2"' - const [base, codecsPart] = mimeType.split(';'); - const container = base.trim(); - const codecs = codecsPart ? codecsPart.replace(/codecs=["']?/i, '').replace(/["']$/, '').trim() : ''; - return { container, codecs }; +function getMimeBase(mimeType?: string): string { + return (mimeType ?? '').split(';')[0].trim(); } -function isMuxedFormat(format: InnertubeFormat): boolean { - // A muxed format has both video and audio codecs in its mimeType - const { codecs } = parseMimeType(format.mimeType); - // MP4 muxed: "avc1.xxx, mp4a.xxx" - // WebM muxed: "vp8, vorbis" etc. - return codecs.includes(',') || (!!format.audioQuality && !!format.qualityLabel); +function getCodecs(mimeType?: string): string { + const m = (mimeType ?? '').match(/codecs="?([^"]+)"?/i); + return m ? m[1].trim() : ''; } -function isVideoMp4(format: InnertubeFormat): boolean { - return format.mimeType.startsWith('video/mp4'); +function getExt(mimeType?: string): 'mp4' | 'webm' | 'm4a' | 'other' { + const base = getMimeBase(mimeType); + if (base.includes('mp4') || base === 'audio/mp4') return 'mp4'; + if (base.includes('webm')) return 'webm'; + if (base === 'audio/mp4' || base.includes('m4a')) return 'm4a'; + return 'other'; } -function formatQualityLabel(format: InnertubeFormat): string { - return format.qualityLabel || format.quality || 'unknown'; +function containerScore(ext: string): number { + switch (ext) { + case 'mp4': + case 'm4a': return 0; + case 'webm': return 1; + default: return 2; + } } -function scoreFormat(format: InnertubeFormat): number { - const preferredIndex = PREFERRED_MUXED_ITAGS.indexOf(format.itag); - const itagBonus = preferredIndex !== -1 ? (PREFERRED_MUXED_ITAGS.length - preferredIndex) * 10000 : 0; - const height = format.height ?? 0; - const heightScore = Math.min(height, 720) * 10; - const bitrateScore = Math.min(format.bitrate ?? 0, 3_000_000) / 1000; - return itagBonus + heightScore + bitrateScore; +function videoScore(height: number, fps: number, bitrate: number): number { + return height * 1_000_000_000 + fps * 1_000_000 + bitrate; +} + +function audioScore(bitrate: number, sampleRate: number): number { + return bitrate * 1_000_000 + sampleRate; +} + +function parseQualityLabel(label?: string): number { + if (!label) return 0; + const m = label.match(/(\d{2,4})p/); + return m ? parseInt(m[1], 10) : 0; +} + +function isMuxedFormat(f: InnertubeFormat): boolean { + const codecs = getCodecs(f.mimeType); + return (!!f.qualityLabel && !!f.audioQuality) || codecs.includes(','); +} + +function isVideoOnly(f: InnertubeFormat): boolean { + return !!(f.qualityLabel && !f.audioQuality && f.mimeType?.startsWith('video/')); +} + +function isAudioOnly(f: InnertubeFormat): boolean { + return !!(f.audioQuality && !f.qualityLabel && f.mimeType?.startsWith('audio/')); +} + +function summarizeUrl(url: string): string { + try { + const u = new URL(url); + return `${u.hostname}${u.pathname.substring(0, 40)}`; + } catch { + return url.substring(0, 80); + } } // --------------------------------------------------------------------------- -// Adaptive stream helpers (Android/DASH only) +// Watch page — extract API key + visitor data dynamically // --------------------------------------------------------------------------- -function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { - // Video-only: has qualityLabel, no audioQuality, has direct URL - const videoOnly = adaptiveFormats.filter( - (f) => f.url && f.qualityLabel && !f.audioQuality && f.mimeType.startsWith('video/') - ); - if (videoOnly.length === 0) return null; - for (const itag of ADAPTIVE_VIDEO_ITAGS_RANKED) { - const match = videoOnly.find((f) => f.itag === itag); - if (match) return match; - } - return videoOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; +interface WatchConfig { + apiKey: string | null; + visitorData: string | null; } -function pickBestAdaptiveAudio(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { - // Audio-only: has audioQuality, no qualityLabel, has direct URL - const audioOnly = adaptiveFormats.filter( - (f) => f.url && f.audioQuality && !f.qualityLabel && f.mimeType.startsWith('audio/') - ); - if (audioOnly.length === 0) return null; - for (const itag of ADAPTIVE_AUDIO_ITAGS_RANKED) { - const match = audioOnly.find((f) => f.itag === itag); - if (match) return match; +async function fetchWatchConfig(videoId: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(`https://www.youtube.com/watch?v=${videoId}&hl=en`, { + headers: DEFAULT_HEADERS, + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) { + logger.warn('YouTubeExtractor', `Watch page fetch failed: ${res.status}`); + return { apiKey: null, visitorData: null }; + } + const html = await res.text(); + const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/); + const visitorMatch = html.match(/"VISITOR_DATA":"([^"]+)"/); + return { + apiKey: apiKeyMatch?.[1] ?? null, + visitorData: visitorMatch?.[1] ?? null, + }; + } catch (err) { + clearTimeout(timer); + logger.warn('YouTubeExtractor', 'Watch page fetch error:', err); + return { apiKey: null, visitorData: null }; } - return audioOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; } -/** - * Write a DASH MPD manifest to a temp file and return its file:// URI. - * - * We use a file URI rather than a data: URI because: - * - ExoPlayer's DefaultDataSource handles file:// URIs natively via FileDataSource. - * - The .mpd file extension lets ExoPlayer auto-detect the type even without an - * explicit 'type' hint — meaning TrailerModal's bare