Merge branch '3.0' of https://github.com/calzoneman/sync into 3.0
[KisSync.git] / src / custom-media.js
bloba43c920dfdaf712feb41a8af2aba45aec697a5f1
1 import { ValidationError } from './errors';
2 import { parse as urlParse } from 'url';
3 import net from 'net';
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([
11     240,
12     360,
13     480,
14     540,
15     720,
16     1080,
17     1440,
18     2160
19 ]);
21 const SOURCE_CONTENT_TYPES = new Set([
22     'application/dash+xml',
23     'application/x-mpegURL',
24     'audio/aac',
25     'audio/ogg',
26     'audio/mpeg',
27     'audio/opus',
28     'video/mp4',
29     'video/ogg',
30     'video/webm'
31 ]);
33 const LIVE_ONLY_CONTENT_TYPES = new Set([
34     'application/dash+xml'
35 ]);
37 export function lookup(url, opts) {
38     if (!opts) opts = {};
39     if (!opts.hasOwnProperty('timeout')) opts.timeout = 10000;
41     return new Promise((resolve, reject) => {
42         const options = {
43             headers: {
44                 'Accept': 'application/json'
45             }
46         };
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'
54             ));
56             return;
57         }
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';
68             reject(error);
69         });
71         req.on('error', error => {
72             LOGGER.warn('Request for %s failed: %s', url, error);
73             reject(error);
74         });
76         req.on('response', res => {
77             if (res.statusCode !== 200) {
78                 req.abort();
80                 reject(new Error(
81                     `Expected HTTP 200 OK, not ${res.statusCode} ${res.statusMessage}`
82                 ));
84                 return;
85             }
87             if (!/^application\/json/.test(res.headers['content-type'])) {
88                 req.abort();
90                 reject(new Error(
91                     `Expected content-type application/json, not ${res.headers['content-type']}`
92                 ));
94                 return;
95             }
97             let buffer = '';
98             res.setEncoding('utf8');
100             res.on('data', data => {
101                 buffer += data;
103                 if (buffer.length > 100 * 1024) {
104                     req.abort();
105                     reject(new Error('Response size exceeds 100KB'));
106                 }
107             });
109             res.on('end', () => {
110                 resolve(buffer);
111             });
112         });
113     }).then(body => {
114         return convert(url, JSON.parse(body));
115     });
118 export function convert(id, data) {
119     validate(data);
121     if (data.live) data.duration = 0;
123     const sources = {};
125     for (let source of data.sources) {
126         if (!sources.hasOwnProperty(source.quality))
127             sources[source.quality] = [];
129         sources[source.quality].push({
130             link: source.url,
131             contentType: source.contentType,
132             quality: source.quality
133         });
134     }
136     const meta = {
137         direct: sources,
138         textTracks: data.textTracks,
139         thumbnail: data.thumbnail, // Currently ignored by Media
140         live: !!data.live          // Currently ignored by Media
141     };
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');
149     if (!data.title)
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);
164     }
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}"`
184             );
186         if (LIVE_ONLY_CONTENT_TYPES.has(source.contentType) && !data.live)
187             throw new ValidationError(
188                 `contentType "${source.contentType}" requires live: true`
189             );
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'
200                 );
201         }
202     }
205 function validateTextTracks(textTracks) {
206     if (typeof textTracks === 'undefined') {
207         return;
208     }
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}"`
222             );
224         if (typeof track.name !== 'string')
225             throw new ValidationError('text track name must be a string');
226         if (!track.name)
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');
234             else
235                 default_count++;
236         }
237     }
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}"`);
246     }
248     return url;
251 function validateURL(urlstring) {
252     let url;
253     try {
254         url = parseURL(urlstring);
255     } catch (error) {
256         throw new ValidationError(`invalid URL "${urlstring}"`);
257     }
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}")`
266         );