Migration towards TypeScript

This is going to be 1.1.0. Remaining TODOs:
* "npm run tsc" should generate declarations
* Add (restrictive) TSLint configuration
* Add support for TSLint in "npm test"
This commit is contained in:
Roel van Uden
2015-02-06 22:31:02 +01:00
parent d01b204cce
commit 9ff6398aee
38 changed files with 1014 additions and 909 deletions

View File

@@ -1,45 +1,41 @@
'use strict';
var Command = require('commander').Command;
var fs = require('fs');
var path = require('path');
var series = require('./series');
export = main;
import commander = require('commander');
import fs = require('fs');
import path = require('path');
import series = require('./series');
import typings = require('./typings');
/**
* Streams the batch of series to disk.
* @param {Array.<string>} args
* @param {function(Error)} done
*/
module.exports = function(args, done) {
var config = _parse(args);
function main(args: string[], done: (err?: Error) => void) {
var config = parse(args);
var batchPath = path.join(config.output || process.cwd(), 'CrunchyRoll.txt');
_tasks(config, batchPath, function(err, tasks) {
tasks(config, batchPath, (err, tasks) => {
if (err) return done(err);
var i = 0;
(function next() {
if (i >= tasks.length) return done();
series(tasks[i].config, tasks[i].address, function(err) {
series(tasks[i].config, tasks[i].address, err => {
if (err) return done(err);
i += 1;
next();
});
})();
});
};
}
/**
* Splits the value into arguments.
* @private
* @param {string} value
* @returns {Array.<string>}
*/
function _split(value) {
function split(value: string): string[] {
var inQuote = false;
var pieces = [];
var i: number;
var pieces: string[] = [];
var previous = 0;
for (var i = 0; i < value.length; i += 1) {
if (value.charAt(i) === '"') {
inQuote = !inQuote;
}
for (i = 0; i < value.length; i += 1) {
if (value.charAt(i) === '"') inQuote = !inQuote;
if (!inQuote && value.charAt(i) === ' ') {
pieces.push(value.substring(previous, i).match(/^"?(.+?)"?$/)[1]);
previous = i + 1;
@@ -51,43 +47,36 @@ function _split(value) {
/**
* Parses the configuration or reads the batch-mode file for tasks.
* @private
* @param {Object} config
* @param {string} batchPath
* @param {function(Error, Object=} done
*/
function _tasks(config, batchPath, done) {
function tasks(config: typings.IConfigLine, batchPath: string, done: (err: Error, tasks?: typings.IConfigTask[]) => void) {
if (config.args.length) {
return done(undefined, config.args.map(function(address) {
return done(null, config.args.map(address => {
return {address: address, config: config};
}));
}
fs.exists(batchPath, function(exists) {
if (!exists) return done(undefined, []);
fs.readFile(batchPath, 'utf8', function(err, data) {
fs.exists(batchPath, exists => {
if (!exists) return done(null, []);
fs.readFile(batchPath, 'utf8', (err, data) => {
if (err) return done(err);
var map = [];
data.split(/\r?\n/).forEach(function(line) {
var map: typings.IConfigTask[] = [];
data.split(/\r?\n/).forEach(line => {
if (/^(\/\/|#)/.test(line)) return;
var lineConfig = _parse(process.argv.concat(_split(line)));
lineConfig.args.forEach(function(address) {
var lineConfig = parse(process.argv.concat(split(line)));
lineConfig.args.forEach(address => {
if (!address) return;
map.push({address: address, config: lineConfig});
});
});
done(undefined, map);
done(null, map);
});
});
}
/**
* Parses the arguments and returns a configuration.
* @private
* @param {Array.<string>} args
* @returns {Object}
*/
function _parse(args) {
return new Command().version(require('../package').version)
function parse(args: string[]): typings.IConfigLine {
return new commander.Command().version(require('../package').version)
// Authentication
.option('-p, --pass <s>', 'The password.')
.option('-u, --user <s>', 'The e-mail address or username.')
@@ -103,4 +92,4 @@ function _parse(args) {
.option('-s, --series <s>', 'The series override.')
.option('-t, --tag <s>', 'The subgroup. (Default: CrunchyRoll)')
.parse(args);
}
}

6
src/cli.ts Normal file
View File

@@ -0,0 +1,6 @@
'use strict';
import batch = require('./batch');
batch(process.argv, err => {
if (err) console.error(err);
});

View File

@@ -1,204 +0,0 @@
'use strict';
var cheerio = require('cheerio');
var fs = require('fs');
var mkdirp = require('mkdirp');
var request = require('./request');
var path = require('path');
var subtitle = require('./subtitle');
var video = require('./video');
var xml2js = require('xml2js');
/**
* Streams the episode to disk.
* @param {Object} config
* @param {string} address
* @param {function(Error)} done
*/
module.exports = function (config, address, done) {
_page(config, address, function(err, page) {
if (err) return done(err);
_player(config, address, page.id, function(err, player) {
if (err) return done(err);
_download(config, page, player, done);
});
});
};
/**
* Completes a download and writes the message with an elapsed time.
* @private
* @param {string} message
* @param {number} begin
* @param {function(Error)} done
*/
function _complete(message, begin, done) {
var timeInMs = Date.now() - begin;
var seconds = _prefix(Math.floor(timeInMs / 1000) % 60, 2);
var minutes = _prefix(Math.floor(timeInMs / 1000 / 60) % 60, 2);
var hours = _prefix(Math.floor(timeInMs / 1000 / 60 / 60), 2);
console.log(message + ' (' + hours + ':' + minutes + ':' + seconds + ')');
done();
}
/**
* Downloads the subtitle and video.
* @private
* @param {Object} config
* @param {Object} page
* @param {Object} player
* @param {function(Error)} done
*/
function _download(config, page, player, done) {
var series = config.series || page.series;
var fileName = _name(config, page, series);
var filePath = path.join(config.output || process.cwd(), series, fileName);
mkdirp(path.dirname(filePath), function(err) {
if (err) return done(err);
_subtitle(config, player, filePath, function(err) {
if (err) return done(err);
var now = Date.now();
console.log('Fetching ' + fileName);
_video(config, page, player, filePath, function(err) {
if (err) return done(err);
if (config.merge) return _complete('Finished ' + fileName, now, done);
video.merge(config, player.video.file, filePath, function(err) {
if (err) return done(err);
_complete('Finished ' + fileName, now, done);
});
});
});
});
}
/**
* Names the file based on the config, page, series and tag.
* @private
* @param {Object} config
* @param {Object} page
* @param {string} series
* @returns {string}
*/
function _name(config, page, series) {
var episode = (page.episode < 10 ? '0' : '') + page.episode;
var volume = (page.volume < 10 ? '0' : '') + page.volume;
var tag = config.tag || 'CrunchyRoll';
return series + ' ' + volume + 'x' + episode + ' [' + tag + ']';
}
/**
* Requests the page data and scrapes the id, episode, series and swf.
* @private
* @param {Object} config
* @param {string} address
* @param {function(Error, Object=)} done
*/
function _page(config, address, done) {
var id = parseInt((address.match(/[0-9]+$/) || [0])[0], 10);
if (!id) return done(new Error('Invalid address.'));
request.get(config, address, function(err, res, body) {
if (err) return done(err);
var $ = cheerio.load(body);
var swf = /^([^?]+)/.exec($('link[rel=video_src]').attr('href'));
var regexp = /Watch\s+(.+?)(?:\s+Season\s+([0-9]+))?\s+Episode\s+([0-9]+)/;
var data = regexp.exec($('title').text());
if (!swf || !data) return done(new Error('Invalid page.'));
done(undefined, {
id: id,
episode: parseInt(data[3], 10),
series: data[1],
swf: swf[1],
volume: parseInt(data[2], 10) || 1
});
});
}
/**
* Prefixes a value.
* @private
* @param {(number|string)} value
* @param {number} length
* @returns {string}
*/
function _prefix(value, length) {
if (typeof value !== 'string') value = String(value);
while (value.length < length) value = '0' + value;
return value;
}
/**
* Requests the player data and scrapes the subtitle and video data.
* @private
* @param {Object} config
* @param {string} address
* @param {number} id
* @param {function(Error, Object=)} done
*/
function _player(config, address, id, done) {
var url = address.match(/^(https?:\/\/[^\/]+)/);
if (!url) return done(new Error('Invalid address.'));
request.post(config, {
form: {current_page: address},
url: url[1] + '/xml/?req=RpcApiVideoPlayer_GetStandardConfig&media_id=' + id
}, function(err, res, xml) {
if (err) return done(err);
xml2js.parseString(xml, {
explicitArray: false,
explicitRoot: false
}, function(err, player) {
if (err) return done(err);
try {
done(undefined, {
subtitle: {
id: player['default:preload'].subtitle.$.id,
iv: player['default:preload'].subtitle.iv,
data: player['default:preload'].subtitle.data
},
video: {
file: player['default:preload'].stream_info.file,
host: player['default:preload'].stream_info.host
}
});
} catch(err) {
done(err);
}
});
});
}
/**
* Saves the subtitles to disk.
* @private
* @param {Object} config
* @param {Object} player
* @param {string} filePath
* @param {function(Error)} done
*/
function _subtitle(config, player, filePath, done) {
var enc = player.subtitle;
subtitle.decode(enc.id, enc.iv, enc.data, function(err, data) {
if (err) return done(err);
var format = subtitle.formats[config.format] ? config.format : 'ass';
subtitle.formats[format](data, function(err, decodedSubtitle) {
if (err) return done(err);
fs.writeFile(filePath + '.' + format, '\ufeff' + decodedSubtitle, done);
});
});
}
/**
* Streams the video to disk.
* @private
* @param {Object} config
* @param {Object} page
* @param {Object} player
* @param {string} filePath
* @param {function(Error)} done
*/
function _video(config, page, player, filePath, done) {
video.stream(
player.video.host,
player.video.file,
page.swf,
filePath + path.extname(player.video.file),
done);
}

166
src/episode.ts Normal file
View File

@@ -0,0 +1,166 @@
'use strict';
export = main;
import cheerio = require('cheerio');
import fs = require('fs');
import mkdirp = require('mkdirp');
import request = require('./request');
import path = require('path');
import subtitle = require('./subtitle/index');
import typings = require('./typings');
import video = require('./video/index');
import xml2js = require('xml2js');
/**
* Streams the episode to disk.
*/
function main(config: typings.IConfig, address: string, done: (err: Error) => void) {
scrapePage(config, address, (err, page) => {
if (err) return done(err);
scrapePlayer(config, address, page.id, (err, player) => {
if (err) return done(err);
download(config, page, player, done);
});
});
}
/**
* Completes a download and writes the message with an elapsed time.
*/
function complete(message: string, begin: number, done: (err: Error) => void) {
var timeInMs = Date.now() - begin;
var seconds = prefix(Math.floor(timeInMs / 1000) % 60, 2);
var minutes = prefix(Math.floor(timeInMs / 1000 / 60) % 60, 2);
var hours = prefix(Math.floor(timeInMs / 1000 / 60 / 60), 2);
console.log(message + ' (' + hours + ':' + minutes + ':' + seconds + ')');
done(null);
}
/**
* Downloads the subtitle and video.
*/
function download(config: typings.IConfig, page: typings.IEpisodePage, player: typings.IEpisodePlayer, done: (err: Error) => void) {
var series = config.series || page.series;
var fileName = name(config, page, series);
var filePath = path.join(config.output || process.cwd(), series, fileName);
mkdirp(path.dirname(filePath), (err: Error) => {
if (err) return done(err);
downloadSubtitle(config, player, filePath, err => {
if (err) return done(err);
var now = Date.now();
console.log('Fetching ' + fileName);
downloadVideo(config, page, player, filePath, err => {
if (err) return done(err);
if (config.merge) return complete('Finished ' + fileName, now, done);
video.merge(config, player.video.file, filePath, err => {
if (err) return done(err);
complete('Finished ' + fileName, now, done);
});
});
});
});
}
/**
* Saves the subtitles to disk.
*/
function downloadSubtitle(config: typings.IConfig, player: typings.IEpisodePlayer, filePath: string, done: (err: Error) => void) {
var enc = player.subtitle;
subtitle.decode(enc.id, enc.iv, enc.data, (err, data) => {
if (err) return done(err);
var formats = subtitle.formats;
var format = formats[config.format] ? config.format : 'ass';
formats[format](data, (err: Error, decodedSubtitle: string) => {
if (err) return done(err);
fs.writeFile(filePath + '.' + format, '\ufeff' + decodedSubtitle, done);
});
});
}
/**
* Streams the video to disk.
*/
function downloadVideo(config: typings.IConfig, page: typings.IEpisodePage, player: typings.IEpisodePlayer, filePath: string, done: (err: Error) => void) {
video.stream(
player.video.host,
player.video.file,
page.swf,
filePath + path.extname(player.video.file),
done);
}
/**
* Names the file based on the config, page, series and tag.
*/
function name(config: typings.IConfig, page: typings.IEpisodePage, series: string) {
var episode = (page.episode < 10 ? '0' : '') + page.episode;
var volume = (page.volume < 10 ? '0' : '') + page.volume;
var tag = config.tag || 'CrunchyRoll';
return series + ' ' + volume + 'x' + episode + ' [' + tag + ']';
}
/**
* Prefixes a value.
*/
function prefix(value: number|string, length: number) {
var valueString = typeof value !== 'string' ? String(value) : value;
while (valueString.length < length) valueString = '0' + valueString;
return valueString;
}
/**
* Requests the page data and scrapes the id, episode, series and swf.
*/
function scrapePage(config: typings.IConfig, address: string, done: (err: Error, page?: typings.IEpisodePage) => void) {
var id = parseInt((address.match(/[0-9]+$/) || ['0'])[0], 10);
if (!id) return done(new Error('Invalid address.'));
request.get(config, address, (err, result) => {
if (err) return done(err);
var $ = cheerio.load(result);
var swf = /^([^?]+)/.exec($('link[rel=video_src]').attr('href'));
var regexp = /Watch\s+(.+?)(?:\s+Season\s+([0-9]+))?\s+Episode\s+([0-9]+)/;
var data = regexp.exec($('title').text());
if (!swf || !data) return done(new Error('Invalid page.'));
done(null, {
id: id,
episode: parseInt(data[3], 10),
series: data[1],
swf: swf[1],
volume: parseInt(data[2], 10) || 1
});
});
}
/**
* Requests the player data and scrapes the subtitle and video data.
*/
function scrapePlayer(config: typings.IConfig, address: string, id: number, done: (err: Error, player?: typings.IEpisodePlayer) => void) {
var url = address.match(/^(https?:\/\/[^\/]+)/);
if (!url) return done(new Error('Invalid address.'));
request.post(config, {
form: {current_page: address},
url: url[1] + '/xml/?req=RpcApiVideoPlayer_GetStandardConfig&media_id=' + id
}, (err, result) => {
if (err) return done(err);
xml2js.parseString(result, {
explicitArray: false,
explicitRoot: false
}, (err: Error, player: typings.IEpisodePlayerConfig) => {
if (err) return done(err);
try {
done(null, {
subtitle: {
id: parseInt(player['default:preload'].subtitle.$.id, 10),
iv: player['default:preload'].subtitle.iv,
data: player['default:preload'].subtitle.data
},
video: {
file: player['default:preload'].stream_info.file,
host: player['default:preload'].stream_info.host
}
});
} catch (parseError) {
done(parseError);
}
});
});
}

View File

@@ -1,5 +0,0 @@
module.exports = {
batch: require('./batch'),
episode: require('./episode'),
series: require('./series')
};

4
src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
'use strict';
export import batch = require('./batch');
export import episode = require('./episode');
export import series = require('./series');

View File

@@ -1,66 +0,0 @@
'use strict';
var isAuthenticated = false;
var request = require('request');
/**
* Performs a GET request for the resource.
* @param {Object} config
* @param {(string|Object)} options
* @param {function(Error, Object, string)} done
*/
module.exports.get = function(config, options, done) {
_authenticate(config, function(err) {
if (err) return done(err);
request.get(_modify(options), done);
});
};
/**
* Performs a POST request for the resource.
* @private
* @param {Object} config
* @param {(string|Object)} options
* @param {function(Error, Object, string)} done
*/
module.exports.post = function(config, options, done) {
_authenticate(config, function(err) {
if (err) return done(err);
request.post(_modify(options), done);
});
};
/**
* Authenticates using the configured pass and user.
* @private
* @param {Object} config
* @param {function(Error)} done
*/
function _authenticate(config, done) {
if (isAuthenticated || !config.pass || !config.user) return done();
request.post({
form: {
formname: 'RpcApiUser_Login',
fail_url: 'https://www.crunchyroll.com/login',
name: config.user,
password: config.pass
},
jar: true,
url: 'https://www.crunchyroll.com/?a=formhandler'
}, function(err) {
if (err) return done(err);
isAuthenticated = true;
done();
});
}
/**
* Modifies the options to use the authenticated cookie jar.
* @private
* @param {(string|Object)} options
* @returns {Object}
*/
function _modify(options) {
if (typeof options === 'string') options = {url: options};
options.jar = true;
return options;
}

63
src/request.ts Normal file
View File

@@ -0,0 +1,63 @@
'use strict';
import request = require('request');
import typings = require('./typings');
var isAuthenticated = false;
/**
* Performs a GET request for the resource.
*/
export function get(config: typings.IConfig, options: request.Options, done: (err: Error, result?: string) => void) {
authenticate(config, err => {
if (err) return done(err);
request.get(modify(options), (err: Error, response: any, body: any) => {
if (err) return done(err);
done(null, typeof body === 'string' ? body : String(body));
});
});
}
/**
* Performs a POST request for the resource.
*/
export function post(config: typings.IConfig, options: request.Options, done: (err: Error, result?: string) => void) {
authenticate(config, err => {
if (err) return done(err);
request.post(modify(options), (err: Error, response: any, body: any) => {
if (err) return done(err);
done(null, typeof body === 'string' ? body : String(body));
});
});
}
/**
* Authenticates using the configured pass and user.
*/
function authenticate(config: typings.IConfig, done: (err: Error) => void) {
if (isAuthenticated || !config.pass || !config.user) return done(null);
var options = {
form: {
formname: 'RpcApiUser_Login',
fail_url: 'https://www.crunchyroll.com/login',
name: config.user,
password: config.pass
},
jar: true,
url: 'https://www.crunchyroll.com/?a=formhandler'
};
request.post(options, (err: Error) => {
if (err) return done(err);
isAuthenticated = true;
done(null);
});
}
/**
* Modifies the options to use the authenticated cookie jar.
*/
function modify(options: string|request.Options): request.Options {
if (typeof options !== 'string') {
options.jar = true;
return options;
}
return {jar: true, url: options.toString()};
}

View File

@@ -1,31 +1,30 @@
'use strict';
var cheerio = require('cheerio');
var episode = require('./episode');
export = main;
import cheerio = require('cheerio');
import episode = require('./episode');
import fs = require('fs');
import request = require('./request');
import path = require('path');
import typings = require('./typings');
import url = require('url');
var persistent = '.crpersistent';
var fs = require('fs');
var request = require('./request');
var path = require('path');
var url = require('url');
/**
* Streams the series to disk.
* @param {Object} config
* @param {string} address
* @param {function(Error)} done
*/
module.exports = function(config, address, done) {
function main(config: typings.IConfig, address: string, done: (err: Error) => void) {
var persistentPath = path.join(config.output || process.cwd(), persistent);
fs.readFile(persistentPath, 'utf8', function(err, contents) {
fs.readFile(persistentPath, 'utf8', (err, contents) => {
var cache = config.cache ? {} : JSON.parse(contents || '{}');
_page(config, address, function(err, page) {
page(config, address, (err, page) => {
if (err) return done(err);
var i = 0;
(function next() {
if (i >= page.episodes.length) return done();
_download(cache, config, address, page.episodes[i], function(err) {
if (i >= page.episodes.length) return done(null);
download(cache, config, address, page.episodes[i], err => {
if (err) return done(err);
var newCache = JSON.stringify(cache, null, ' ');
fs.writeFile(persistentPath, newCache, function(err) {
fs.writeFile(persistentPath, newCache, err => {
if (err) return done(err);
i += 1;
next();
@@ -34,42 +33,33 @@ module.exports = function(config, address, done) {
})();
});
});
};
}
/**
* Downloads the episode.
* @private
* @param {Object.<string, string>} cache
* @param {Object} config
* @param {string} baseAddress
* @param {Object} item
* @param {function(Error)} done
*/
function _download(cache, config, baseAddress, item, done) {
if (!_filter(config, item)) return done();
function download(cache: {[address: string]: number}, config: typings.IConfig, baseAddress: string, item: typings.ISeriesEpisode, done: (err: Error) => void) {
if (!filter(config, item)) return done(null);
var address = url.resolve(baseAddress, item.address);
if (cache[address]) return done();
episode(config, address, function(err) {
if (cache[address]) return done(null);
episode(config, address, err => {
if (err) return done(err);
cache[address] = Date.now();
done();
done(null);
});
}
/**
* Filters the item based on the configuration.
* @param {Object} config
* @param {Object} item
* @returns {boolean}
*/
function _filter(config, item) {
* Filters the item based on the configuration.
*/
function filter(config: typings.IConfig, item: typings.ISeriesEpisode) {
// Filter on chapter.
var episodeFilter = parseInt(config.episode, 10);
var episodeFilter = config.episode;
if (episodeFilter > 0 && item.episode <= episodeFilter) return false;
if (episodeFilter < 0 && item.episode >= -episodeFilter) return false;
// Filter on volume.
var volumeFilter = parseInt(config.volume, 10);
var volumeFilter = config.volume;
if (volumeFilter > 0 && item.volume <= volumeFilter) return false;
if (volumeFilter < 0 && item.volume >= -volumeFilter) return false;
return true;
@@ -77,19 +67,15 @@ function _filter(config, item) {
/**
* Requests the page and scrapes the episodes and series.
* @private
* @param {Object} config
* @param {string} address
* @param {function(Error, Object=)} done
*/
function _page(config, address, done) {
request.get(config, address, function(err, res, body) {
function page(config: typings.IConfig, address: string, done: (err: Error, result?: typings.ISeries) => void) {
request.get(config, address, (err, result) => {
if (err) return done(err);
var $ = cheerio.load(body);
var $ = cheerio.load(result);
var title = $('span[itemprop=name]').text();
if (!title) return done(new Error('Invalid page.'));
var episodes = [];
$('.episode').each(function(i, el) {
var episodes: typings.ISeriesEpisode[] = [];
$('.episode').each((i, el) => {
if ($(el).children('img[src*=coming_soon]').length) return;
var volume = /([0-9]+)\s*$/.exec($(el).closest('ul').prev('a').text());
var regexp = /Episode\s+([0-9]+)\s*$/i;
@@ -102,6 +88,6 @@ function _page(config, address, done) {
volume: volume ? parseInt(volume[0], 10) : 1
});
});
done(undefined, {episodes: episodes.reverse(), series: title});
done(null, {episodes: episodes.reverse(), series: title});
});
}
}

View File

@@ -1,98 +0,0 @@
'use strict';
var crypto = require('crypto');
var bigInt = require('big-integer');
var zlib = require('zlib');
/**
* Decodes the data.
* @param {number} id
* @param {(Buffer|string)} iv
* @param {(Buffer|string)} data
* @param {function(Error, Buffer=)} done
*/
module.exports = function(id, iv, data, done) {
try {
_decompress(_decrypt(id, iv, data), done);
} catch(e) {
done(e);
}
};
/**
* Decrypts the data.
* @private
* @param {number} id
* @param {(Buffer|string)} iv
* @param {(Buffer|string)} data
* @return {Buffer}
*/
function _decrypt(id, iv, data) {
if (typeof iv === 'string') iv = new Buffer(iv, 'base64');
if (typeof data === 'string') data = new Buffer(data, 'base64');
var decipher = crypto.createDecipheriv('aes-256-cbc', _key(id), iv);
decipher.setAutoPadding(false);
return Buffer.concat([decipher.update(data), decipher.final()]);
}
/**
* Decompresses the data.
* @private
* @param {Buffer} data
* @param {function(Error, Buffer=)} done
*/
function _decompress(data, done) {
try {
zlib.inflate(data, done);
} catch(e) {
done(undefined, data);
}
}
/**
* Generates a key.
* @private
* @param {number} subtitleId
* @return {Buffer}
*/
function _key(subtitleId) {
var hash = _secret(20, 97, 1, 2) + _magic(subtitleId);
var result = new Buffer(32);
result.fill(0);
crypto.createHash('sha1').update(hash).digest().copy(result);
return result;
}
/**
* Generates a magic number.
* @private
* @param {number} subtitleId
* @return {number}
*/
function _magic(subtitleId) {
var base = Math.floor(Math.sqrt(6.9) * Math.pow(2, 25));
var hash = bigInt(base).xor(subtitleId);
var multipliedHash = bigInt(hash).multiply(32);
return bigInt(hash).xor(hash >> 3).xor(multipliedHash).toJSNumber();
}
/**
* Generates a secret string based on a Fibonacci sequence.
* @private
* @param {number} size
* @param {number} modulo
* @param {number} firstSeed
* @param {number} secondSeed
* @return {string}
*/
function _secret(size, modulo, firstSeed, secondSeed) {
var currentValue = firstSeed + secondSeed;
var previousValue = secondSeed;
var result = '';
for (var i = 0; i < size; i += 1) {
var oldValue = currentValue;
result += String.fromCharCode(currentValue % modulo + 33);
currentValue += previousValue;
previousValue = oldValue;
}
return result;
}

75
src/subtitle/decode.ts Normal file
View File

@@ -0,0 +1,75 @@
'use strict';
export = main;
import crypto = require('crypto');
import bigInt = require('big-integer');
import zlib = require('zlib');
/**
* Decodes the data.
*/
function main(id: number, iv: Buffer|string, data: Buffer|string, done: (err?: Error, result?: Buffer) => void) {
try {
decompress(decrypt(id, iv, data), done);
} catch (e) {
done(e);
}
}
/**
* Decrypts the data.
*/
function decrypt(id: number, iv: Buffer|string, data: Buffer|string) {
var ivBuffer = typeof iv === 'string' ? new Buffer(iv, 'base64') : iv;
var dataBuffer = typeof data === 'string' ? new Buffer(data, 'base64') : data;
var decipher = crypto.createDecipheriv('aes-256-cbc', key(id), ivBuffer);
decipher.setAutoPadding(false);
return Buffer.concat([decipher.update(dataBuffer), decipher.final()]);
}
/**
* Decompresses the data.
*/
function decompress(data: Buffer, done: (err: Error, result?: Buffer) => void) {
try {
zlib.inflate(data, done);
} catch (e) {
done(null, data);
}
}
/**
* Generates a key.
*/
function key(subtitleId: number): Buffer {
var hash = secret(20, 97, 1, 2) + magic(subtitleId);
var result = new Buffer(32);
result.fill(0);
crypto.createHash('sha1').update(hash).digest().copy(result);
return result;
}
/**
* Generates a magic number.
*/
function magic(subtitleId: number): number {
var base = Math.floor(Math.sqrt(6.9) * Math.pow(2, 25));
var hash = bigInt(base).xor(subtitleId).toJSNumber();
var multipliedHash = bigInt(hash).multiply(32).toJSNumber();
return bigInt(hash).xor(hash >> 3).xor(multipliedHash).toJSNumber();
}
/**
* Generates a secret string based on a Fibonacci sequence.
*/
function secret(size: number, modulo: number, firstSeed: number, secondSeed: number): string {
var currentValue = firstSeed + secondSeed;
var previousValue = secondSeed;
var result = '';
for (var i = 0; i < size; i += 1) {
var oldValue = currentValue;
result += String.fromCharCode(currentValue % modulo + 33);
currentValue += previousValue;
previousValue = oldValue;
}
return result;
}

View File

@@ -1,92 +0,0 @@
'use strict';
var xml2js = require('xml2js');
/**
* Converts an input buffer to a SubStation Alpha subtitle.
* @param {Buffer|string} input
* @param {function(Error, string=)} done
*/
module.exports = function(input, done) {
if (typeof buffer !== 'string') input = input.toString();
xml2js.parseString(input, {
explicitArray: false,
explicitRoot: false
}, function(err, xml) {
if (err) return done(err);
try {
done(undefined, _script(xml) + '\n' +
_style(xml.styles) + '\n' +
_event(xml.events));
} catch(err) {
done(err);
}
});
};
/**
* Converts the event block.
* @param {Object} events
* @returns {string}
*/
function _event(events) {
var format = 'Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text';
var items = [].concat(events.event).map(function(style) {
return _values(style.$, 'Dialogue: 0,{start},{end},{style},{name},' +
'{margin_l},{margin_r},{margin_v},{effect},{text}');
});
return '[Events]\n' +
'Format: ' + format + '\n' +
items.join('\n') + '\n';
}
/**
* Converts the script block.
* @param {Object} script
* @returns {string}
*/
function _script(script) {
return _values(script.$,
'[Script Info]\n' +
'Title: {title}\n' +
'ScriptType: v4.00+\n' +
'WrapStyle: {wrap_style}\n' +
'PlayResX: {play_res_x}}\n' +
'PlayResY: {play_res_y}\n' +
'Subtitle ID: {id}\n' +
'Language: {lang_string}\n' +
'Created: {created}\n');
}
/**
* Converts the style block.
* @param {Object} styles
* @returns {string}
*/
function _style(styles) {
var format = 'Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,' +
'OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,' +
'ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,' +
'MarginL,MarginR,MarginV,Encoding';
var items = [].concat(styles.style).map(function(style) {
return _values(style.$, 'Style: {name},{font_name},{font_size}, ' +
'{primary_colour},{secondary_colour},{outline_colour}, ' +
'{back_colour},{bold},{italic},{underline},{strikeout},{scale_x}, ' +
'{scale_y},{spacing},{angle},{border_style},{outline},{shadow},' +
'{alignment},{margin_l},{margin_r},{margin_v},{encoding}');
});
return '[V4+ Styles]\n' +
'Format: ' + format + '\n' +
items.join('\n') + '\n';
}
/**
* Fills a predetermined format with the values from the attributes.
* @param {Object.<string, *>} attributes
* @param {string} value
* @returns {string}
*/
function _values(attributes, format) {
return format.replace(/{([^}]+)}/g, function(match, key) {
return attributes[key] || '';
});
}

View File

@@ -0,0 +1,93 @@
'use strict';
export = main;
import xml2js = require('xml2js');
import typings = require('../../typings');
/**
* Converts an input buffer to a SubStation Alpha subtitle.
*/
function main(input: string|Buffer, done: (err: Error, subtitle?: string) => void) {
xml2js.parseString(input.toString(), {
explicitArray: false,
explicitRoot: false
}, (err: Error, xml: typings.ISubtitle) => {
if (err) return done(err);
try {
done(null, script(xml) + '\n' +
style(xml.styles) + '\n' +
event(xml.events));
} catch (err) {
done(err);
}
});
}
/**
* Converts the event block.
*/
function event(block: typings.ISubtitleEvent): string {
var format = 'Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text';
return '[Events]\n' +
'Format: ' + format + '\n' +
block.event.map(style => ('Dialogue: 0,' +
style.$.start + ',' +
style.$.end + ',' +
style.$.style + ',' +
style.$.name + ',' +
style.$.margin_l + ',' +
style.$.margin_r + ',' +
style.$.margin_v + ',' +
style.$.effect + ',' +
style.$.text)).join('\n');
}
/**
* Converts the script block.
*/
function script(block: typings.ISubtitle): string {
return '[Script Info]\n' +
'Title: ' + block.$.title + '\n' +
'ScriptType: v4.00+\n' +
'WrapStyle: ' + block.$.wrap_style + '\n' +
'PlayResX: ' + block.$.play_res_x + '\n' +
'PlayResY: ' + block.$.play_res_y + '\n' +
'Subtitle ID: ' + block.$.id + '\n' +
'Language: ' + block.$.lang_string + '\n' +
'Created: ' + block.$.created;
}
/**
* Converts the style block.
*/
function style(block: typings.ISubtitleStyle): string {
var format = 'Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,' +
'OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,' +
'ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,' +
'MarginL,MarginR,MarginV,Encoding';
return '[V4+ Styles]\n' +
'Format: ' + format + '\n' +
block.style.map(style => 'Style: ' +
style.$.name + ',' +
style.$.font_name + ',' +
style.$.font_size + ',' +
style.$.primary_colour + ',' +
style.$.secondary_colour + ',' +
style.$.outline_colour + ',' +
style.$.back_colour + ',' +
style.$.bold + ',' +
style.$.italic + ',' +
style.$.underline + ',' +
style.$.strikeout + ',' +
style.$.scale_x + ',' +
style.$.scale_y + ',' +
style.$.spacing + ',' +
style.$.angle + ',' +
style.$.border_style + ',' +
style.$.outline + ',' +
style.$.shadow + ',' +
style.$.alignment + ',' +
style.$.margin_l + ',' +
style.$.margin_r + ',' +
style.$.margin_v + ',' +
style.$.encoding).join('\n');
}

View File

@@ -1,4 +0,0 @@
module.exports = {
ass: require('./ass'),
srt: require('./srt')
};

View File

@@ -0,0 +1,10 @@
'use strict';
export = main;
import ass = require('./ass');
import srt = require('./srt');
import typings = require('../../typings');
var main: typings.IFormatterTable = {
ass: ass,
srt: srt
};

View File

@@ -1,92 +0,0 @@
'use strict';
var xml2js = require('xml2js');
/**
* Converts an input buffer to a SubRip subtitle.
* @param {Buffer|string} input
* @param {function(Error, string=)} done
*/
module.exports = function(input, done) {
if (typeof buffer !== 'string') input = input.toString();
xml2js.parseString(input, {
explicitArray: false,
explicitRoot: false
}, function(err, xml) {
try {
if (err) return done(err);
done(undefined, xml.events.event.map(_event).join('\n'));
} catch(err) {
done(err);
}
});
};
/**
* Converts an event.
* @private
* @param {Object} event
* @param {number} index
* @returns {string}
*/
function _event(event, index) {
var attributes = event.$;
return (index + 1) + '\n' +
_time(attributes.start) + ' --> ' + _time(attributes.end) + '\n' +
_text(attributes.text) + '\n';
}
/**
* Prefixes a value.
* @private
* @param {string} value
* @param {number} length
* @returns {string}
*/
function _prefix(value, length) {
while (value.length < length) value = '0' + value;
return value;
}
/**
* Suffixes a value.
* @private
* @param {string} value
* @param {number} length
* @returns {string}
*/
function _suffix(value, length) {
while (value.length < length) value = value + '0';
return value;
}
/**
* Formats a text value.
* @private
* @param {string} text
* @returns {string}
*/
function _text(text) {
return text
.replace(/{\\i1}/g, '<i>').replace(/{\\i0}/g, '</i>')
.replace(/{\\b1}/g, '<b>').replace(/{\\b0}/g, '</b>')
.replace(/{\\u1}/g, '<u>').replace(/{\\u0}/g, '</u>')
.replace(/{[^}]+}/g, '')
.replace(/(\s+)?\\n(\s+)?/ig, '\n')
.trim();
}
/**
* Formats a time stamp.
* @private
* @param {string} time
* @returns {string}
*/
function _time(time) {
var all = time.match(/^([0-9]+):([0-9]+):([0-9]+)\.([0-9]+)$/);
if (!all) throw new Error('Invalid time.');
var hours = _prefix(all[1], 2);
var minutes = _prefix(all[2], 2);
var seconds = _prefix(all[3], 2);
var milliseconds = _suffix(all[4], 3);
return hours + ':' + minutes + ':' + seconds + ',' + milliseconds;
}

View File

@@ -0,0 +1,66 @@
'use strict';
export = srt;
import xml2js = require('xml2js');
import typings = require('../../typings');
/**
* Converts an input buffer to a SubRip subtitle.
*/
function srt(input: Buffer|string, done: (err: Error, subtitle?: string) => void) {
var options = {explicitArray: false, explicitRoot: false};
xml2js.parseString(input.toString(), options, (err: Error, xml: typings.ISubtitle) => {
try {
if (err) return done(err);
done(null, xml.events.event.map((event, index) => {
var attributes = event.$;
return (index + 1) + '\n' +
time(attributes.start) + ' --> ' + time(attributes.end) + '\n' +
text(attributes.text) + '\n';
}).join('\n'));
} catch (err) {
done(err);
}
});
}
/**
* Prefixes a value.
*/
function prefix(value: string, length: number): string {
while (value.length < length) value = '0' + value;
return value;
}
/**
* Suffixes a value.
*/
function suffix(value: string, length: number): string {
while (value.length < length) value = value + '0';
return value;
}
/**
* Formats a text value.
*/
function text(value: string): string {
return value
.replace(/{\\i1}/g, '<i>').replace(/{\\i0}/g, '</i>')
.replace(/{\\b1}/g, '<b>').replace(/{\\b0}/g, '</b>')
.replace(/{\\u1}/g, '<u>').replace(/{\\u0}/g, '</u>')
.replace(/{[^}]+}/g, '')
.replace(/(\s+)?\\n(\s+)?/ig, '\n')
.trim();
}
/**
* Formats a time stamp.
*/
function time(value: string): string {
var all = value.match(/^([0-9]+):([0-9]+):([0-9]+)\.([0-9]+)$/);
if (!all) throw new Error('Invalid time.');
var hours = prefix(all[1], 2);
var minutes = prefix(all[2], 2);
var seconds = prefix(all[3], 2);
var milliseconds = suffix(all[4], 3);
return hours + ':' + minutes + ':' + seconds + ',' + milliseconds;
}

View File

@@ -1,4 +0,0 @@
module.exports = {
decode: require('./decode'),
formats: require('./formats')
};

3
src/subtitle/index.ts Normal file
View File

@@ -0,0 +1,3 @@
'use strict';
export import decode = require('./decode');
export import formats = require('./formats/index');

136
src/typings.ts Normal file
View File

@@ -0,0 +1,136 @@
export interface IConfig {
// Authentication
pass?: string;
user?: string;
// Disables
cache?: boolean;
merge?: boolean;
// Filters
episode?: number;
volume?: number;
// Settings
format?: string;
output?: string;
series?: string;
tag?: string;
}
export interface IConfigLine extends IConfig {
args: string[];
}
export interface IConfigTask {
address: string;
config: IConfigLine;
}
export interface IEpisodePage {
id: number;
episode: number;
series: string;
volume: number;
swf: string;
}
export interface IEpisodePlayer {
subtitle: {
id: number;
iv: string;
data: string;
};
video: {
file: string;
host: string;
};
}
export interface IEpisodePlayerConfig {
'default:preload': {
subtitle: {
$: {
id: string;
};
iv: string;
data: string;
};
stream_info: {
file: string;
host: string;
};
};
}
export interface IFormatterTable {
[key: string]: (input: string|Buffer, done: (err: Error, subtitle?: string) => void) => void;
}
export interface ISeries {
episodes: ISeriesEpisode[];
series: string;
}
export interface ISeriesEpisode {
address: string;
episode: number;
volume: number;
}
export interface ISubtitle {
$: {
title: string;
wrap_style: string;
play_res_x: string;
play_res_y: string;
id: string;
lang_string: string;
created: string;
};
events: ISubtitleEvent;
styles: ISubtitleStyle;
}
export interface ISubtitleEvent {
event: {
$: {
end: string;
start: string;
style: string;
name: string;
margin_l: string;
margin_r: string;
margin_v: string;
effect: string;
text: string;
};
}[];
}
export interface ISubtitleStyle {
style: {
$: {
name: string;
font_name: string;
font_size: string;
primary_colour: string;
secondary_colour: string;
outline_colour: string;
back_colour: string;
bold: string;
italic: string;
underline: string;
strikeout: string;
scale_x: string;
scale_y: string;
spacing: string;
angle: string;
border_style: string;
outline: string;
shadow: string;
alignment: string;
margin_l: string;
margin_r: string;
margin_v: string;
encoding: string;
};
}[];
}

View File

@@ -1,4 +0,0 @@
module.exports = {
merge: require('./merge'),
stream: require('./stream')
};

3
src/video/index.ts Normal file
View File

@@ -0,0 +1,3 @@
'use strict';
export import merge = require('./merge');
export import stream = require('./stream');

View File

@@ -1,71 +0,0 @@
'use strict';
var childProcess = require('child_process');
var fs = require('fs');
var path = require('path');
var os = require('os');
var subtitle = require('../subtitle');
/**
* Merges the subtitle and video files into a Matroska Multimedia Container.
* @param {Object} config
* @param {string} rtmpInputPath
* @param {string} filePath
* @param {function(Error)} done
*/
module.exports = function(config, rtmpInputPath, filePath, done) {
var format = subtitle.formats[config.format] ? config.format : 'ass';
var subtitlePath = filePath + '.' + format;
var videoPath = filePath + path.extname(rtmpInputPath);
childProcess.exec(_command() + ' ' +
'-o "' + filePath + '.mkv" ' +
'"' + videoPath + '" ' +
'"' + subtitlePath + '"', {
maxBuffer: Infinity
}, function(err) {
if (err) return done(err);
_unlink(videoPath, subtitlePath, function(err) {
if (err) _unlinkTimeout(videoPath, subtitlePath, 5000);
done();
});
});
};
/**
* Determines the command for the operating system.
* @private
* @returns {string}
*/
function _command() {
if (os.platform() !== 'win32') return 'mkvmerge';
return path.join(__dirname, '../../bin/mkvmerge.exe');
}
/**
* Unlinks the video and subtitle.
* @private
* @param {string} videoPath
* @param {string} subtitlePath
* @param {function(Error)} done
*/
function _unlink(videoPath, subtitlePath, done) {
fs.unlink(videoPath, function(err) {
if (err) return done(err);
fs.unlink(subtitlePath, done);
});
}
/**
* Attempts to unlink the video and subtitle with a timeout between each try.
* @private
* @param {string} videoPath
* @param {string} subtitlePath
* @param {function(Error)} done
*/
function _unlinkTimeout(videoPath, subtitlePath, timeout) {
console.log('Trying to unlink...' + Date.now());
setTimeout(function() {
_unlink(videoPath, subtitlePath, function(err) {
if (err) _unlinkTimeout(videoPath, subtitlePath, timeout);
});
}, timeout);
}

58
src/video/merge.ts Normal file
View File

@@ -0,0 +1,58 @@
'use strict';
export = main;
import childProcess = require('child_process');
import fs = require('fs');
import path = require('path');
import os = require('os');
import subtitle = require('../subtitle/index');
import typings = require('../typings');
/**
* Merges the subtitle and video files into a Matroska Multimedia Container.
*/
function main(config: typings.IConfig, rtmpInputPath: string, filePath: string, done: (err: Error) => void) {
var subtitlePath = filePath + '.' + (subtitle.formats[config.format] ? config.format : 'ass');
var videoPath = filePath + path.extname(rtmpInputPath);
childProcess.exec(command() + ' ' +
'-o "' + filePath + '.mkv" ' +
'"' + videoPath + '" ' +
'"' + subtitlePath + '"', {
maxBuffer: Infinity
}, err => {
if (err) return done(err);
unlink(videoPath, subtitlePath, err => {
if (err) unlinkTimeout(videoPath, subtitlePath, 5000);
done(null);
});
});
}
/**
* Determines the command for the operating system.
*/
function command(): string {
if (os.platform() !== 'win32') return 'mkvmerge';
return path.join(__dirname, '../../bin/mkvmerge.exe');
}
/**
* Unlinks the video and subtitle.
* @private
*/
function unlink(videoPath: string, subtitlePath: string, done: (err: Error) => void) {
fs.unlink(videoPath, err => {
if (err) return done(err);
fs.unlink(subtitlePath, done);
});
}
/**
* Attempts to unlink the video and subtitle with a timeout between each try.
*/
function unlinkTimeout(videoPath: string, subtitlePath: string, timeout: number) {
setTimeout(() => {
unlink(videoPath, subtitlePath, err => {
if (err) unlinkTimeout(videoPath, subtitlePath, timeout);
});
}, timeout);
}

View File

@@ -1,32 +0,0 @@
'use strict';
var childProcess = require('child_process');
var path = require('path');
var os = require('os');
/**
* Streams the video to disk.
* @param {string} rtmpUrl
* @param {string} rtmpInputPath
* @param {string} swfUrl
* @param {string} filePath
* @param {function(Error)} done
*/
module.exports = function(rtmpUrl, rtmpInputPath, swfUrl, filePath, done) {
childProcess.exec(_command() + ' ' +
'-r "' + rtmpUrl + '" ' +
'-y "' + rtmpInputPath + '" ' +
'-W "' + swfUrl + '" ' +
'-o "' + filePath + '"', {
maxBuffer: Infinity
}, done);
};
/**
* Determines the command for the operating system.
* @private
* @returns {string}
*/
function _command() {
if (os.platform() !== 'win32') return 'rtmpdump';
return path.join(__dirname, '../../bin/rtmpdump.exe');
}

26
src/video/stream.ts Normal file
View File

@@ -0,0 +1,26 @@
'use strict';
export = main;
import childProcess = require('child_process');
import path = require('path');
import os = require('os');
/**
* Streams the video to disk.
*/
function main(rtmpUrl: string, rtmpInputPath: string, swfUrl: string, filePath: string, done: (err: Error) => void) {
childProcess.exec(command() + ' ' +
'-r "' + rtmpUrl + '" ' +
'-y "' + rtmpInputPath + '" ' +
'-W "' + swfUrl + '" ' +
'-o "' + filePath + '"', {
maxBuffer: Infinity
}, done);
}
/**
* Determines the command for the operating system.
*/
function command(): string {
if (os.platform() !== 'win32') return 'rtmpdump';
return path.join(__dirname, '../../bin/rtmpdump.exe');
}