import { logger } from '../utils/logger'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface InnertubeFormat { itag: number; url?: string; signatureCipher?: string; mimeType: string; bitrate: number; width?: number; height?: number; contentLength?: string; 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[]; expiresInSeconds?: string; } interface InnertubePlayerResponse { streamingData?: InnertubeStreamingData; videoDetails?: { videoId: string; title: string; lengthSeconds: string; isLive?: boolean; isLiveDvr?: boolean; }; playabilityStatus?: { status: string; reason?: string; }; } export interface ExtractedStream { url: string; quality: string; // e.g. "720p", "480p" mimeType: string; // e.g. "video/mp4" itag: number; hasAudio: boolean; hasVideo: boolean; bitrate: number; } export interface YouTubeExtractionResult { streams: ExtractedStream[]; bestStream: ExtractedStream | null; videoId: string; title?: string; durationSeconds?: number; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- // Innertube client configs — we use Android (no cipher, direct URLs) // and web as fallback (may need cipher decode) const INNERTUBE_API_KEY = 'AIzaSyA8ggJvXiQHQFN-YMEoM30s0s3RlxEYJuA'; const INNERTUBE_URL = 'https://www.youtube.com/youtubei/v1/player'; // Android client gives direct URLs without cipher obfuscation const ANDROID_CLIENT_CONTEXT = { client: { clientName: 'ANDROID', clientVersion: '19.09.37', androidSdkVersion: 30, userAgent: 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', hl: 'en', gl: 'US', }, }; // iOS client as secondary fallback const IOS_CLIENT_CONTEXT = { client: { clientName: 'IOS', clientVersion: '19.09.3', deviceModel: 'iPhone14,3', userAgent: 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)', hl: 'en', gl: 'US', }, }; // TV Embedded client — works for age-restricted / embed-allowed content const TVHTML5_EMBEDDED_CONTEXT = { client: { clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', clientVersion: '2.0', hl: 'en', gl: 'US', }, }; // Web Embedded client — good fallback for content that rejects app clients const WEB_EMBEDDED_CONTEXT = { client: { clientName: 'WEB_EMBEDDED_PLAYER', clientVersion: '2.20240726.00.00', hl: 'en', gl: 'US', }, 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; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function extractVideoId(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(); } try { const url = new URL(input); // youtu.be/VIDEO_ID if (url.hostname === 'youtu.be') { const id = url.pathname.slice(1).split('/')[0]; if (id && /^[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]; } catch { // Not a valid URL — try regex fallback const match = input.match(/[?&]v=([A-Za-z0-9_-]{11})/); if (match) return match[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 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 isVideoMp4(format: InnertubeFormat): boolean { return format.mimeType.startsWith('video/mp4'); } function formatQualityLabel(format: InnertubeFormat): string { return format.qualityLabel || format.quality || 'unknown'; } 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; } // --------------------------------------------------------------------------- // Adaptive stream helpers (Android/DASH only) // --------------------------------------------------------------------------- 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; } 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; } 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