mirror of
				https://github.com/musix-org/musix-oss
				synced 2025-10-31 05:21:34 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			377 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			377 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const urllib      = require('url');
 | |
| const querystring = require('querystring');
 | |
| const sax         = require('sax');
 | |
| const request     = require('miniget');
 | |
| const util        = require('./util');
 | |
| const extras      = require('./info-extras');
 | |
| const sig         = require('./sig');
 | |
| const FORMATS     = require('./formats');
 | |
| 
 | |
| 
 | |
| const VIDEO_URL = 'https://www.youtube.com/watch?v=';
 | |
| const EMBED_URL = 'https://www.youtube.com/embed/';
 | |
| const VIDEO_EURL = 'https://youtube.googleapis.com/v/';
 | |
| const INFO_HOST = 'www.youtube.com';
 | |
| const INFO_PATH = '/get_video_info';
 | |
| const KEYS_TO_SPLIT = [
 | |
|   'fmt_list',
 | |
|   'fexp',
 | |
|   'watermark'
 | |
| ];
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Gets info from a video without getting additional formats.
 | |
|  *
 | |
|  * @param {string} id
 | |
|  * @param {Object} options
 | |
|  * @param {Function(Error, Object)} callback
 | |
|  */
 | |
| exports.getBasicInfo = (id, options, callback) => {
 | |
|   // Try getting config from the video page first.
 | |
|   const params = 'hl=' + (options.lang || 'en');
 | |
|   let url = VIDEO_URL + id + '&' + params +
 | |
|     '&bpctr=' + Math.ceil(Date.now() / 1000);
 | |
| 
 | |
|   // Remove header from watch page request.
 | |
|   // Otherwise, it'll use a different framework for rendering content.
 | |
|   const reqOptions = Object.assign({}, options.requestOptions);
 | |
|   reqOptions.headers = Object.assign({}, reqOptions.headers, {
 | |
|     'User-Agent': ''
 | |
|   });
 | |
| 
 | |
|   request(url, reqOptions, (err, res, body) => {
 | |
|     if (err) return callback(err);
 | |
| 
 | |
|     // Check if there are any errors with this video page.
 | |
|     const unavailableMsg = util.between(body, '<div id="player-unavailable"', '>');
 | |
|     if (unavailableMsg &&
 | |
|         !/\bhid\b/.test(util.between(unavailableMsg, 'class="', '"'))) {
 | |
|       // Ignore error about age restriction.
 | |
|       if (!body.includes('<div id="watch7-player-age-gate-content"')) {
 | |
|         return callback(Error(util.between(body,
 | |
|           '<h1 id="unavailable-message" class="message">', '</h1>').trim()));
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Parse out additional metadata from this page.
 | |
|     const additional = {
 | |
|       // Get the author/uploader.
 | |
|       author: extras.getAuthor(body),
 | |
| 
 | |
|       // Get the day the vid was published.
 | |
|       published: extras.getPublished(body),
 | |
| 
 | |
|       // Get description.
 | |
|       description: extras.getVideoDescription(body),
 | |
| 
 | |
|       // Get media info.
 | |
|       media: extras.getVideoMedia(body),
 | |
| 
 | |
|       // Get related videos.
 | |
|       related_videos: extras.getRelatedVideos(body),
 | |
| 
 | |
|       // Give the standard link to the video.
 | |
|       video_url: VIDEO_URL + id,
 | |
|     };
 | |
| 
 | |
|     const jsonStr = util.between(body, 'ytplayer.config = ', '</script>');
 | |
|     let config;
 | |
|     if (jsonStr) {
 | |
|       config = jsonStr.slice(0, jsonStr.lastIndexOf(';ytplayer.load'));
 | |
|       gotConfig(id, options, additional, config, false, callback);
 | |
| 
 | |
|     } else {
 | |
|       // If the video page doesn't work, maybe because it has mature content.
 | |
|       // and requires an account logged in to view, try the embed page.
 | |
|       url = EMBED_URL + id + '?' + params;
 | |
|       request(url, options.requestOptions, (err, res, body) => {
 | |
|         if (err) return callback(err);
 | |
|         config = util.between(body, 't.setConfig({\'PLAYER_CONFIG\': ', /\}(,'|\}\);)/);
 | |
|         gotConfig(id, options, additional, config, true, callback);
 | |
|       });
 | |
|     }
 | |
|   });
 | |
| };
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * @param {Object} id
 | |
|  * @param {Object} options
 | |
|  * @param {Object} additional
 | |
|  * @param {Object} config
 | |
|  * @param {boolean} fromEmbed
 | |
|  * @param {Function(Error, Object)} callback
 | |
|  */
 | |
| const gotConfig = (id, options, additional, config, fromEmbed, callback) => {
 | |
|   if (!config) {
 | |
|     return callback(Error('Could not find player config'));
 | |
|   }
 | |
|   try {
 | |
|     config = JSON.parse(config + (fromEmbed ? '}' : ''));
 | |
|   } catch (err) {
 | |
|     return callback(Error('Error parsing config: ' + err.message));
 | |
|   }
 | |
|   const url = urllib.format({
 | |
|     protocol: 'https',
 | |
|     host: INFO_HOST,
 | |
|     pathname: INFO_PATH,
 | |
|     query: {
 | |
|       video_id: id,
 | |
|       eurl: VIDEO_EURL + id,
 | |
|       ps: 'default',
 | |
|       gl: 'US',
 | |
|       hl: (options.lang || 'en'),
 | |
|       sts: config.sts,
 | |
|     },
 | |
|   });
 | |
|   request(url, options.requestOptions, (err, res, body) => {
 | |
|     if (err) return callback(err);
 | |
|     let info = querystring.parse(body);
 | |
| 
 | |
|     if (info.status === 'fail') {
 | |
|       if (config.args && (config.args.fmt_list ||
 | |
|         config.args.url_encoded_fmt_stream_map || config.args.adaptive_fmts)) {
 | |
|         info = config.args;
 | |
|         info.no_embed_allowed = true;
 | |
|       } else {
 | |
|         return callback(
 | |
|           Error(`Code ${info.errorcode}: ${util.stripHTML(info.reason)}`));
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const player_response = config.args.player_response || info.player_response;
 | |
|     if (player_response) {
 | |
|       try {
 | |
|         info.player_response = JSON.parse(player_response);
 | |
|       } catch (err) {
 | |
|         return callback(
 | |
|           Error('Error parsing `player_response`: ' + err.message));
 | |
|       }
 | |
|       let playability = info.player_response.playabilityStatus;
 | |
|       if (playability && playability.status === 'UNPLAYABLE') {
 | |
|         return callback(Error(playability.reason));
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Split some keys by commas.
 | |
|     KEYS_TO_SPLIT.forEach((key) => {
 | |
|       if (!info[key]) return;
 | |
|       info[key] = info[key]
 | |
|         .split(',')
 | |
|         .filter((v) => v !== '');
 | |
|     });
 | |
| 
 | |
|     info.fmt_list = info.fmt_list ?
 | |
|       info.fmt_list.map((format) => format.split('/')) : [];
 | |
| 
 | |
|     info.formats = util.parseFormats(info);
 | |
| 
 | |
|     // Add additional properties to info.
 | |
|     Object.assign(info, additional);
 | |
|     info.age_restricted = fromEmbed;
 | |
|     info.html5player = config.assets.js;
 | |
| 
 | |
|     callback(null, info);
 | |
|   });
 | |
| };
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Gets info from a video additional formats and deciphered URLs.
 | |
|  *
 | |
|  * @param {string} id
 | |
|  * @param {Object} options
 | |
|  * @param {Function(Error, Object)} callback
 | |
|  */
 | |
| exports.getFullInfo = (id, options, callback) => {
 | |
|   return exports.getBasicInfo(id, options, (err, info) => {
 | |
|     if (err) return callback(err);
 | |
|     const hasManifest =
 | |
|       info.player_response && info.player_response.streamingData && (
 | |
|         info.player_response.streamingData.dashManifestUrl ||
 | |
|         info.player_response.streamingData.hlsManifestUrl
 | |
|       );
 | |
|     if (info.formats.length || hasManifest) {
 | |
|       const html5playerfile = urllib.resolve(VIDEO_URL, info.html5player);
 | |
|       sig.getTokens(html5playerfile, options, (err, tokens) => {
 | |
|         if (err) return callback(err);
 | |
| 
 | |
|         sig.decipherFormats(info.formats, tokens, options.debug);
 | |
|         let funcs = [];
 | |
|         if (hasManifest && info.player_response.streamingData.dashManifestUrl) {
 | |
|           let url = decipherURL(info.player_response.streamingData.dashManifestUrl, tokens);
 | |
|           funcs.push(getDashManifest.bind(null, url, options));
 | |
|         }
 | |
|         if (hasManifest && info.player_response.streamingData.hlsManifestUrl) {
 | |
|           let url = decipherURL(info.player_response.streamingData.hlsManifestUrl, tokens);
 | |
|           funcs.push(getM3U8.bind(null, url, options));
 | |
|         }
 | |
| 
 | |
|         util.parallel(funcs, (err, results) => {
 | |
|           if (err) return callback(err);
 | |
|           if (results[0]) { mergeFormats(info, results[0]); }
 | |
|           if (results[1]) { mergeFormats(info, results[1]); }
 | |
|           if (!info.formats.length) {
 | |
|             callback(Error('No formats found'));
 | |
|             return;
 | |
|           }
 | |
| 
 | |
|           if (options.debug) {
 | |
|             info.formats.forEach((format) => {
 | |
|               const itag = format.itag;
 | |
|               if (!FORMATS[itag]) {
 | |
|                 console.warn(`No format metadata for itag ${itag} found`);
 | |
|               }
 | |
|             });
 | |
|           }
 | |
| 
 | |
|           info.formats.forEach(util.addFormatMeta);
 | |
|           info.formats.sort(util.sortFormats);
 | |
|           info.full = true;
 | |
|           callback(null, info);
 | |
|         });
 | |
|       });
 | |
|     } else {
 | |
|       callback(Error('This video is unavailable'));
 | |
|     }
 | |
|   });
 | |
| };
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * @param {string} url
 | |
|  * @param {Array.<string>} tokens
 | |
|  */
 | |
| const decipherURL = (url, tokens) => {
 | |
|   return url.replace(/\/s\/([a-fA-F0-9.]+)/, (_, s) => {
 | |
|     return '/signature/' + sig.decipher(tokens, s);
 | |
|   });
 | |
| };
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Merges formats from DASH or M3U8 with formats from video info page.
 | |
|  *
 | |
|  * @param {Object} info
 | |
|  * @param {Object} formatsMap
 | |
|  */
 | |
| const mergeFormats = (info, formatsMap) => {
 | |
|   info.formats.forEach((f) => {
 | |
|     if (!formatsMap[f.itag]) {
 | |
|       formatsMap[f.itag] = f;
 | |
|     }
 | |
|   });
 | |
|   info.formats = [];
 | |
|   for (let itag in formatsMap) { info.formats.push(formatsMap[itag]); }
 | |
| };
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Gets additional DASH formats.
 | |
|  *
 | |
|  * @param {string} url
 | |
|  * @param {Object} options
 | |
|  * @param {Function(!Error, Array.<Object>)} callback
 | |
|  */
 | |
| const getDashManifest = (url, options, callback) => {
 | |
|   let formats = {};
 | |
| 
 | |
|   const parser = sax.parser(false);
 | |
|   parser.onerror = callback;
 | |
|   parser.onopentag = (node) => {
 | |
|     if (node.name === 'REPRESENTATION') {
 | |
|       const itag = node.attributes.ID;
 | |
|       formats[itag] = { itag, url };
 | |
|     }
 | |
|   };
 | |
|   parser.onend = () => { callback(null, formats); };
 | |
| 
 | |
|   const req = request(urllib.resolve(VIDEO_URL, url), options.requestOptions);
 | |
|   req.setEncoding('utf8');
 | |
|   req.on('error', callback);
 | |
|   req.on('data', (chunk) => { parser.write(chunk); });
 | |
|   req.on('end', parser.close.bind(parser));
 | |
| };
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Gets additional formats.
 | |
|  *
 | |
|  * @param {string} url
 | |
|  * @param {Object} options
 | |
|  * @param {Function(!Error, Array.<Object>)} callback
 | |
|  */
 | |
| const getM3U8 = (url, options, callback) => {
 | |
|   url = urllib.resolve(VIDEO_URL, url);
 | |
|   request(url, options.requestOptions, (err, res, body) => {
 | |
|     if (err) return callback(err);
 | |
| 
 | |
|     let formats = {};
 | |
|     body
 | |
|       .split('\n')
 | |
|       .filter((line) => /https?:\/\//.test(line))
 | |
|       .forEach((line) => {
 | |
|         const itag = line.match(/\/itag\/(\d+)\//)[1];
 | |
|         formats[itag] = { itag: itag, url: line };
 | |
|       });
 | |
|     callback(null, formats);
 | |
|   });
 | |
| };
 | |
| 
 | |
| 
 | |
| // Cached for getting basic/full info.
 | |
| exports.cache = new Map();
 | |
| exports.cache.timeout = 1000;
 | |
| 
 | |
| 
 | |
| // Cache get info functions.
 | |
| // In case a user wants to get a video's info before downloading.
 | |
| for (let fnName of ['getBasicInfo', 'getFullInfo']) {
 | |
|   /**
 | |
|    * @param {string} link
 | |
|    * @param {Object} options
 | |
|    * @param {Function(Error, Object)} callback
 | |
|    */
 | |
|   const fn = exports[fnName];
 | |
|   exports[fnName] = (link, options, callback) => {
 | |
|     if (typeof options === 'function') {
 | |
|       callback = options;
 | |
|       options = {};
 | |
|     } else if (!options) {
 | |
|       options = {};
 | |
|     }
 | |
| 
 | |
|     if (!callback) {
 | |
|       return new Promise((resolve, reject) => {
 | |
|         exports[fnName](link, options, (err, info) => {
 | |
|           if (err) return reject(err);
 | |
|           resolve(info);
 | |
|         });
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     const id = util.getVideoID(link);
 | |
|     if (id instanceof Error) return callback(id);
 | |
| 
 | |
|     const key = [fnName, id, options.lang].join('-');
 | |
|     if (exports.cache.has(key)) {
 | |
|       callback(null, exports.cache.get(key));
 | |
|     } else {
 | |
|       fn(id, options, (err, info) => {
 | |
|         if (err) return callback(err);
 | |
|         exports.cache.set(key, info);
 | |
|         setTimeout(() => { exports.cache.delete(key); }, exports.cache.timeout);
 | |
|         callback(null, info);
 | |
|       });
 | |
|     }
 | |
|   };
 | |
| }
 | |
| 
 | |
| 
 | |
| // Export a few helpers.
 | |
| exports.validateID = util.validateID;
 | |
| exports.validateURL = util.validateURL;
 | |
| exports.getURLVideoID = util.getURLVideoID;
 | |
| exports.getVideoID = util.getVideoID;
 | 
