Browse Source

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"
Roel van Uden 9 years ago
parent
commit
9ff6398aee

+ 7 - 0
.gitignore

@@ -1 +1,8 @@
+dist/
 node_modules/
+obj/
+typings/
+*.dat
+*.dll
+*.suo
+*.tmp

+ 0 - 60
.jscsrc

@@ -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"
-}

+ 0 - 79
.jshintrc

@@ -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"      : []
-}

+ 11 - 1
.npmignore

@@ -1 +1,11 @@
-extras/
+node_modules/
+obj/
+src/
+typings/
+*.dat
+*.dll
+*.map
+*.njsproj
+*.sln
+*.suo
+*.tmp

+ 1 - 0
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
 

+ 1 - 1
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'));

+ 102 - 0
crunchyroll.js.njsproj

@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <PropertyGroup>
+    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+	<EnableTypeScript>True</EnableTypeScript>
+    <OutputPath>.</OutputPath>
+	<ProjectGuid>{c5cff68a-d733-4347-83e7-6e5fe58eb0e3}</ProjectGuid>
+	<ProjectHome />
+	<ProjectTypeGuids>{3AF33F2E-1136-4D97-BBB7-1795711AC8B8};{349c5851-65df-11da-9384-00065b846f21};{9092AA53-FB77-4645-B42D-1CCCA6BD08BD}</ProjectTypeGuids>
+    <ProjectView>ShowAllFiles</ProjectView>
+	<SchemaVersion>2.0</SchemaVersion>
+    <StartupFile>cli.ts</StartupFile>
+	<TypeScriptModuleKind>CommonJS</TypeScriptModuleKind>
+	<TypeScriptNoImplicitAny>True</TypeScriptNoImplicitAny>
+	<TypeScriptOutDir>dist</TypeScriptOutDir>
+    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">11.0</VisualStudioVersion>
+    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
+	<WorkingDirectory>.</WorkingDirectory>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
+    <TypeScriptSourceMap>True</TypeScriptSourceMap>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)' == 'Release'">
+    <TypeScriptGeneratesDeclarations>True</TypeScriptGeneratesDeclarations>
+  </PropertyGroup>
+  <ItemGroup>
+    <TypeScriptCompile Include="src\cli.ts" />
+    <TypeScriptCompile Include="src\batch.ts" />
+    <TypeScriptCompile Include="src\episode.ts" />
+    <TypeScriptCompile Include="src\index.ts" />
+    <TypeScriptCompile Include="src\request.ts" />
+    <TypeScriptCompile Include="src\series.ts" />
+    <TypeScriptCompile Include="src\subtitle\decode.ts" />
+    <TypeScriptCompile Include="src\subtitle\formats\ass.ts" />
+    <TypeScriptCompile Include="src\subtitle\formats\index.ts" />
+    <TypeScriptCompile Include="src\subtitle\formats\srt.ts" />
+    <TypeScriptCompile Include="src\subtitle\index.ts" />
+    <TypeScriptCompile Include="src\typings.ts" />
+    <TypeScriptCompile Include="src\video\index.ts" />
+    <TypeScriptCompile Include="src\video\merge.ts" />
+    <TypeScriptCompile Include="src\video\stream.ts" />
+    <TypeScriptCompile Include="typings\big-integer\big-integer.d.ts" />
+    <TypeScriptCompile Include="typings\cheerio\cheerio.d.ts" />
+    <TypeScriptCompile Include="typings\commander\commander.d.ts" />
+    <TypeScriptCompile Include="typings\form-data\form-data.d.ts" />
+    <TypeScriptCompile Include="typings\mkdirp\mkdirp.d.ts" />
+    <TypeScriptCompile Include="typings\node\node.d.ts" />
+    <TypeScriptCompile Include="typings\request\request.d.ts" />
+    <TypeScriptCompile Include="typings\xml2js\xml2js.d.ts" />
+  </ItemGroup>
+  <ItemGroup>
+    <Folder Include="src\" />
+    <Folder Include="src\subtitle\" />
+    <Folder Include="src\subtitle\formats\" />
+    <Folder Include="src\video\" />
+    <Folder Include="typings" />
+    <Folder Include="typings\big-integer\" />
+    <Folder Include="typings\cheerio\" />
+    <Folder Include="typings\commander\" />
+    <Folder Include="typings\form-data\" />
+    <Folder Include="typings\mkdirp\" />
+    <Folder Include="typings\node" />
+    <Folder Include="typings\request\" />
+    <Folder Include="typings\xml2js\" />
+  </ItemGroup>
+  <Import Project="$(MSBuildToolsPath)\Microsoft.Common.targets" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
+  <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets" Condition="False" />
+  <Import Project="$(VSToolsPath)\Node.js Tools\Microsoft.NodejsTools.targets" />
+  <ProjectExtensions>
+    <VisualStudio>
+      <FlavorProperties GUID="{349c5851-65df-11da-9384-00065b846f21}">
+        <WebProjectProperties>
+          <AutoAssignPort>True</AutoAssignPort>
+		  <CustomServerUrl>http://localhost:1337</CustomServerUrl>
+		  <DevelopmentServerPort>0</DevelopmentServerPort>
+          <DevelopmentServerVPath>/</DevelopmentServerVPath>
+		  <IISUrl>http://localhost:48022/</IISUrl>
+		  <NTLMAuthentication>False</NTLMAuthentication>
+		  <SaveServerSettingsInUserFile>False</SaveServerSettingsInUserFile>
+		  <UseCustomServer>True</UseCustomServer>
+		  <UseIIS>False</UseIIS>
+        </WebProjectProperties>
+      </FlavorProperties>
+      <FlavorProperties GUID="{349c5851-65df-11da-9384-00065b846f21}" User="">
+        <WebProjectProperties>
+		  <AlwaysStartWebServerOnDebug>False</AlwaysStartWebServerOnDebug>
+          <AspNetDebugging>True</AspNetDebugging>
+		  <EnableENC>False</EnableENC>
+		  <ExternalProgram />
+		  <NativeDebugging>False</NativeDebugging>
+		  <SilverlightDebugging>False</SilverlightDebugging>
+		  <SQLDebugging>False</SQLDebugging>
+		  <StartAction>CurrentPage</StartAction>
+		  <StartCmdLineArguments />
+		  <StartExternalURL />
+		  <StartPageUrl />
+          <StartWorkingDirectory />
+        </WebProjectProperties>
+      </FlavorProperties>
+    </VisualStudio>
+  </ProjectExtensions>
+</Project>

