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:
@@ -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
6
src/cli.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
'use strict';
|
||||
import batch = require('./batch');
|
||||
|
||||
batch(process.argv, err => {
|
||||
if (err) console.error(err);
|
||||
});
|
||||
204
src/episode.js
204
src/episode.js
@@ -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
166
src/episode.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
batch: require('./batch'),
|
||||
episode: require('./episode'),
|
||||
series: require('./series')
|
||||
};
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
'use strict';
|
||||
export import batch = require('./batch');
|
||||
export import episode = require('./episode');
|
||||
export import series = require('./series');
|
||||
@@ -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
63
src/request.ts
Normal 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()};
|
||||
}
|
||||
@@ -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});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
75
src/subtitle/decode.ts
Normal 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;
|
||||
}
|
||||
@@ -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] || '';
|
||||
});
|
||||
}
|
||||
93
src/subtitle/formats/ass.ts
Normal file
93
src/subtitle/formats/ass.ts
Normal 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');
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
ass: require('./ass'),
|
||||
srt: require('./srt')
|
||||
};
|
||||
10
src/subtitle/formats/index.ts
Normal file
10
src/subtitle/formats/index.ts
Normal 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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
66
src/subtitle/formats/srt.ts
Normal file
66
src/subtitle/formats/srt.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
decode: require('./decode'),
|
||||
formats: require('./formats')
|
||||
};
|
||||
3
src/subtitle/index.ts
Normal file
3
src/subtitle/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
export import decode = require('./decode');
|
||||
export import formats = require('./formats/index');
|
||||
136
src/typings.ts
Normal file
136
src/typings.ts
Normal 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;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
merge: require('./merge'),
|
||||
stream: require('./stream')
|
||||
};
|
||||
3
src/video/index.ts
Normal file
3
src/video/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
export import merge = require('./merge');
|
||||
export import stream = require('./stream');
|
||||
@@ -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
58
src/video/merge.ts
Normal 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);
|
||||
}
|
||||
@@ -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
26
src/video/stream.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user