From 9ff6398aeedac00c07ac77a07d2f472ccb9f9cd1 Mon Sep 17 00:00:00 2001 From: Roel van Uden Date: Fri, 6 Feb 2015 22:31:02 +0100 Subject: [PATCH] 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" --- .gitignore | 7 ++ .jscsrc | 60 ---------- .jshintrc | 79 ------------- .npmignore | 12 +- README.md | 1 + bin/crunchyroll | 2 +- crunchyroll.js.njsproj | 102 +++++++++++++++++ crunchyroll.js.sln | 22 ++++ crunchyroll.js.sln.DotSettings | 57 +++++++++ index.js | 6 - package.json | 14 ++- src/{batch.js => batch.ts} | 69 +++++------ src/cli.ts | 6 + src/episode.js | 204 --------------------------------- src/episode.ts | 166 +++++++++++++++++++++++++++ src/index.js | 5 - src/index.ts | 4 + src/request.js | 66 ----------- src/request.ts | 63 ++++++++++ src/{series.js => series.ts} | 78 ++++++------- src/subtitle/decode.js | 98 ---------------- src/subtitle/decode.ts | 75 ++++++++++++ src/subtitle/formats/ass.js | 92 --------------- src/subtitle/formats/ass.ts | 93 +++++++++++++++ src/subtitle/formats/index.js | 4 - src/subtitle/formats/index.ts | 10 ++ src/subtitle/formats/srt.js | 92 --------------- src/subtitle/formats/srt.ts | 66 +++++++++++ src/subtitle/index.js | 4 - src/subtitle/index.ts | 3 + src/typings.ts | 136 ++++++++++++++++++++++ src/video/index.js | 4 - src/video/index.ts | 3 + src/video/merge.js | 71 ------------ src/video/merge.ts | 58 ++++++++++ src/video/stream.js | 32 ------ src/video/stream.ts | 26 +++++ tsd.json | 33 ++++++ 38 files changed, 1014 insertions(+), 909 deletions(-) delete mode 100644 .jscsrc delete mode 100644 .jshintrc create mode 100644 crunchyroll.js.njsproj create mode 100644 crunchyroll.js.sln create mode 100644 crunchyroll.js.sln.DotSettings delete mode 100644 index.js rename src/{batch.js => batch.ts} (57%) create mode 100644 src/cli.ts delete mode 100644 src/episode.js create mode 100644 src/episode.ts delete mode 100644 src/index.js create mode 100644 src/index.ts delete mode 100644 src/request.js create mode 100644 src/request.ts rename src/{series.js => series.ts} (52%) delete mode 100644 src/subtitle/decode.js create mode 100644 src/subtitle/decode.ts delete mode 100644 src/subtitle/formats/ass.js create mode 100644 src/subtitle/formats/ass.ts delete mode 100644 src/subtitle/formats/index.js create mode 100644 src/subtitle/formats/index.ts delete mode 100644 src/subtitle/formats/srt.js create mode 100644 src/subtitle/formats/srt.ts delete mode 100644 src/subtitle/index.js create mode 100644 src/subtitle/index.ts create mode 100644 src/typings.ts delete mode 100644 src/video/index.js create mode 100644 src/video/index.ts delete mode 100644 src/video/merge.js create mode 100644 src/video/merge.ts delete mode 100644 src/video/stream.js create mode 100644 src/video/stream.ts create mode 100644 tsd.json diff --git a/.gitignore b/.gitignore index c2658d7..5adc8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ +dist/ node_modules/ +obj/ +typings/ +*.dat +*.dll +*.suo +*.tmp \ No newline at end of file diff --git a/.jscsrc b/.jscsrc deleted file mode 100644 index c29b41b..0000000 --- a/.jscsrc +++ /dev/null @@ -1,60 +0,0 @@ -{ - "disallowEmptyBlocks": true, - "disallowImplicitTypeConversion": ["binary", "boolean", "numeric", "string"], - "disallowKeywords": ["delete", "with"], - "disallowKeywordsOnNewLine": ["catch", "else", "finally"], - "disallowMixedSpacesAndTabs": true, - "disallowMultipleLineBreaks": true, - "disallowMultipleLineStrings": true, - "disallowMultipleVarDecl": true, - "disallowNewlineBeforeBlockStatements": true, - "disallowPaddingNewlinesInBlocks": true, - "disallowQuotedKeysInObjects": "allButReserved", - "disallowSpaceAfterObjectKeys": true, - "disallowSpaceAfterPrefixUnaryOperators": true, - "disallowSpaceBeforePostfixUnaryOperators": true, - "disallowSpacesInCallExpression": true, - "disallowSpacesInFunction": {"beforeOpeningRoundBrace": true}, - "disallowSpacesInsideArrayBrackets": true, - "disallowSpacesInsideObjectBrackets": true, - "disallowSpacesInsideParentheses": true, - "disallowTrailingComma": true, - "disallowTrailingWhitespace": true, - "disallowYodaConditions": true, - "jsDoc": { - "checkAnnotations": "closurecompiler", - "checkParamNames": true, - "checkRedundantAccess": true, - "checkRedundantParams": true, - "checkReturnTypes": true, - "checkTypes": "strictNativeCase", - "enforceExistence": true, - "leadingUnderscoreAccess": true, - "requireParamTypes": true, - "requireReturnTypes": true - }, - "maximumLineLength": 80, - "plugins": ["jscs-jsdoc"], - "requireBlocksOnNewline": true, - "requireCamelCaseOrUpperCaseIdentifiers": true, - "requireCapitalizedConstructors": true, - "requireCommaBeforeLineBreak": true, - "requireDotNotation": true, - "requireFunctionDeclarations": true, - "requireLineFeedAtFileEnd": true, - "requireOperatorBeforeLineBreak": true, - "requireParenthesesAroundIIFE": true, - "requireSpaceAfterBinaryOperators": true, - "requireSpaceAfterKeywords": ["case", "catch", "do", "else", "for", "if", "return", "switch", "try", "typeof", "void", "while", "with"], - "requireSpaceAfterLineComment": true, - "requireSpaceBeforeBinaryOperators": true, - "requireSpaceBeforeBlockStatements": true, - "requireSpaceBeforeObjectValues": true, - "requireSpacesInConditionalExpression": true, - "requireSpacesInFunction": {"beforeOpeningCurlyBrace": true}, - "safeContextKeyword": ["that"], - "validateIndentation": 2, - "validateParameterSeparator": ", ", - "validateQuoteMarks": "'", - "validateLineBreaks": "LF" -} diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 0d4a772..0000000 --- a/.jshintrc +++ /dev/null @@ -1,79 +0,0 @@ -{ - "bitwise" : false, - "camelcase" : false, - "curly" : false, - "eqeqeq" : true, - "es3" : false, - "forin" : true, - "freeze" : true, - "immed" : true, - "indent" : 4, - "latedef" : "nofunc", - "newcap" : true, - "noarg" : true, - "noempty" : true, - "nonbsp" : true, - "nonew" : true, - "plusplus" : true, - "quotmark" : "single", - "undef" : true, - "unused" : true, - "singleGroups" : false, - "strict" : true, - "maxparams" : 5, - "maxdepth" : 5, - "maxstatements": 25, - "maxcomplexity": 5, - "maxlen" : 80, - - "asi" : false, - "boss" : false, - "debug" : false, - "eqnull" : false, - "evil" : false, - "expr" : false, - "esnext" : false, - "funcscope" : false, - "globalstrict" : false, - "iterator" : false, - "lastsemic" : false, - "laxbreak" : false, - "laxcomma" : false, - "loopfunc" : false, - "maxerr" : 50, - "moz" : false, - "multistr" : false, - "notypeof" : false, - "noyield" : false, - "proto" : false, - "scripturl" : false, - "scope" : false, - "shadow" : false, - "sub" : false, - "supernew" : false, - "validthis" : false, - "withstmt" : false, - - "browser" : false, - "browserify" : false, - "couch" : false, - "devel" : false, - "dojo" : false, - "jasmine" : false, - "jquery" : false, - "mootools" : false, - "mocha" : false, - "node" : true, - "nonstandard" : false, - "phantom" : false, - "prototypejs" : false, - "qunit" : false, - "rhino" : false, - "shelljs" : false, - "typed" : false, - "worker" : false, - "wsh" : false, - "yui" : false, - - "globals" : [] -} diff --git a/.npmignore b/.npmignore index d12a3f1..0d7a2b0 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,11 @@ -extras/ +node_modules/ +obj/ +src/ +typings/ +*.dat +*.dll +*.map +*.njsproj +*.sln +*.suo +*.tmp \ No newline at end of file diff --git a/README.md b/README.md index c57816e..178ff40 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ decision prior to using this application. * Binary runner for `npm` * Windows examples with a .bat for ease of use. * Publish to `npm` with a fixed package.json. +* Conversion to beautiful TypeScript 1.4 code. ### Pending Implementation diff --git a/bin/crunchyroll b/bin/crunchyroll index 52c02f5..160eb1b 100644 --- a/bin/crunchyroll +++ b/bin/crunchyroll @@ -1,4 +1,4 @@ #!/usr/bin/env node var path = require('path'); var fs = require('fs'); -require(path.join(path.dirname(fs.realpathSync(__filename)), '..')); +require(path.join(path.dirname(fs.realpathSync(__filename)), '../dist/cli')); \ No newline at end of file diff --git a/crunchyroll.js.njsproj b/crunchyroll.js.njsproj new file mode 100644 index 0000000..d91f19a --- /dev/null +++ b/crunchyroll.js.njsproj @@ -0,0 +1,102 @@ + + + + Debug + True + . + {c5cff68a-d733-4347-83e7-6e5fe58eb0e3} + + {3AF33F2E-1136-4D97-BBB7-1795711AC8B8};{349c5851-65df-11da-9384-00065b846f21};{9092AA53-FB77-4645-B42D-1CCCA6BD08BD} + ShowAllFiles + 2.0 + cli.ts + CommonJS + True + dist + 11.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + . + + + True + + + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + http://localhost:1337 + 0 + / + http://localhost:48022/ + False + False + True + False + + + + + False + True + False + + False + False + False + CurrentPage + + + + + + + + + \ No newline at end of file diff --git a/crunchyroll.js.sln b/crunchyroll.js.sln new file mode 100644 index 0000000..475427a --- /dev/null +++ b/crunchyroll.js.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2013 +VisualStudioVersion = 12.0.31101.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9092AA53-FB77-4645-B42D-1CCCA6BD08BD}") = "crunchyroll.js", "crunchyroll.js.njsproj", "{C5CFF68A-D733-4347-83E7-6E5FE58EB0E3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C5CFF68A-D733-4347-83E7-6E5FE58EB0E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5CFF68A-D733-4347-83E7-6E5FE58EB0E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5CFF68A-D733-4347-83E7-6E5FE58EB0E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5CFF68A-D733-4347-83E7-6E5FE58EB0E3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/crunchyroll.js.sln.DotSettings b/crunchyroll.js.sln.DotSettings new file mode 100644 index 0000000..dd8e72f --- /dev/null +++ b/crunchyroll.js.sln.DotSettings @@ -0,0 +1,57 @@ + + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + + DO_NOT_SHOW + TypeScript14 + <?xml version="1.0" encoding="utf-16"?><Profile name="TypeScript"><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><JsInsertSemicolon>True</JsInsertSemicolon><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs></Profile> + TypeScript + ONLY_FOR_MULTILINE + 1 + 1 + SingleQuoted + + + False + False + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> + True + RelativeDotSlash + True + + False + True + C:\Dropbox\Github\crunchyroll.js\crunchyroll.js.sln.DotSettings + True + 1 \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index ffc4a88..0000000 --- a/index.js +++ /dev/null @@ -1,6 +0,0 @@ -'use strict'; -var src = require('./src'); - -src.batch(process.argv, function(err) { - if (err) return console.error(err.stack || err); -}); diff --git a/package.json b/package.json index b03bf9d..f62e08a 100644 --- a/package.json +++ b/package.json @@ -8,19 +8,25 @@ ], "name": "crunchyroll", "repository": "git://github.com/Deathspike/crunchyroll.js.git", - "version": "1.0.6", + "version": "1.1.0", "bin": { "crunchyroll": "./bin/crunchyroll" }, "dependencies": { - "big-integer": "^1.4.1", + "big-integer": "^1.4.3", "cheerio": "^0.18.0", "commander": "^2.6.0", "mkdirp": "^0.5.0", - "request": "^2.51.0", + "request": "^2.53.0", "xml2js": "^0.4.4" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "prepublish": "npm run tsd && npm run tsc", + "tsc": "./node_modules/.bin/tsc --declaration --module CommonJS --noImplicitAny --outDir dist typings/tsd.d.ts src/cli.ts", + "tsd": "./node_modules/.bin/tsd reinstall --overwrite --save" + }, + "devDependencies": { + "tsd": "^0.5.7", + "typescript": "^1.4.1" } } diff --git a/src/batch.js b/src/batch.ts similarity index 57% rename from src/batch.js rename to src/batch.ts index 0b1ec18..90c7c31 100644 --- a/src/batch.js +++ b/src/batch.ts @@ -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.} 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.} */ -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.} 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 ', 'The password.') .option('-u, --user ', 'The e-mail address or username.') @@ -103,4 +92,4 @@ function _parse(args) { .option('-s, --series ', 'The series override.') .option('-t, --tag ', 'The subgroup. (Default: CrunchyRoll)') .parse(args); -} +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..33ca998 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,6 @@ +'use strict'; +import batch = require('./batch'); + +batch(process.argv, err => { + if (err) console.error(err); +}); \ No newline at end of file diff --git a/src/episode.js b/src/episode.js deleted file mode 100644 index 14c147a..0000000 --- a/src/episode.js +++ /dev/null @@ -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); -} diff --git a/src/episode.ts b/src/episode.ts new file mode 100644 index 0000000..d4d8a30 --- /dev/null +++ b/src/episode.ts @@ -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); + } + }); + }); +} \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index b0d833d..0000000 --- a/src/index.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - batch: require('./batch'), - episode: require('./episode'), - series: require('./series') -}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eae512a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +'use strict'; +export import batch = require('./batch'); +export import episode = require('./episode'); +export import series = require('./series'); \ No newline at end of file diff --git a/src/request.js b/src/request.js deleted file mode 100644 index 1a241f6..0000000 --- a/src/request.js +++ /dev/null @@ -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; -} diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..2e15185 --- /dev/null +++ b/src/request.ts @@ -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()}; +} \ No newline at end of file diff --git a/src/series.js b/src/series.ts similarity index 52% rename from src/series.js rename to src/series.ts index bd1d1e2..0946763 100644 --- a/src/series.js +++ b/src/series.ts @@ -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.} 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}); }); -} +} \ No newline at end of file diff --git a/src/subtitle/decode.js b/src/subtitle/decode.js deleted file mode 100644 index e3728b7..0000000 --- a/src/subtitle/decode.js +++ /dev/null @@ -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; -} diff --git a/src/subtitle/decode.ts b/src/subtitle/decode.ts new file mode 100644 index 0000000..ec90651 --- /dev/null +++ b/src/subtitle/decode.ts @@ -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; +} \ No newline at end of file diff --git a/src/subtitle/formats/ass.js b/src/subtitle/formats/ass.js deleted file mode 100644 index 01628d8..0000000 --- a/src/subtitle/formats/ass.js +++ /dev/null @@ -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.} attributes - * @param {string} value - * @returns {string} - */ -function _values(attributes, format) { - return format.replace(/{([^}]+)}/g, function(match, key) { - return attributes[key] || ''; - }); -} diff --git a/src/subtitle/formats/ass.ts b/src/subtitle/formats/ass.ts new file mode 100644 index 0000000..8693032 --- /dev/null +++ b/src/subtitle/formats/ass.ts @@ -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'); +} \ No newline at end of file diff --git a/src/subtitle/formats/index.js b/src/subtitle/formats/index.js deleted file mode 100644 index 57a7798..0000000 --- a/src/subtitle/formats/index.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ass: require('./ass'), - srt: require('./srt') -}; diff --git a/src/subtitle/formats/index.ts b/src/subtitle/formats/index.ts new file mode 100644 index 0000000..507da2a --- /dev/null +++ b/src/subtitle/formats/index.ts @@ -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 +}; \ No newline at end of file diff --git a/src/subtitle/formats/srt.js b/src/subtitle/formats/srt.js deleted file mode 100644 index 6962e05..0000000 --- a/src/subtitle/formats/srt.js +++ /dev/null @@ -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, '').replace(/{\\i0}/g, '') - .replace(/{\\b1}/g, '').replace(/{\\b0}/g, '') - .replace(/{\\u1}/g, '').replace(/{\\u0}/g, '') - .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; -} diff --git a/src/subtitle/formats/srt.ts b/src/subtitle/formats/srt.ts new file mode 100644 index 0000000..ce7ffea --- /dev/null +++ b/src/subtitle/formats/srt.ts @@ -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, '').replace(/{\\i0}/g, '') + .replace(/{\\b1}/g, '').replace(/{\\b0}/g, '') + .replace(/{\\u1}/g, '').replace(/{\\u0}/g, '') + .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; +} \ No newline at end of file diff --git a/src/subtitle/index.js b/src/subtitle/index.js deleted file mode 100644 index 8b29f16..0000000 --- a/src/subtitle/index.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - decode: require('./decode'), - formats: require('./formats') -}; diff --git a/src/subtitle/index.ts b/src/subtitle/index.ts new file mode 100644 index 0000000..272b460 --- /dev/null +++ b/src/subtitle/index.ts @@ -0,0 +1,3 @@ +'use strict'; +export import decode = require('./decode'); +export import formats = require('./formats/index'); \ No newline at end of file diff --git a/src/typings.ts b/src/typings.ts new file mode 100644 index 0000000..cabbb47 --- /dev/null +++ b/src/typings.ts @@ -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; + }; + }[]; +} \ No newline at end of file diff --git a/src/video/index.js b/src/video/index.js deleted file mode 100644 index 23ab138..0000000 --- a/src/video/index.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - merge: require('./merge'), - stream: require('./stream') -}; diff --git a/src/video/index.ts b/src/video/index.ts new file mode 100644 index 0000000..f1d4e50 --- /dev/null +++ b/src/video/index.ts @@ -0,0 +1,3 @@ +'use strict'; +export import merge = require('./merge'); +export import stream = require('./stream'); \ No newline at end of file diff --git a/src/video/merge.js b/src/video/merge.js deleted file mode 100644 index 3b12911..0000000 --- a/src/video/merge.js +++ /dev/null @@ -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); -} diff --git a/src/video/merge.ts b/src/video/merge.ts new file mode 100644 index 0000000..2e68fbf --- /dev/null +++ b/src/video/merge.ts @@ -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); +} \ No newline at end of file diff --git a/src/video/stream.js b/src/video/stream.js deleted file mode 100644 index c872850..0000000 --- a/src/video/stream.js +++ /dev/null @@ -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'); -} diff --git a/src/video/stream.ts b/src/video/stream.ts new file mode 100644 index 0000000..c4130bd --- /dev/null +++ b/src/video/stream.ts @@ -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'); +} \ No newline at end of file diff --git a/tsd.json b/tsd.json new file mode 100644 index 0000000..ba0e305 --- /dev/null +++ b/tsd.json @@ -0,0 +1,33 @@ +{ + "version": "v4", + "repo": "borisyankov/DefinitelyTyped", + "ref": "master", + "path": "typings", + "bundle": "typings/tsd.d.ts", + "installed": { + "node/node.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "commander/commander.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "xml2js/xml2js.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "cheerio/cheerio.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "mkdirp/mkdirp.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "request/request.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "big-integer/big-integer.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + }, + "form-data/form-data.d.ts": { + "commit": "42c8a3b74c05f6887ce21dd63c6234e424f9f8fe" + } + } +}