newpipe-extractor-js
Version:
JavaScript/Node.js port of NewPipeExtractor
1,073 lines • 50 kB
JavaScript
"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