1 import { ValidationError } from './errors';
2 import { parse as urlParse } from 'url';
4 import Media from './media';
5 import { get as httpGet } from 'http';
6 import { get as httpsGet } from 'https';
8 const LOGGER = require('@calzoneman/jsli')('custom-media');
10 const SOURCE_QUALITIES = new Set([
21 const SOURCE_CONTENT_TYPES = new Set([
22 'application/dash+xml',
23 'application/x-mpegURL',
33 const LIVE_ONLY_CONTENT_TYPES = new Set([
34 'application/dash+xml'
37 export function lookup(url, opts) {
39 if (!opts.hasOwnProperty('timeout')) opts.timeout = 10000;
41 return new Promise((resolve, reject) => {
44 'Accept': 'application/json'
48 Object.assign(options, parseURL(url));
50 if (!/^https?:$/.test(options.protocol)) {
51 reject(new ValidationError(
52 `Unacceptable protocol "${options.protocol}". Custom metadata must be`
53 + ' retrieved by HTTP or HTTPS'
59 LOGGER.info('Looking up %s', url);
61 // this is fucking stupid
62 const get = options.protocol === 'https:' ? httpsGet : httpGet;
63 const req = get(options);
65 req.setTimeout(opts.timeout, () => {
66 const error = new Error('Request timed out');
67 error.code = 'ETIMEDOUT';
71 req.on('error', error => {
72 LOGGER.warn('Request for %s failed: %s', url, error);
76 req.on('response', res => {
77 if (res.statusCode !== 200) {
81 `Expected HTTP 200 OK, not ${res.statusCode} ${res.statusMessage}`
87 if (!/^application\/json/.test(res.headers['content-type'])) {
91 `Expected content-type application/json, not ${res.headers['content-type']}`
98 res.setEncoding('utf8');
100 res.on('data', data => {
103 if (buffer.length > 100 * 1024) {
105 reject(new Error('Response size exceeds 100KB'));
109 res.on('end', () => {
114 return convert(url, JSON.parse(body));
118 export function convert(id, data) {
121 if (data.live) data.duration = 0;
125 for (let source of data.sources) {
126 if (!sources.hasOwnProperty(source.quality))
127 sources[source.quality] = [];
129 sources[source.quality].push({
131 contentType: source.contentType,
132 quality: source.quality
138 textTracks: data.textTracks,
139 thumbnail: data.thumbnail, // Currently ignored by Media
140 live: !!data.live // Currently ignored by Media
143 return new Media(id, data.title, data.duration, 'cm', meta);
146 export function validate(data) {
147 if (typeof data.title !== 'string')
148 throw new ValidationError('title must be a string');
150 throw new ValidationError('title must not be blank');
152 if (typeof data.duration !== 'number')
153 throw new ValidationError('duration must be a number');
154 if (!isFinite(data.duration) || data.duration < 0)
155 throw new ValidationError('duration must be a non-negative finite number');
157 if (data.hasOwnProperty('live') && typeof data.live !== 'boolean')
158 throw new ValidationError('live must be a boolean');
160 if (data.hasOwnProperty('thumbnail')) {
161 if (typeof data.thumbnail !== 'string')
162 throw new ValidationError('thumbnail must be a string');
163 validateURL(data.thumbnail);
166 validateSources(data.sources, data);
167 validateTextTracks(data.textTracks);
170 function validateSources(sources, data) {
171 if (!Array.isArray(sources))
172 throw new ValidationError('sources must be a list');
173 if (sources.length === 0)
174 throw new ValidationError('source list must be nonempty');
176 for (let source of sources) {
177 if (typeof source.url !== 'string')
178 throw new ValidationError('source URL must be a string');
179 validateURL(source.url);
181 if (!SOURCE_CONTENT_TYPES.has(source.contentType))
182 throw new ValidationError(
183 `unacceptable source contentType "${source.contentType}"`
186 if (LIVE_ONLY_CONTENT_TYPES.has(source.contentType) && !data.live)
187 throw new ValidationError(
188 `contentType "${source.contentType}" requires live: true`
191 if (!SOURCE_QUALITIES.has(source.quality))
192 throw new ValidationError(`unacceptable source quality "${source.quality}"`);
194 if (source.hasOwnProperty('bitrate')) {
195 if (typeof source.bitrate !== 'number')
196 throw new ValidationError('source bitrate must be a number');
197 if (!isFinite(source.bitrate) || source.bitrate < 0)
198 throw new ValidationError(
199 'source bitrate must be a non-negative finite number'
205 function validateTextTracks(textTracks) {
206 if (typeof textTracks === 'undefined') {
210 if (!Array.isArray(textTracks))
211 throw new ValidationError('textTracks must be a list');
213 let default_count = 0;
214 for (let track of textTracks) {
215 if (typeof track.url !== 'string')
216 throw new ValidationError('text track URL must be a string');
217 validateURL(track.url);
219 if (track.contentType !== 'text/vtt')
220 throw new ValidationError(
221 `unacceptable text track contentType "${track.contentType}"`
224 if (typeof track.name !== 'string')
225 throw new ValidationError('text track name must be a string');
227 throw new ValidationError('text track name must be nonempty');
229 if (typeof track.default !== 'undefined') {
230 if (default_count > 0)
231 throw new ValidationError('only one default text track is allowed');
232 else if (typeof track.default !== 'boolean' || track.default !== true)
233 throw new ValidationError('text default attribute must be set to boolean true');
240 function parseURL(urlstring) {
241 const url = urlParse(urlstring);
243 // legacy url.parse doesn't check this
244 if (url.protocol == null || url.host == null) {
245 throw new Error(`Invalid URL "${urlstring}"`);
251 function validateURL(urlstring) {
254 url = parseURL(urlstring);
256 throw new ValidationError(`invalid URL "${urlstring}"`);
259 if (url.protocol !== 'https:')
260 throw new ValidationError(`URL protocol must be HTTPS (invalid: "${urlstring}")`);
262 if (net.isIP(url.hostname))
263 throw new ValidationError(
264 'URL hostname must be a domain name, not an IP address'
265 + ` (invalid: "${urlstring}")`