UNPKG

newpipe-extractor-js

Version:
1,073 lines 50 kB
"use strict"; // YouTube Service Implementation - Based on Android NewPipeExtractor // Uses mobile Android client which bypasses download restrictions var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.YoutubeService = void 0; const node_fetch_1 = __importDefault(require("node-fetch")); const StreamingService_1 = require("../../core/StreamingService"); const YoutubeLinkHandler_1 = require("./YoutubeLinkHandler"); const YoutubeSignatureDecryptor_1 = require("./YoutubeSignatureDecryptor"); const DashManifestParser_1 = require("./DashManifestParser"); const types_1 = require("../../types"); const PoTokenDirector_1 = require("../../potoken/PoTokenDirector"); const PoTokenProvider_1 = require("../../potoken/PoTokenProvider"); const FormatSorter_1 = require("../../utils/FormatSorter"); const CodecUtils_1 = require("../../utils/CodecUtils"); const ThrottlingDecryptor_1 = require("../../utils/ThrottlingDecryptor"); class YoutubeService extends StreamingService_1.StreamingService { constructor(poTokenOptions) { super({ name: 'YouTube', serviceId: 0, baseUrl: 'https://www.youtube.com' }); this.linkHandler = new YoutubeLinkHandler_1.YoutubeLinkHandler(); this.signatureDecryptor = new YoutubeSignatureDecryptor_1.YoutubeSignatureDecryptor(); this.dashManifestParser = new DashManifestParser_1.DashManifestParser(); this.formatSorter = FormatSorter_1.FormatSorterPresets.createWebCompatibilityPreset(); if (poTokenOptions) { this.poTokenDirector = new PoTokenDirector_1.PoTokenDirector(poTokenOptions); } } getLinkHandler() { return this.linkHandler; } async getStreamExtractor(url) { const videoId = this.linkHandler.getId(url); try { // Use Android client approach for better success rate const playerResponse = await this.getAndroidPlayerResponse(videoId); return this.extractStreamInfo(videoId, playerResponse); } catch (error) { console.warn('Android client failed, trying web fallback:', error); // Fallback to web client if Android fails const playerResponse = await this.getWebPlayerResponse(videoId); return this.extractStreamInfo(videoId, playerResponse); } } async getAndroidPlayerResponse(videoId) { const url = YoutubeService.YOUTUBEI_V1_GAPIS_URL + 'player'; const cpn = this.generateContentPlaybackNonce(); const t = this.generateTParameter(); // Generate PoToken for Android client if available let poToken = null; if (this.poTokenDirector) { try { const request = { context: PoTokenProvider_1.PoTokenContext.PLAYER, innertubeContext: { client: { clientName: YoutubeService.ANDROID_CLIENT_NAME, clientVersion: YoutubeService.ANDROID_CLIENT_VERSION, platform: 'MOBILE', osName: 'Android', osVersion: YoutubeService.ANDROID_OS_VERSION } }, isAuthenticated: false, videoId: videoId, requestCookies: [], requestHeaders: {}, requestVerifyTls: true, bypassCache: false }; poToken = await this.poTokenDirector.getPoToken(request); } catch (error) { console.warn('Failed to get Android PoToken:', error); } } const requestBody = { context: { client: { clientName: YoutubeService.ANDROID_CLIENT_NAME, clientVersion: YoutubeService.ANDROID_CLIENT_VERSION, clientScreen: 'WATCH', androidSdkVersion: parseInt(YoutubeService.ANDROID_SDK_VERSION), platform: 'MOBILE', osName: 'Android', osVersion: YoutubeService.ANDROID_OS_VERSION, hl: 'en', gl: 'US', utcOffsetMinutes: 0 }, request: { useSsl: true, internalExperimentFlags: [] }, user: { lockedSafetyMode: false } }, videoId: videoId, cpn: cpn, contentCheckOk: true, racyCheckOk: true }; // Add PoToken if available if (poToken) { requestBody.serviceIntegrityDimensions = { poToken: poToken }; } const headers = { 'User-Agent': this.getAndroidUserAgent(), 'X-Goog-Api-Format-Version': '2', 'Content-Type': 'application/json', 'Accept': 'application/json', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', 'Origin': 'https://www.youtube.com', 'Referer': 'https://www.youtube.com/' }; const fullUrl = `${url}?prettyPrint=false&t=${t}&id=${videoId}`; const response = await (0, node_fetch_1.default)(fullUrl, { method: 'POST', headers: headers, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`Android player request failed: ${response.status} ${response.statusText}`); } return await response.json(); } async getWebPlayerResponse(videoId) { const url = YoutubeService.YOUTUBEI_V1_URL + 'player'; const requestBody = { context: { client: { clientName: 'WEB', clientVersion: '2.20250122.04.00', hl: 'en', gl: 'US', platform: 'DESKTOP', utcOffsetMinutes: 0 }, request: { useSsl: true, internalExperimentFlags: [] }, user: { lockedSafetyMode: false } }, videoId: videoId, contentCheckOk: true, racyCheckOk: true }; const headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Content-Type': 'application/json', 'Accept': 'application/json', 'Origin': 'https://www.youtube.com', 'Referer': 'https://www.youtube.com/', 'X-YouTube-Client-Name': '1', 'X-YouTube-Client-Version': '2.20250122.04.00' }; const response = await (0, node_fetch_1.default)(`${url}?prettyPrint=false`, { method: 'POST', headers: headers, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`Web player request failed: ${response.status} ${response.statusText}`); } return await response.json(); } async extractStreamInfo(videoId, playerResponse) { if (!playerResponse || !playerResponse.videoDetails) { throw new Error('Invalid player response'); } const videoDetails = playerResponse.videoDetails; const streamingData = playerResponse.streamingData; if (!streamingData) { throw new Error('No streaming data available'); } // Process audio streams const audioStreams = []; const videoStreams = []; const videoOnlyStreams = []; if (streamingData.adaptiveFormats) { for (const format of streamingData.adaptiveFormats) { const mimeType = format.mimeType || ''; const codec = this.extractCodec(mimeType); const mediaFormat = this.getMediaFormat(mimeType); // Enhanced codec parsing const parsedCodecs = codec ? this.parseCodecs(codec) : {}; // Determine stream type based on MIME type and codec analysis const isAudioOnly = mimeType.includes('audio') || (!mimeType.includes('video') && parsedCodecs.acodec && !parsedCodecs.vcodec); const isVideoOnly = mimeType.includes('video') && !mimeType.includes('audio') && parsedCodecs.vcodec && !parsedCodecs.acodec; const processedStream = await this.processStream(videoId, format, playerResponse); if (!processedStream) continue; if (isAudioOnly) { audioStreams.push({ url: processedStream.url, itag: format.itag, quality: format.audioQuality || this.mapBitrateToQuality(format.averageBitrate || format.bitrate), codec: parsedCodecs.acodec || codec || 'unknown', format: mediaFormat, bitrate: format.averageBitrate || format.bitrate || 0, samplingRate: format.audioSampleRate ? parseInt(format.audioSampleRate) : undefined, contentLength: format.contentLength ? parseInt(format.contentLength) : undefined, approxDurationMs: format.approxDurationMs ? parseInt(format.approxDurationMs) : undefined, averageBitrate: format.averageBitrate }); } else if (isVideoOnly) { videoOnlyStreams.push({ url: processedStream.url, itag: format.itag, quality: format.qualityLabel || `${format.height}p` || 'unknown', codec: parsedCodecs.vcodec || codec || 'unknown', format: mediaFormat, bitrate: format.averageBitrate || format.bitrate || 0, fps: format.fps || undefined, width: format.width || undefined, height: format.height || undefined, resolution: format.qualityLabel || `${format.height}p`, isVideoOnly: true, contentLength: format.contentLength ? parseInt(format.contentLength) : undefined, approxDurationMs: format.approxDurationMs ? parseInt(format.approxDurationMs) : undefined }); } else if (mimeType.includes('video')) { videoStreams.push({ url: processedStream.url, itag: format.itag, quality: format.qualityLabel || `${format.height}p` || 'unknown', codec: parsedCodecs.vcodec || codec || 'unknown', format: mediaFormat, bitrate: format.averageBitrate || format.bitrate || 0, fps: format.fps || undefined, width: format.width || undefined, height: format.height || undefined, resolution: format.qualityLabel || `${format.height}p`, isVideoOnly: false, contentLength: format.contentLength ? parseInt(format.contentLength) : undefined, approxDurationMs: format.approxDurationMs ? parseInt(format.approxDurationMs) : undefined }); } } } // Also process regular formats for video streams if (streamingData.formats) { for (const format of streamingData.formats) { if (format.mimeType && format.mimeType.includes('video')) { const codec = this.extractCodec(format.mimeType); const mediaFormat = this.getMediaFormat(format.mimeType); const parsedCodecs = codec ? this.parseCodecs(codec) : {}; const processedStream = await this.processStream(videoId, format, playerResponse); if (processedStream) { videoStreams.push({ url: processedStream.url, itag: format.itag, quality: format.qualityLabel || `${format.height}p` || 'unknown', codec: parsedCodecs.vcodec || codec || 'unknown', format: mediaFormat, bitrate: format.averageBitrate || format.bitrate || 0, fps: format.fps || 30, width: format.width, height: format.height, resolution: format.qualityLabel || `${format.height}p`, isVideoOnly: false, contentLength: format.contentLength ? parseInt(format.contentLength) : undefined, approxDurationMs: format.approxDurationMs ? parseInt(format.approxDurationMs) : undefined }); } } } } // Process DASH manifest if available let dashManifestStreams = null; if (streamingData.dashManifestUrl) { try { console.log('Processing DASH manifest:', streamingData.dashManifestUrl); dashManifestStreams = await this.processDashManifest(streamingData.dashManifestUrl); if (dashManifestStreams) { // Merge DASH streams with existing streams, avoiding duplicates this.mergeDashStreams(dashManifestStreams, audioStreams, videoStreams, videoOnlyStreams); } } catch (error) { console.warn('Failed to process DASH manifest:', error); } } return { id: videoId, name: videoDetails.title || 'Unknown Title', url: `https://www.youtube.com/watch?v=${videoId}`, originalUrl: `https://www.youtube.com/watch?v=${videoId}`, streamType: videoDetails.isLiveContent ? types_1.StreamType.LIVE_STREAM : types_1.StreamType.VIDEO_STREAM, length: parseInt(videoDetails.lengthSeconds) || 0, viewCount: parseInt(videoDetails.viewCount) || 0, uploaderName: videoDetails.author || 'Unknown', uploaderUrl: videoDetails.channelId ? `https://www.youtube.com/channel/${videoDetails.channelId}` : '', uploaderAvatars: [], description: videoDetails.shortDescription || '', thumbnails: videoDetails.thumbnail?.thumbnails?.map((thumb) => ({ url: thumb.url, width: thumb.width, height: thumb.height })) || [], uploadDate: undefined, ageLimit: 0, likeCount: undefined, audioStreams: this.sortAudioStreams(audioStreams), videoStreams: this.sortVideoStreams(videoStreams), videoOnlyStreams: this.sortVideoStreams(videoOnlyStreams), hlsUrl: streamingData.hlsManifestUrl || undefined, dashMpdUrl: streamingData.dashManifestUrl || undefined, relatedItems: [], tags: videoDetails.keywords || [], category: undefined, shortFormContent: false }; } async processStream(_videoId, format, playerResponse) { try { let streamUrl; // Check for direct URL first if (format.url) { streamUrl = format.url; console.log(`[YoutubeService] Direct URL found for format ${format.itag}`); } else if (format.signatureCipher || format.cipher) { // Handle signature cipher with enhanced decryption console.log(`[YoutubeService] Decrypting signature for format ${format.itag}...`); const cipherString = format.signatureCipher || format.cipher; // Try signature decryption with retry mechanism let decryptedUrl = null; const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { decryptedUrl = await this.signatureDecryptor.decryptSignature(cipherString, playerResponse); if (decryptedUrl) { console.log(`[YoutubeService] Signature decrypted successfully for format ${format.itag} (attempt ${attempt})`); break; } } catch (error) { console.warn(`[YoutubeService] Signature decryption attempt ${attempt} failed:`, error); if (attempt === maxRetries) { console.error(`[YoutubeService] All signature decryption attempts failed for format ${format.itag}`); return null; } // Wait before retrying (exponential backoff) await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100)); } } if (!decryptedUrl) { console.error(`[YoutubeService] Failed to decrypt signature for format ${format.itag}`); return null; } streamUrl = decryptedUrl; } else { console.warn(`[YoutubeService] No URL or cipher found for format ${format.itag}`); return null; } // Advanced throttling parameter handling using yt-dlp methodology if (streamUrl.includes('&n=') || streamUrl.includes('?n=')) { try { console.log(`[YoutubeService] Processing throttling parameter for format ${format.itag}...`); // Try advanced throttling decryptor first, then fallback to original method try { streamUrl = await ThrottlingDecryptor_1.throttlingDecryptor.decryptThrottlingParameter(streamUrl); console.log(`[YoutubeService] Advanced throttling parameter processed successfully for format ${format.itag}`); } catch (advancedError) { console.warn(`[YoutubeService] Advanced throttling decryption failed, using fallback for format ${format.itag}`); // Fallback to original signature decryptor method streamUrl = await this.signatureDecryptor.deobfuscateThrottlingParameter(streamUrl, playerResponse); console.log(`[YoutubeService] Fallback throttling parameter processed successfully for format ${format.itag}`); } } catch (error) { console.warn(`[YoutubeService] Throttling parameter processing failed for format ${format.itag}:`, error); // Continue with original URL - throttling failure shouldn't block playback } } // Add Android client specific parameters const cpn = this.generateContentPlaybackNonce(); const separator = streamUrl.includes('?') ? '&' : '?'; streamUrl += `${separator}cpn=${cpn}`; // Add ALR_YES parameter for better redirect handling (from NewPipe Android implementation) // This tells YouTube to provide redirect URLs instead of doing server-side redirects if (!streamUrl.includes('alr=yes')) { streamUrl += '&alr=yes'; } // Add additional Android client parameters if (!streamUrl.includes('c=ANDROID')) { streamUrl += '&c=ANDROID'; } if (!streamUrl.includes('cver=')) { streamUrl += `&cver=${YoutubeService.ANDROID_CLIENT_VERSION}`; } // Add stream request parameters (like NewPipe) if (!streamUrl.includes('sq=')) { streamUrl += '&sq=0'; // First sequence for streams } if (!streamUrl.includes('rn=')) { streamUrl += '&rn=0'; // First request number } // Enhanced URL validation if (!this.isValidStreamUrl(streamUrl)) { console.error(`[YoutubeService] Invalid stream URL generated for format ${format.itag}`); return null; } console.log(`[YoutubeService] Successfully processed stream for format ${format.itag}`); return { url: streamUrl }; } catch (error) { console.error(`[YoutubeService] Failed to process stream for format ${format.itag}:`, error); return null; } } // Enhanced URL validation method isValidStreamUrl(url) { try { const urlObj = new URL(url); // Check if it's a valid YouTube streaming URL const validHosts = [ 'googlevideo.com', 'c.youtube.com', 'videoplayback.googleapis.com', 'rr1---sn-', 'rr2---sn-', 'rr3---sn-', 'rr4---sn-', 'rr5---sn-' ]; const isValidHost = validHosts.some(host => urlObj.hostname.includes(host) || urlObj.hostname.endsWith('.googlevideo.com')); return isValidHost && urlObj.protocol === 'https:'; } catch { return false; } } getAndroidUserAgent() { return `com.google.android.youtube/${YoutubeService.ANDROID_CLIENT_VERSION} (Linux; U; Android ${YoutubeService.ANDROID_OS_VERSION}; US) gzip`; } generateContentPlaybackNonce() { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; let result = ''; for (let i = 0; i < 16; i++) { result += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); } return result; } generateTParameter() { return Math.floor(Date.now() / 1000).toString(); } // Advanced stream sorting methods using yt-dlp methodology sortAudioStreams(audioStreams) { try { return this.formatSorter.sortAudioStreams(audioStreams); } catch (error) { console.warn(`[YoutubeService] Audio stream sorting failed, using default order:`, error); return audioStreams; } } sortVideoStreams(videoStreams) { try { return this.formatSorter.sortVideoStreams(videoStreams); } catch (error) { console.warn(`[YoutubeService] Video stream sorting failed, using default order:`, error); return videoStreams; } } // Map bitrate to quality levels for audio streams mapBitrateToQuality(bitrate) { if (!bitrate) return 'unknown'; if (bitrate >= 256000) return 'high'; if (bitrate >= 128000) return 'medium'; if (bitrate >= 64000) return 'low'; return 'ultralow'; } // Enhanced format detection based on cross-platform analysis getMediaFormat(mimeType) { const lowerMimeType = mimeType.toLowerCase(); // Primary format detection (Universal support) if (lowerMimeType.includes('mp4')) return types_1.MediaFormat.MPEG_4; if (lowerMimeType.includes('webm')) return types_1.MediaFormat.WEBM; if (lowerMimeType.includes('3gpp') || lowerMimeType.includes('3gp')) return types_1.MediaFormat.v3GPP; // Enhanced audio format support (inspired by yt-dlp) if (lowerMimeType.includes('audio')) { if (lowerMimeType.includes('mp4') || lowerMimeType.includes('m4a')) return types_1.MediaFormat.M4A; if (lowerMimeType.includes('webm') || lowerMimeType.includes('opus')) return types_1.MediaFormat.WEBMA_OPUS; if (lowerMimeType.includes('mpeg') || lowerMimeType.includes('mp3')) return types_1.MediaFormat.MP3; if (lowerMimeType.includes('ogg')) return types_1.MediaFormat.OGG; if (lowerMimeType.includes('flac')) return types_1.MediaFormat.FLAC; // yt-dlp compatibility if (lowerMimeType.includes('wav') || lowerMimeType.includes('wave')) return types_1.MediaFormat.WAV; // yt-dlp compatibility if (lowerMimeType.includes('aac')) return types_1.MediaFormat.M4A; // AAC usually in MP4 container } // Video format detection if (lowerMimeType.includes('video')) { if (lowerMimeType.includes('mp4')) return types_1.MediaFormat.MPEG_4; if (lowerMimeType.includes('webm')) return types_1.MediaFormat.WEBM; if (lowerMimeType.includes('3gpp')) return types_1.MediaFormat.v3GPP; } // Default fallback based on common patterns return types_1.MediaFormat.MPEG_4; // Most compatible default } // Advanced codec parsing inspired by yt-dlp RFC 6381 compliance parseCodecs(codecString) { if (!codecString) return {}; // Use enhanced codec parser from CodecUtils const codecInfo = (0, CodecUtils_1.parseCodecs)(codecString); // Convert to legacy format for backward compatibility const result = {}; if (codecInfo.vcodec) result.vcodec = codecInfo.vcodec; if (codecInfo.acodec) result.acodec = codecInfo.acodec; if (codecInfo.scodec) result.scodec = codecInfo.scodec; if (codecInfo.hdr) result.hdr = codecInfo.hdr; if (codecInfo.profile) result.profile = codecInfo.profile; // Log codec analysis for debugging if (result.vcodec || result.acodec) { const description = (0, CodecUtils_1.getCodecDescription)(codecInfo); console.log(`[YoutubeService] Codec analysis: ${description}`); // Log HDR content detection if (result.hdr) { console.log(`[YoutubeService] HDR content detected: ${result.hdr}`); } } return result; } // Enhanced codec extraction with better parsing extractCodec(mimeType) { // Primary codec extraction const codecMatch = mimeType.match(/codecs="([^"]+)"/i); if (codecMatch) return codecMatch[1]; // Fallback codec detection from MIME type const lowerMimeType = mimeType.toLowerCase(); // Video codec inference if (lowerMimeType.includes('avc1') || lowerMimeType.includes('h264')) return 'avc1.42E01E'; if (lowerMimeType.includes('vp9')) return 'vp9'; if (lowerMimeType.includes('vp8')) return 'vp8'; if (lowerMimeType.includes('av01') || lowerMimeType.includes('av1')) return 'av01.0.04M.08'; // Audio codec inference if (lowerMimeType.includes('mp4a') || lowerMimeType.includes('aac')) return 'mp4a.40.2'; if (lowerMimeType.includes('opus')) return 'opus'; if (lowerMimeType.includes('vorbis')) return 'vorbis'; if (lowerMimeType.includes('mp3')) return 'mp3'; return undefined; } // Cross-platform container compatibility check (inspired by yt-dlp) // @ts-ignore - Reserved for future container compatibility features getCompatibleExtension(vcodecs, acodecs, preferences = ['mp4', 'webm']) { // Container compatibility matrix (yt-dlp inspired) const COMPATIBLE_CODECS = { 'mp4': new Set(['av1', 'hevc', 'avc1', 'h264', 'mp4a', 'aac', 'ac-4', 'ec-3']), 'webm': new Set(['av1', 'vp9', 'vp8', 'opus', 'vorbis', 'vp9x', 'vp8x']), 'm4a': new Set(['mp4a', 'aac', 'alac']), 'webma': new Set(['opus', 'vorbis']) }; const sanitizeCodec = (codec) => codec.split('.')[0].toLowerCase().replace(/0+/, ''); const vcodec = vcodecs.length > 0 ? sanitizeCodec(vcodecs[0]) : ''; const acodec = acodecs.length > 0 ? sanitizeCodec(acodecs[0]) : ''; // Check preferences in order for (const ext of preferences) { const codecSet = COMPATIBLE_CODECS[ext]; if (codecSet && codecSet.has(vcodec) && codecSet.has(acodec)) { return ext; } } // Default to MP4 for maximum compatibility return 'mp4'; } // Required abstract method implementations async getChannelExtractor(_url) { throw new Error('Channel extraction not implemented yet'); } async getPlaylistExtractor(_url) { throw new Error('Playlist extraction not implemented yet'); } async search(query, contentFilters = [], _sortFilter = '', localization = { languageCode: 'en', countryCode: 'US' }) { try { // Content filter mapping (from NewPipe Java implementation) const filterMap = { 'all': '', // No filter 'videos': 'EgIQAfABAQ%3D%3D', // Videos only 'channels': 'EgIQAvABAQ%3D%3D', // Channels only 'playlists': 'EgIQA_ABAQ%3D%3D', // Playlists only 'music_songs': '', // For YouTube Music 'music_videos': '', 'music_albums': '', 'music_playlists': '', 'music_artists': '' }; const contentFilter = contentFilters.length > 0 ? contentFilters[0] : 'all'; const searchParams = filterMap[contentFilter] || ''; // Prepare request body similar to NewPipe Java implementation const requestBody = { context: { client: { clientName: YoutubeService.ANDROID_CLIENT_NAME, clientVersion: YoutubeService.ANDROID_CLIENT_VERSION, androidSdkVersion: parseInt(YoutubeService.ANDROID_SDK_VERSION), osName: 'Android', osVersion: YoutubeService.ANDROID_OS_VERSION, platform: 'MOBILE', userAgent: this.getAndroidUserAgent() }, user: { lockedSafetyMode: false }, request: { useSsl: true } }, query: query }; // Add search parameters if specified if (searchParams) { requestBody.params = searchParams; } // Add localization if (localization.countryCode) { requestBody.context.client.gl = localization.countryCode; } if (localization.languageCode) { requestBody.context.client.hl = localization.languageCode; } // Generate CPN (Content Playback Nonce) const cpn = this.generateContentPlaybackNonce(); requestBody.context.client.screenWidthPoints = 1080; requestBody.context.client.screenHeightPoints = 1920; requestBody.context.client.screenPixelDensity = 3; requestBody.context.client.utcOffsetMinutes = 0; requestBody.context.client.connectionType = 'CONN_CELLULAR_4G'; requestBody.context.client.memoryTotalKbytes = 8388608; requestBody.cpn = cpn; // Make API request to YouTube search endpoint const apiUrl = YoutubeService.YOUTUBEI_V1_GAPIS_URL + 'search'; const url = new URL(apiUrl); url.searchParams.set('key', 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w'); // Public API key url.searchParams.set('prettyPrint', 'false'); const response = await (0, node_fetch_1.default)(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': this.getAndroidUserAgent(), 'Accept': 'application/json', 'Accept-Language': localization.languageCode || 'en-US', 'Accept-Encoding': 'gzip, deflate, br', 'Origin': 'https://www.youtube.com', 'Referer': 'https://www.youtube.com/', 'X-Goog-Api-Format-Version': '2', 'X-YouTube-Client-Name': '3', // ANDROID 'X-YouTube-Client-Version': YoutubeService.ANDROID_CLIENT_VERSION }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`Search API request failed: ${response.status} ${response.statusText}`); } const data = await response.json(); // Parse search results using the same structure as NewPipe Java implementation return this.parseSearchResults(data, contentFilter); } catch (error) { console.error('YouTube search failed:', error); throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } parseSearchResults(data, contentFilter) { const result = { items: [], suggestion: '', corrected: '', metaInfo: [], errors: [] }; try { // Navigate to search results content (Android API has different structure) const contents = data?.contents?.sectionListRenderer?.contents; if (!contents || !Array.isArray(contents)) { return result; } // Process search result items for (const section of contents) { const itemSection = section.itemSectionRenderer; if (!itemSection?.contents) continue; for (const item of itemSection.contents) { // Check for suggestions first if (item.didYouMeanRenderer) { result.suggestion = this.extractTextFromRenderer(item.didYouMeanRenderer.correctedQuery); continue; } if (item.showingResultsForRenderer) { result.corrected = item.showingResultsForRenderer.correctedQueryEndpoint?.searchEndpoint?.query || ''; continue; } const parsedItem = this.parseSearchResultItem(item, contentFilter); if (parsedItem) { result.items.push(parsedItem); } } } } catch (error) { result.errors.push(`Failed to parse search results: ${error instanceof Error ? error.message : 'Unknown error'}`); } return result; } parseSearchResultItem(item, contentFilter) { try { // Handle video results (Android API uses compactVideoRenderer) if (item.videoRenderer || item.compactVideoRenderer) { const videoRenderer = item.videoRenderer || item.compactVideoRenderer; return this.parseVideoSearchResult(videoRenderer); } // Handle channel results if ((item.channelRenderer || item.compactChannelRenderer) && (contentFilter === 'all' || contentFilter === 'channels')) { const channelRenderer = item.channelRenderer || item.compactChannelRenderer; return this.parseChannelSearchResult(channelRenderer); } // Handle playlist results (Android API uses compactPlaylistRenderer) if ((item.playlistRenderer || item.compactPlaylistRenderer) && (contentFilter === 'all' || contentFilter === 'playlists')) { const playlistRenderer = item.playlistRenderer || item.compactPlaylistRenderer; return this.parsePlaylistSearchResult(playlistRenderer); } // Handle radio/mix results if (item.radioRenderer && (contentFilter === 'all' || contentFilter === 'playlists')) { return this.parseRadioSearchResult(item.radioRenderer); } // Skip non-content items if (item.elementRenderer) { return null; } } catch (error) { console.warn('Failed to parse search result item:', error); } return null; } parseVideoSearchResult(videoRenderer) { const videoId = videoRenderer.videoId; const title = this.extractTextFromRenderer(videoRenderer.title); const url = `https://www.youtube.com/watch?v=${videoId}`; // Extract thumbnail (compact renderer has different structure) const thumbnails = this.extractThumbnails(videoRenderer.thumbnail?.thumbnails || videoRenderer.thumbnailRenderer?.thumbnails); // Extract duration (compact renderer may have different field) const duration = this.parseDuration(videoRenderer.lengthText?.simpleText || videoRenderer.thumbnailOverlayTimeStatusRenderer?.text?.simpleText || videoRenderer.lengthText?.accessibility?.accessibilityData?.label); // Extract uploader info (compact renderer has different structure) const uploaderName = this.extractTextFromRenderer(videoRenderer.ownerText || videoRenderer.longBylineText || videoRenderer.shortBylineText); const uploaderUrl = this.extractUploaderUrl(videoRenderer.ownerText || videoRenderer.longBylineText || videoRenderer.shortBylineText); const uploaderAvatars = this.extractUploaderAvatars(videoRenderer); // Extract view count (compact renderer may have different field) const viewCount = this.parseViewCount(videoRenderer.viewCountText?.simpleText || videoRenderer.viewCountText?.runs?.[0]?.text); // Extract upload date const uploadDate = videoRenderer.publishedTimeText?.simpleText; return { infoType: types_1.InfoType.STREAM, serviceId: 0, // YouTube service ID url: url, name: title, thumbnails: thumbnails, streamType: types_1.StreamType.VIDEO_STREAM, // Default for search results duration: duration, uploaderName: uploaderName, uploaderUrl: uploaderUrl, uploaderAvatars: uploaderAvatars, uploadDate: uploadDate, viewCount: viewCount, shortFormContent: duration < 60 // Consider videos under 1 minute as shorts }; } parseChannelSearchResult(channelRenderer) { const channelId = channelRenderer.channelId; const title = this.extractTextFromRenderer(channelRenderer.title); const url = `https://www.youtube.com/channel/${channelId}`; const thumbnails = this.extractThumbnails(channelRenderer.thumbnail?.thumbnails); return { infoType: types_1.InfoType.CHANNEL, serviceId: 0, url: url, name: title, thumbnails: thumbnails }; } parsePlaylistSearchResult(playlistRenderer) { const playlistId = playlistRenderer.playlistId; const title = this.extractTextFromRenderer(playlistRenderer.title); const url = `https://www.youtube.com/playlist?list=${playlistId}`; // Extract thumbnails (compact renderer has different structure) const thumbnails = this.extractThumbnails(playlistRenderer.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail?.thumbnails || playlistRenderer.thumbnail?.thumbnails || playlistRenderer.thumbnails?.[0]?.thumbnails); return { infoType: types_1.InfoType.PLAYLIST, serviceId: 0, url: url, name: title, thumbnails: thumbnails }; } parseRadioSearchResult(radioRenderer) { const playlistId = radioRenderer.playlistId; const title = this.extractTextFromRenderer(radioRenderer.title); const url = `https://www.youtube.com/watch?v=${radioRenderer.videoId}&list=${playlistId}`; const thumbnails = this.extractThumbnails(radioRenderer.thumbnail?.thumbnails); return { infoType: types_1.InfoType.PLAYLIST, serviceId: 0, url: url, name: title, thumbnails: thumbnails }; } // Helper methods for parsing search results extractTextFromRenderer(textRenderer) { if (!textRenderer) return ''; if (typeof textRenderer === 'string') return textRenderer; if (textRenderer.simpleText) return textRenderer.simpleText; if (textRenderer.runs && Array.isArray(textRenderer.runs)) { return textRenderer.runs.map((run) => run.text || '').join(''); } return ''; } extractThumbnails(thumbnailArray) { if (!Array.isArray(thumbnailArray)) return []; return thumbnailArray.map(thumb => ({ url: thumb.url, width: thumb.width, height: thumb.height, estimatedResolutionLevel: thumb.width > 480 ? types_1.ResolutionLevel.HIGH : thumb.width > 320 ? types_1.ResolutionLevel.MEDIUM : types_1.ResolutionLevel.LOW })); } parseDuration(durationText) { if (!durationText) return 0; // Parse duration in format "MM:SS" or "HH:MM:SS" const parts = durationText.split(':').map(p => parseInt(p, 10)); if (parts.length === 2) { return parts[0] * 60 + parts[1]; // MM:SS } else if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; // HH:MM:SS } return 0; } parseViewCount(viewText) { if (!viewText) return 0; // Parse view count like "1.2M views", "123K views", "1,234 views" const match = viewText.match(/([\d,\.]+)([KMB]?)/i); if (!match) return 0; const number = parseFloat(match[1].replace(/,/g, '')); const suffix = match[2]?.toUpperCase(); switch (suffix) { case 'K': return Math.floor(number * 1000); case 'M': return Math.floor(number * 1000000); case 'B': return Math.floor(number * 1000000000); default: return Math.floor(number); } } extractUploaderUrl(textRenderer) { if (!textRenderer?.runs) return ''; for (const run of textRenderer.runs) { if (run.navigationEndpoint?.commandMetadata?.webCommandMetadata?.url) { const url = run.navigationEndpoint.commandMetadata.webCommandMetadata.url; if (url.includes('/channel/') || url.includes('/c/') || url.includes('/user/')) { return `https://www.youtube.com${url}`; } } } return ''; } extractUploaderAvatars(videoRenderer) { const avatarThumbnails = videoRenderer.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail?.thumbnails; return this.extractThumbnails(avatarThumbnails); } async getSuggestionExtractor(query, localization = { languageCode: 'en', countryCode: 'US' }) { try { // YouTube search suggestions endpoint const suggestUrl = 'https://suggestqueries.google.com/complete/search'; const params = new URLSearchParams({ client: 'youtube', ds: 'yt', q: query, gl: localization.countryCode || 'US', hl: localization.languageCode || 'en' }); const response = await (0, node_fetch_1.default)(`${suggestUrl}?${params}`, { headers: { 'User-Agent': this.getAndroidUserAgent(), 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`Suggestions request failed: ${response.status}`); } const text = await response.text(); // Parse JSONP response (format: callback([query, [suggestions]]) const jsonMatch = text.match(/\[.*\]/); if (!jsonMatch) return []; const data = JSON.parse(jsonMatch[0]); if (data.length < 2 || !Array.isArray(data[1])) return []; return data[1].map((suggestion) => Array.isArray(suggestion) ? suggestion[0] : suggestion).filter(Boolean); } catch (error) { console.error('Failed to get search suggestions:', error); return []; } } async getKioskExtractor(_kioskId, _localization) { throw new Error('Kiosk extraction not implemented yet'); } async getKioskList() { return ['Trending']; } async getChannelTabExtractor(_channelUrl, _tab) { throw new Error('Channel tabs not implemented yet'); } async getCommentsExtractor(_streamUrl) { throw new Error('Comments extraction not implemented yet'); } /** * Process DASH manifest URL and extract streams */ async processDashManifest(dashUrl) { try { // Download DASH manifest const response = await (0, node_fetch_1.default)(dashUrl, { headers: { 'User-Agent': this.getAndroidUserAgent(), 'Accept': 'application/dash+xml,application/xml,text/xml', 'Accept-Language': 'en-US,en;q=0.9', 'Referer': 'https://www.youtube.com/' } }); if (!response.ok) { throw new Error(`DASH manifest request failed: ${response.status}`); } const manifestXml = await response.text(); return await this.dashManifestParser.parseDashManifest(manifestXml); } catch (error) { console.error('Failed to process DASH manifest:', error); return null; } } /** * Merge DASH streams with existing streams, avoiding duplicates */ mergeDashStreams(dashManifest, audioStreams, videoStreams, videoOnlyStreams) { // Merge audio streams for (const dashAudio of dashManifest.audioStreams) { if (dashAudio.url && !this.isDuplicateAudioStream(dashAudio, audioStreams)) { audioStreams.push({ ...dashAudio, // Add DASH-specific metadata quality: dashAudio.quality || 'dash' }); } } // Merge video streams for (const dashVideo of dashManifest.videoStreams) { if (dashVideo.url && !this.isDuplicateVideoStream(dashVideo, videoStreams)) { videoStreams.push({ ...dashVideo, quality: dashVideo.quality || 'dash' }); } } // Merge video-only streams for (const dashVideoOnly of dashManifest.videoOnlyStreams) { if (dashVideoOnly.url && !this.isDuplicateVideoStream(dashVideoOnly, videoOnlyStreams)) { videoOnlyStreams.push({ ...dashVideoOnly, quality: dashVideoOnly.quality || 'dash', isVideoOnly: true }); } } } /** * Check if audio stream is duplicate */ isDuplicateAudioStream(newStream, existingStreams) { return existingStreams.some(existing => existing.itag === newStream.itag || (existing.bitrate === newStream.bitrate && existing.format === newStream.format && existing.codec === newStream.codec)); } /** * Check if video stream is duplicate */ isDuplicateVideoStream(newStream, existingStreams) { return existingStreams.some(existing => existing.itag === newStream.itag || (existing.width === newStream.width && existing.height === newStream.height && existing.bitrate === newStream.bitrate && existing.format === newStream.format)); } } exports.YoutubeService = YoutubeService; // Android client constants (from working NewPipe implementation) YoutubeService.ANDROID_CLIENT_NAME = 'ANDROID'; YoutubeService.ANDROID_CLIENT_VERSION = '19.28.35'; YoutubeService.ANDROID_OS_VERSION = '15'; YoutubeService.ANDROID_SDK_VERSION = '35'; // API endpoints (mobile clients use googleapis.com) YoutubeService.YOUTUBEI_V1_GAPIS_URL = 'https://youtubei.googleapis.com/youtubei/v1/'; YoutubeService.YOUTUBEI_V1_URL = 'https://www.youtube.com/youtubei/v1/'; //# sourceMappingURL=YoutubeService.js