const PassThrough = require('stream').PassThrough; const getInfo = require('./info'); const utils = require('./utils'); const formatUtils = require('./format-utils'); const urlUtils = require('./url-utils'); const sig = require('./sig'); const miniget = require('miniget'); const m3u8stream = require('m3u8stream'); const { parseTimestamp } = require('m3u8stream'); /** * @param {string} link * @param {!Object} options * @returns {ReadableStream} */ const ytdl = (link, options) => { const stream = createStream(options); ytdl.getInfo(link, options).then(info => { downloadFromInfoCallback(stream, info, options); }, stream.emit.bind(stream, 'error')); return stream; }; module.exports = ytdl; ytdl.getBasicInfo = getInfo.getBasicInfo; ytdl.getInfo = getInfo.getInfo; ytdl.chooseFormat = formatUtils.chooseFormat; ytdl.filterFormats = formatUtils.filterFormats; ytdl.validateID = urlUtils.validateID; ytdl.validateURL = urlUtils.validateURL; ytdl.getURLVideoID = urlUtils.getURLVideoID; ytdl.getVideoID = urlUtils.getVideoID; ytdl.cache = { sig: sig.cache, info: getInfo.cache, watch: getInfo.watchPageCache, cookie: getInfo.cookieCache, }; ytdl.version = require('../package.json').version; const createStream = options => { const stream = new PassThrough({ highWaterMark: (options && options.highWaterMark) || 1024 * 512, }); stream._destroy = () => { stream.destroyed = true; }; return stream; }; const pipeAndSetEvents = (req, stream, end) => { // Forward events from the request to the stream. [ 'abort', 'request', 'response', 'error', 'redirect', 'retry', 'reconnect', ].forEach(event => { req.prependListener(event, stream.emit.bind(stream, event)); }); req.pipe(stream, { end }); }; /** * Chooses a format to download. * * @param {stream.Readable} stream * @param {Object} info * @param {Object} options */ const downloadFromInfoCallback = (stream, info, options) => { options = options || {}; let err = utils.playError(info.player_response, ['UNPLAYABLE', 'LIVE_STREAM_OFFLINE', 'LOGIN_REQUIRED']); if (err) { stream.emit('error', err); return; } if (!info.formats.length) { stream.emit('error', Error('This video is unavailable')); return; } let format; try { format = formatUtils.chooseFormat(info.formats, options); } catch (e) { stream.emit('error', e); return; } stream.emit('info', info, format); if (stream.destroyed) { return; } let contentLength, downloaded = 0; const ondata = chunk => { downloaded += chunk.length; stream.emit('progress', chunk.length, downloaded, contentLength); }; // Download the file in chunks, in this case the default is 10MB, // anything over this will cause youtube to throttle the download const dlChunkSize = options.dlChunkSize || 1024 * 1024 * 10; let req; let shouldEnd = true; if (format.isHLS || format.isDashMPD) { req = m3u8stream(format.url, { chunkReadahead: +info.live_chunk_readahead, begin: options.begin || (format.isLive && Date.now()), liveBuffer: options.liveBuffer, requestOptions: options.requestOptions, parser: format.isDashMPD ? 'dash-mpd' : 'm3u8', id: format.itag, }); req.on('progress', (segment, totalSegments) => { stream.emit('progress', segment.size, segment.num, totalSegments); }); pipeAndSetEvents(req, stream, shouldEnd); } else { const requestOptions = Object.assign({}, options.requestOptions, { maxReconnects: 6, maxRetries: 3, backoff: { inc: 500, max: 10000 }, }); let shouldBeChunked = dlChunkSize !== 0 && (!format.hasAudio || !format.hasVideo); if (shouldBeChunked) { let start = (options.range && options.range.start) || 0; let end = start + dlChunkSize; const rangeEnd = options.range && options.range.end; contentLength = options.range ? (rangeEnd ? rangeEnd + 1 : parseInt(format.contentLength)) - start : parseInt(format.contentLength); const getNextChunk = () => { if (!rangeEnd && end >= contentLength) end = 0; if (rangeEnd && end > rangeEnd) end = rangeEnd; shouldEnd = !end || end === rangeEnd; requestOptions.headers = Object.assign({}, requestOptions.headers, { Range: `bytes=${start}-${end || ''}`, }); req = miniget(format.url, requestOptions); req.on('data', ondata); req.on('end', () => { if (stream.destroyed) { return; } if (end && end !== rangeEnd) { start = end + 1; end += dlChunkSize; getNextChunk(); } }); pipeAndSetEvents(req, stream, shouldEnd); }; getNextChunk(); } else { // Audio only and video only formats don't support begin if (options.begin) { format.url += `&begin=${parseTimestamp(options.begin)}`; } if (options.range && (options.range.start || options.range.end)) { requestOptions.headers = Object.assign({}, requestOptions.headers, { Range: `bytes=${options.range.start || '0'}-${options.range.end || ''}`, }); } req = miniget(format.url, requestOptions); req.on('response', res => { if (stream.destroyed) { return; } contentLength = contentLength || parseInt(res.headers['content-length']); }); req.on('data', ondata); pipeAndSetEvents(req, stream, shouldEnd); } } stream._destroy = () => { stream.destroyed = true; req.destroy(); req.end(); }; }; /** * Can be used to download video after its `info` is gotten through * `ytdl.getInfo()`. In case the user might want to look at the * `info` object before deciding to download. * * @param {Object} info * @param {!Object} options * @returns {ReadableStream} */ ytdl.downloadFromInfo = (info, options) => { const stream = createStream(options); if (!info.full) { throw Error('Cannot use `ytdl.downloadFromInfo()` when called ' + 'with info from `ytdl.getBasicInfo()`'); } setImmediate(() => { downloadFromInfoCallback(stream, info, options); }); return stream; };