+ 22 - 0
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

File diff suppressed because it is too large
+ 8 - 0
crunchyroll.js.sln.DotSettings


+ 0 - 6
index.js

@@ -1,6 +0,0 @@
-'use strict';
-var src = require('./src');
-
-src.batch(process.argv, function(err) {
-  if (err) return console.error(err.stack || err);
-});

+ 10 - 4
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"
   }
 }

+ 29 - 40
src/batch.js → 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.<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 - 0
src/cli.ts

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

+ 0 - 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 - 0
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);
+      }
+    });
+  });
+}

+ 0 - 5
src/index.js

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

+ 4 - 0
src/index.ts

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

+ 0 - 66
src/request.js

@@ -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 - 0
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()};
+}

+ 32 - 46
src/series.js → 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.<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});
   });
-}
+}

+ 0 - 98
src/subtitle/decode.js

@@ -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 - 0
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;
+}

+ 0 - 92
src/subtitle/formats/ass.js

@@ -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 - 0
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');
+}

+ 0 - 4
src/subtitle/formats/index.js

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

+ 10 - 0
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
+};

+ 0 - 92
src/subtitle/formats/srt.js

@@ -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 - 0
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, '<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;
+}

+ 0 - 4
src/subtitle/index.js

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

+ 3 - 0
src/subtitle/index.ts

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

+ 136 - 0
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;
+    };
+  }[];
+}

+ 0 - 4
src/video/index.js

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

+ 3 - 0
src/video/index.ts

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

+ 0 - 71
src/video/merge.js

@@ -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 - 0
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);
+}

+ 0 - 32
src/video/stream.js

@@ -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 - 0
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');
+}

+ 33 - 0
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"
+    }
+  }
+}

Some files were not shown because too many files changed in this diff