import { ValidationError } from './errors'; import { parse as urlParse } from 'url'; import net from 'net'; import Media from './media'; import { hash } from './util/hash'; import { get as httpGet } from 'http'; import { get as httpsGet } from 'https'; const LOGGER = require('@calzoneman/jsli')('custom-media'); const SOURCE_QUALITIES = new Set([ 240, 360, 480, 540, 720, 1080, 1440, 2160 ]); const SOURCE_CONTENT_TYPES = new Set([ 'application/x-mpegURL', 'audio/aac', 'audio/ogg', 'audio/mpeg', 'video/mp4', 'video/ogg', 'video/webm' ]); const LIVE_ONLY_CONTENT_TYPES = new Set([ 'application/x-mpegURL' ]); export function lookup(url, opts) { if (!opts) opts = {}; if (!opts.hasOwnProperty('timeout')) opts.timeout = 10000; return new Promise((resolve, reject) => { const options = { headers: { 'Accept': 'application/json' } }; Object.assign(options, parseURL(url)); if (!/^https?:$/.test(options.protocol)) { reject(new ValidationError( `Unacceptable protocol "${options.protocol}". Custom metadata must be` + ' retrieved by HTTP or HTTPS' )); return; } LOGGER.info('Looking up %s', url); // this is fucking stupid const get = options.protocol === 'https:' ? httpsGet : httpGet; const req = get(options); req.setTimeout(opts.timeout, () => { const error = new Error('Request timed out'); error.code = 'ETIMEDOUT'; reject(error); }); req.on('error', error => { LOGGER.warn('Request for %s failed: %s', url, error); reject(error); }); req.on('response', res => { if (res.statusCode !== 200) { req.abort(); reject(new Error( `Expected HTTP 200 OK, not ${res.statusCode} ${res.statusMessage}` )); return; } if (!/^application\/json/.test(res.headers['content-type'])) { req.abort(); reject(new Error( `Expected content-type application/json, not ${res.headers['content-type']}` )); return; } let buffer = ''; res.setEncoding('utf8'); res.on('data', data => { buffer += data; if (buffer.length > 100 * 1024) { req.abort(); reject(new Error('Response size exceeds 100KB')); } }); res.on('end', () => { resolve(buffer); }); }); }).then(body => { return convert(url, JSON.parse(body)); }); } export function convert(id, data) { validate(data); if (data.live) data.duration = 0; const sources = {}; for (let source of data.sources) { if (!sources.hasOwnProperty(source.quality)) sources[source.quality] = []; sources[source.quality].push({ link: source.url, contentType: source.contentType, quality: source.quality }); } const meta = { direct: sources, textTracks: data.textTracks, thumbnail: data.thumbnail, // Currently ignored by Media live: !!data.live // Currently ignored by Media }; return new Media(id, data.title, data.duration, 'cm', meta); } export function validate(data) { if (typeof data.title !== 'string') throw new ValidationError('title must be a string'); if (!data.title) throw new ValidationError('title must not be blank'); if (typeof data.duration !== 'number') throw new ValidationError('duration must be a number'); if (!isFinite(data.duration) || data.duration < 0) throw new ValidationError('duration must be a non-negative finite number'); if (data.hasOwnProperty('live') && typeof data.live !== 'boolean') throw new ValidationError('live must be a boolean'); if (data.hasOwnProperty('thumbnail')) { if (typeof data.thumbnail !== 'string') throw new ValidationError('thumbnail must be a string'); validateURL(data.thumbnail); } validateSources(data.sources, data); validateTextTracks(data.textTracks); } function validateSources(sources, data) { if (!Array.isArray(sources)) throw new ValidationError('sources must be a list'); if (sources.length === 0) throw new ValidationError('source list must be nonempty'); for (let source of sources) { if (typeof source.url !== 'string') throw new ValidationError('source URL must be a string'); validateURL(source.url); if (!SOURCE_CONTENT_TYPES.has(source.contentType)) throw new ValidationError( `unacceptable source contentType "${source.contentType}"` ); if (LIVE_ONLY_CONTENT_TYPES.has(source.contentType) && !data.live) throw new ValidationError( `contentType "${source.contentType}" requires live: true` ); if (!SOURCE_QUALITIES.has(source.quality)) throw new ValidationError(`unacceptable source quality "${source.quality}"`); if (source.hasOwnProperty('bitrate')) { if (typeof source.bitrate !== 'number') throw new ValidationError('source bitrate must be a number'); if (!isFinite(source.bitrate) || source.bitrate < 0) throw new ValidationError( 'source bitrate must be a non-negative finite number' ); } } } function validateTextTracks(textTracks) { if (typeof textTracks === 'undefined') { return; } if (!Array.isArray(textTracks)) throw new ValidationError('textTracks must be a list'); for (let track of textTracks) { if (typeof track.url !== 'string') throw new ValidationError('text track URL must be a string'); validateURL(track.url); if (track.contentType !== 'text/vtt') throw new ValidationError( `unacceptable text track contentType "${track.contentType}"` ); if (typeof track.name !== 'string') throw new ValidationError('text track name must be a string'); if (!track.name) throw new ValidationError('text track name must be nonempty'); } } function parseURL(urlstring) { const url = urlParse(urlstring); // legacy url.parse doesn't check this if (url.protocol == null || url.host == null) { throw new Error(`Invalid URL "${urlstring}"`); } return url; } function validateURL(urlstring) { let url; try { url = parseURL(urlstring); } catch (error) { throw new ValidationError(`invalid URL "${urlstring}"`); } if (url.protocol !== 'https:') throw new ValidationError(`URL protocol must be HTTPS (invalid: "${urlstring}")`); if (net.isIP(url.hostname)) throw new ValidationError( 'URL hostname must be a domain name, not an IP address' + ` (invalid: "${urlstring}")` ); }