31 Commits

Author SHA1 Message Date
Godzil
48544020a1 1.1.20 2017-09-16 22:58:27 +01:00
Godzil
cc68d21107 correct permissions 2017-09-16 22:54:49 +01:00
Godzil
acd91e2679 Add (unless) node minimum version in packages.json 2017-09-16 22:54:27 +01:00
Godzil
53f0a9462a Better filename forbidden character handling
Logs are a bit better
2017-09-16 22:51:49 +01:00
Godzil
10d71944d9 Fix lint error 2017-08-21 16:08:58 +02:00
Manoël Trapier
b5bbde7cdd Change to make travis npm happy 2017-08-21 14:24:23 +01:00
Manoël Trapier
c406bc70ee Sanitise more characters from filenames 2017-05-17 16:17:26 +01:00
Godzil
1dea620295 1.1.19 2017-05-12 00:14:24 +01:00
Manoël Trapier
2019c104b6 Add comment on issue report 2017-05-10 16:29:58 +01:00
Manoël Trapier
9f1ead1368 Update README.md 2017-05-09 20:15:51 +01:00
Manoël Trapier
41f67798d6 Merge pull request #22 from ssttevee/master
Custom filenames
2017-05-09 19:55:45 +01:00
ssttevee
2c2ed2c136 added more custom filename variables 2017-03-29 14:10:06 -07:00
ssttevee
4dc90aeb00 added custom filenames 2017-03-29 13:59:51 -07:00
Godzil
361c6cf54c Abandon node v4 as some packages does not want to install. 2017-03-16 19:25:06 +01:00
Godzil
b691b953d4 remove a useless target and update travis 2017-03-16 18:34:54 +01:00
Godzil
3d067979e9 update node version for travis 2017-03-16 17:47:58 +01:00
Godzil
58247f53e4 Add Travis 2017-03-16 17:44:35 +01:00
Manoël Trapier
6189e31e6b Update README.md 2017-03-16 12:08:59 +00:00
Manoël Trapier
3df650a0a6 Markdown I hate you 2017-02-27 16:14:55 +00:00
Manoël Trapier
c785c0f7c3 Add comment about login 2017-02-27 16:14:16 +00:00
Godzil
a01f3cd09c 1.1.18: Fix issue #19 2017-02-16 23:05:16 +00:00
Godzil
ed4f398062 Fix #19, better to check if a file exist before trying to copy it 😂 2017-02-16 23:03:43 +00:00
Godzil
e9cf0c353b 1.1.17 2017-02-12 23:37:19 +00:00
Godzil
6bc39083b9 - Support more episode naming schemes
- Display when we are going to fetch from a single URL (@http://...)
- Display a warning when a series looks to have no episodes
- Make a backup of the .crpersistent before changing it
2017-02-12 23:10:51 +00:00
Godzil
67d06246d4 1.1.17-0 2017-02-11 19:24:59 +00:00
Godzil
2ab1daf2b3 Another lint pass on episode.ts
Correct a stupid bug where it try to download an episode two times (and led to a failure) if metadata can't be fetch as expected. Doh!
2017-02-11 19:23:48 +00:00
Godzil
065d3b4c66 1.1.16 2017-02-10 23:52:23 +00:00
Godzil
cfe73f5ca8 More lint cleaning, add a way to download a single episode by URL 2017-02-10 23:51:22 +00:00
Godzil
2fea379484 Fancy output also works under windows, so it's now enabled for all platform! 2017-02-10 23:51:04 +00:00
Godzil
bee3f33e20 Update npm packages, cleanup the code, cleanup all tslint complain 2017-02-10 17:43:52 +00:00
Manoël Trapier
5d9c25491d Typo on typings 2017-02-08 16:47:52 +00:00
20 changed files with 2585 additions and 472 deletions

11
.travis.yml Normal file
View File

@@ -0,0 +1,11 @@
language: node_js
sudo: false
node_js:
- 5
- 6
before_install:
- npm install --only=dev
script:
- npm run types
- npm run compile
- npm test

View File

@@ -1,5 +1,7 @@
# Crunchy: a fork of Deathspike/CrunchyRoll.js
[![Issue Stats](http://issuestats.com/github/Godzil/Crunchy/badge/issue)](http://issuestats.com/github/Godzil/Crunchy) [![Travis CI](https://travis-ci.org/Godzil/Crunchy.svg?branch=master)](https://travis-ci.org/Godzil/Crunchy)
*Crunchy* is capable of downloading *anime* episodes from the popular *CrunchyRoll* streaming service. An episode is stored in the original video format (often H.264 in a MP4 container) and the configured subtitle format (ASS or SRT).The two output files are then merged into a single MKV file.
## Motivation
@@ -19,7 +21,7 @@ It is recommended to enable authentication (`-p` and `-u`) so your account permi
## Prerequisites
* NodeJS >= 0.12.x (http://nodejs.org/)
* NodeJS >= 5.x (http://nodejs.org/)
* NPM >= 2.5.x (https://www.npmjs.org/)
## Installation
@@ -93,6 +95,8 @@ Download *Fairy Tail* to `C:\Anime`:
* `-p or --pass <s>` sets the password.
* `-u or --user <s>` sets the e-mail address or username.
_Please remember that login has to be done for each call of Crunchy, as none of the credentials are stored_
##### Disables
* `-c or --cache` disables the cache.
@@ -110,6 +114,36 @@ Download *Fairy Tail* to `C:\Anime`:
* `-s or --series <s>` sets the series override.
* `-t or --tag <s>` sets The subgroup. (Default: CrunchyRoll)
## When things goes wrong
First, make sure you have the latest version of Crunchy installed, if you run an older version, the issue you face may have been solved.
Second thing to check, you have to give your credentials (-u and -p parameters) each time you run Crunchy. It does not actually store the token it receive when login and need to relog each time it is called. This may change in the future.
Third, is it a recently released episode? If yes, sometimes CR have issues were the requested format is not available, and Crunchy is not able to get it. When in doubt, try to watch CR website, if it does not work there, Crunchy will not either. This is valid in all cases even on non recently released.
Fourth, sometimes, CR website does weird things, and there are some transient errors, wait a couple of minutes (or hours) and try again. It often solved the issue on my side (yes I know that's not really a way of fixing, but if the error is on CR side, Crunchy can't do anything)
If really nothing works or you find a problem with Crunchy, then you can go and fill an Issue, first read the already open and closed one to make sure you are not reporting an existing problem. If your problem has been already reported, what you can do is to either:
- Add a comment saying you also have the same issue
- Add a Thumbs Up reaction to the original entry in the issue, they will are used as a metric to know how many people are annoyed by that issue
If you find one which correspond and is close, don't hesitate to add a comment, the issue may have not be fully solved.
If there is no comparable opened or close issue, you can create a new one.
### What to put in a bug report
It is really important for me to know:
- on which Operating System you are running Crunchy,
- which anime you want to fetch if it is related to a specific one,
- The command line you use to run Crunchy
- What message Crunchy is giving you if any
**Please be careful to remove your real account login and password if they appear!**
Also don't hesitate to add labels you feel apropriate on your report.
_Note: You can also use a bug report for a feature requests._
## Developers
More information will be added at a later point. For now the recommendations are:
@@ -117,3 +151,8 @@ More information will be added at a later point. For now the recommendations are
* Atom with `atom-typescript` and `linter-tslint` (and dependencies).
Since this project uses TypeScript, compile with `node ts` or `npm install`.
#### A note about pull requests:
If you want to help working on this project, Pull request are welcome, but please explain the goal of your changes, and do a pull request per change: you want to add support for _X_, _Y_ and _Z_, make a pull request for X, one for Y and one for Z. I'm not saying a pull request per commit that would be idiotic.
The idea is if your pull request changes lots of thing at the same time, if just a single part can't be accepted, if will delay the acceptation of the whole pull request where some of the feature could be integrated quicker is they were requested alone.
Also if for example the change _Y_ depends on _X_, you can wait for _X_ to be accepted before requesting for _Y_, if they are independant you can send a pull request for each at the same time.

0
bin/crunchy Normal file → Executable file
View File

1655
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,27 +12,37 @@
"type": "git",
"url": "git://github.com/Godzil/crunchyroll.js.git"
},
"version": "1.1.15",
"engines": {
"node": ">=5.0"
},
"version": "1.1.20",
"bin": {
"crunchy": "./bin/crunchy"
},
"dependencies": {
"big-integer": "1.4.4",
"cheerio": "0.22.0",
"cloudscraper": "1.4.1",
"commander": "2.6.0",
"mkdirp": "0.5.0",
"request": "2.74.0",
"xml2js": "0.4.5"
"big-integer": "^1.4.4",
"cheerio": "^0.22.0",
"cloudscraper": "^1.4.1",
"commander": "^2.6.0",
"fs-extra": "^2.0.0",
"mkdirp": "^0.5.0",
"request": "^2.74.0",
"xml2js": "^0.4.5"
},
"devDependencies": {
"typing": "2.1.0",
"tslint": "2.3.0-beta",
"typescript": "1.5.0-beta"
"tsconfig-lint": "^0.12.0",
"tslint": "^4.4.2",
"typescript": "^2.2.0",
"typings": "^2.1.0"
},
"scripts": {
"prepublish": "npm run types && tsc",
"test": "node ts --only-test",
"types": "typings install"
"compile": "tsc",
"test": "tslint -c ./tslint.json --project ./tsconfig.json ./src/**/*.ts",
"types": "typings install",
"start": "node ./bin/crunchy"
},
"bugs": {
"url": "https://github.com/Godzil/Crunchy/issues"
}
}

View File

@@ -7,16 +7,33 @@ import series from './series';
/**
* Streams the batch of series to disk.
*/
export default function(args: string[], done: (err?: Error) => void) {
var config = parse(args);
var batchPath = path.join(config.output || process.cwd(), 'CrunchyRoll.txt');
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, err => {
if (err) return done(err);
export default function(args: string[], done: (err?: Error) => void)
{
const config = parse(args);
const batchPath = path.join(config.output || process.cwd(), 'CrunchyRoll.txt');
tasks(config, batchPath, (err, tasks) =>
{
if (err)
{
return done(err);
}
let i = 0;
(function next()
{
if (i >= tasks.length)
{
return done();
}
series(tasks[i].config, tasks[i].address, (errin) =>
{
if (errin)
{
return done(errin);
}
i += 1;
next();
});
@@ -27,43 +44,85 @@ export default function(args: string[], done: (err?: Error) => void) {
/**
* Splits the value into arguments.
*/
function split(value: string): string[] {
var inQuote = false;
var i: number;
var pieces: string[] = [];
var previous = 0;
for (i = 0; i < value.length; i += 1) {
if (value.charAt(i) === '"') inQuote = !inQuote;
if (!inQuote && value.charAt(i) === ' ') {
function split(value: string): string[]
{
let inQuote = false;
let i: number;
const pieces: string[] = [];
let previous = 0;
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;
}
}
var lastPiece = value.substring(previous, i).match(/^"?(.+?)"?$/);
if (lastPiece) pieces.push(lastPiece[1]);
const lastPiece = value.substring(previous, i).match(/^"?(.+?)"?$/);
if (lastPiece)
{
pieces.push(lastPiece[1]);
}
return pieces;
}
/**
* Parses the configuration or reads the batch-mode file for tasks.
*/
function tasks(config: IConfigLine, batchPath: string, done: (err: Error, tasks?: IConfigTask[]) => void) {
if (config.args.length) {
return done(null, config.args.map(address => {
return {address: address, config: config};
function tasks(config: IConfigLine, batchPath: string, done: (err: Error, tasks?: IConfigTask[]) => void)
{
if (config.args.length)
{
const configIn = config;
return done(null, config.args.map((addressIn) =>
{
return {address: addressIn, config: configIn};
}));
}
fs.exists(batchPath, exists => {
if (!exists) return done(null, []);
fs.readFile(batchPath, 'utf8', (err, data) => {
if (err) return done(err);
var map: IConfigTask[] = [];
data.split(/\r?\n/).forEach(line => {
if (/^(\/\/|#)/.test(line)) return;
var lineConfig = parse(process.argv.concat(split(line)));
lineConfig.args.forEach(address => {
if (!address) return;
map.push({address: address, config: lineConfig});
fs.exists(batchPath, (exists) =>
{
if (!exists)
{
return done(null, []);
}
fs.readFile(batchPath, 'utf8', (err, data) =>
{
if (err)
{
return done(err);
}
const map: IConfigTask[] = [];
data.split(/\r?\n/).forEach((line) =>
{
if (/^(\/\/|#)/.test(line))
{
return;
}
const lineConfig = parse(process.argv.concat(split(line)));
lineConfig.args.forEach((addressIn) =>
{
if (!addressIn)
{
return;
}
map.push({address: addressIn, config: lineConfig});
});
});
done(null, map);
@@ -74,7 +133,8 @@ function tasks(config: IConfigLine, batchPath: string, done: (err: Error, tasks?
/**
* Parses the arguments and returns a configuration.
*/
function parse(args: string[]): IConfigLine {
function parse(args: string[]): IConfigLine
{
return new commander.Command().version(require('../package').version)
// Authentication
.option('-p, --pass <s>', 'The password.')
@@ -89,6 +149,7 @@ function parse(args: string[]): IConfigLine {
.option('-f, --format <s>', 'The subtitle format. (Default: ass)')
.option('-o, --output <s>', 'The output path.')
.option('-s, --series <s>', 'The series override.')
.option('-n, --filename <s>', 'The name override.')
.option('-t, --tag <s>', 'The subgroup. (Default: CrunchyRoll)')
.parse(args);
}

View File

@@ -1,6 +1,10 @@
'use strict';
import batch from './batch';
batch(process.argv, (err: any) => {
if (err) console.error(err.stack || err);
batch(process.argv, (err: any) =>
{
if (err)
{
console.error(err.stack || err);
}
});

View File

@@ -2,7 +2,7 @@
import cheerio = require('cheerio');
import fs = require('fs');
import mkdirp = require('mkdirp');
import request = require('./request');
import my_request = require('./my_request');
import path = require('path');
import subtitle from './subtitle/index';
import video from './video/index';
@@ -12,11 +12,22 @@ import log = require('./log');
/**
* Streams the episode to disk.
*/
export default function(config: IConfig, address: string, done: (err: Error, ign: boolean) => void) {
scrapePage(config, address, (err, page) => {
if (err) return done(err, false);
scrapePlayer(config, address, page.id, (err, player) => {
if (err) return done(err, false);
export default function(config: IConfig, address: string, done: (err: Error, ign: boolean) => void)
{
scrapePage(config, address, (err, page) =>
{
if (err)
{
return done(err, false);
}
scrapePlayer(config, address, page.id, (errS, player) =>
{
if (errS)
{
return done(errS, false);
}
download(config, page, player, done);
});
});
@@ -25,63 +36,105 @@ export default function(config: IConfig, address: string, done: (err: Error, ign
/**
* Completes a download and writes the message with an elapsed time.
*/
function complete(epName: string, message: string, begin: number, done: (err: Error, ign: boolean) => 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);
function complete(epName: string, message: string, begin: number, done: (err: Error, ign: boolean) => void)
{
const timeInMs = Date.now() - begin;
const seconds = prefix(Math.floor(timeInMs / 1000) % 60, 2);
const minutes = prefix(Math.floor(timeInMs / 1000 / 60) % 60, 2);
const hours = prefix(Math.floor(timeInMs / 1000 / 60 / 60), 2);
log.dispEpisode(epName, message + ' (' + hours + ':' + minutes + ':' + seconds + ')', true);
done(null, false);
}
/**
* Check if a file exist..
*/
function fileExist(path: string) {
function fileExist(path: string)
{
try
{
fs.statSync(path);
return true;
}
catch (e) { }
return false;
fs.statSync(path);
return true;
} catch (e)
{
return false;
}
}
function sanitiseFileName(str: string)
{
return str.replace(/[\/':\?\*"<>\.]/g, '_');
}
/**
* Downloads the subtitle and video.
*/
function download(config: IConfig, page: IEpisodePage, player: IEpisodePlayer, done: (err: Error, ign: boolean) => void) {
var series = config.series || page.series;
series = series.replace("/","_").replace("'","_").replace(":","_");
var fileName = name(config, page, series, "").replace("/","_").replace("'","_").replace(":","_");
var filePath = path.join(config.output || process.cwd(), series, fileName);
if (fileExist(filePath + ".mkv"))
function download(config: IConfig, page: IEpisodePage, player: IEpisodePlayer, done: (err: Error, ign: boolean) => void)
{
let series = config.series || page.series;
series = sanitiseFileName(series);
let fileName = sanitiseFileName(name(config, page, series, ''));
let filePath = path.join(config.output || process.cwd(), series, fileName);
if (fileExist(filePath + '.mkv'))
{
var count = 0;
log.warn("File '"+fileName+"' already exist...");
let count = 0;
log.warn('File \'' + fileName + '\' already exist...');
do
{
count = count + 1;
fileName = name(config, page, series, "-" + count).replace("/","_").replace("'","_").replace(":","_");
fileName = sanitiseFileName(name(config, page, series, '-' + count));
filePath = path.join(config.output || process.cwd(), series, fileName);
} while(fileExist(filePath + ".mkv"))
log.warn("Renaming to '"+fileName+"'...");
} while (fileExist(filePath + '.mkv'));
log.warn('Renaming to \'' + fileName + '\'...');
}
mkdirp(path.dirname(filePath), (err: Error) => {
if (err) return done(err, false);
downloadSubtitle(config, player, filePath, err => {
if (err) return done(err, false);
var now = Date.now();
if (player.video.file != undefined)
{
log.dispEpisode(fileName, 'Fetching...', false);
downloadVideo(config, page, player, filePath, err => {
if (err) return done(err, false);
if (config.merge) return complete(fileName, 'Finished!', now, done);
var isSubtited = Boolean(player.subtitle);
video.merge(config, isSubtited, player.video.file, filePath, player.video.mode, err => {
if (err) return done(err, false);
mkdirp(path.dirname(filePath), (errM: Error) =>
{
if (errM)
{
return done(errM, false);
}
log.dispEpisode(fileName, 'Fetching...', false);
downloadSubtitle(config, player, filePath, (errDS) =>
{
if (errDS)
{
return done(errDS, false);
}
const now = Date.now();
if (player.video.file !== undefined)
{
log.dispEpisode(fileName, 'Fetching video...', false);
downloadVideo(config, page, player, filePath, (errDV) =>
{
if (errDV)
{
return done(errDV, false);
}
if (config.merge)
{
return complete(fileName, 'Finished!', now, done);
}
const isSubtited = Boolean(player.subtitle);
log.dispEpisode(fileName, 'Merging...', false);
video.merge(config, isSubtited, player.video.file, filePath, player.video.mode, (errVM) =>
{
if (errVM)
{
return done(errVM, false);
}
complete(fileName, 'Finished!', now, done);
});
});
@@ -98,15 +151,32 @@ function download(config: IConfig, page: IEpisodePage, player: IEpisodePlayer, d
/**
* Saves the subtitles to disk.
*/
function downloadSubtitle(config: IConfig, player: IEpisodePlayer, filePath: string, done: (err?: Error) => void) {
var enc = player.subtitle;
if (!enc) return done();
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);
function downloadSubtitle(config: IConfig, player: IEpisodePlayer, filePath: string, done: (err?: Error) => void)
{
const enc = player.subtitle;
if (!enc)
{
return done();
}
subtitle.decode(enc.id, enc.iv, enc.data, (errSD, data) =>
{
if (errSD)
{
return done(errSD);
}
const formats = subtitle.formats;
const format = formats[config.format] ? config.format : 'ass';
formats[format](data, (errF: Error, decodedSubtitle: string) =>
{
if (errF)
{
return done(errF);
}
fs.writeFile(filePath + '.' + format, '\ufeff' + decodedSubtitle, done);
});
});
@@ -115,114 +185,167 @@ function downloadSubtitle(config: IConfig, player: IEpisodePlayer, filePath: str
/**
* Streams the video to disk.
*/
function downloadVideo(config: IConfig,
page: IEpisodePage,
player: IEpisodePlayer,
filePath: string,
done: (err: Error) => void) {
video.stream(
player.video.host,
player.video.file,
page.swf,
filePath, path.extname(player.video.file),
player.video.mode,
done);
function downloadVideo(ignored/*config*/: IConfig, page: IEpisodePage, player: IEpisodePlayer,
filePath: string, done: (err: Error) => void)
{
video.stream(player.video.host, player.video.file, page.swf, filePath,
path.extname(player.video.file), player.video.mode, done);
}
/**
* Names the file based on the config, page, series and tag.
*/
function name(config: IConfig, page: IEpisodePage, series: string, extra: string) {
var episodeNum = parseInt(page.episode, 10);
var volumeNum = parseInt(page.volume, 10);
var episode = (episodeNum < 10 ? '0' : '') + page.episode;
var volume = (volumeNum < 10 ? '0' : '') + page.volume;
var tag = config.tag || 'CrunchyRoll';
return series + ' - s' + volume + 'e' + episode +' - [' + tag + ']' + extra;
function name(config: IConfig, page: IEpisodePage, series: string, extra: string)
{
const episodeNum = parseInt(page.episode, 10);
const volumeNum = parseInt(page.volume, 10);
const episode = (episodeNum < 10 ? '0' : '') + page.episode;
const volume = (volumeNum < 10 ? '0' : '') + page.volume;
const tag = config.tag || 'CrunchyRoll';
if (!config.filename) {
return page.series + ' - s' + volume + 'e' + episode + ' - [' + tag + ']' + extra;
}
return config.filename
.replace(/{EPISODE_ID}/g, page.id.toString())
.replace(/{EPISODE_NUMBER}/g, episode)
.replace(/{SEASON_NUMBER}/g, volume)
.replace(/{VOLUME_NUMBER}/g, volume)
.replace(/{SEASON_TITLE}/g, page.season)
.replace(/{VOLUME_TITLE}/g, page.season)
.replace(/{SERIES_TITLE}/g, series)
.replace(/{EPISODE_TITLE}/g, page.title)
.replace(/{TAG}/g, tag) + extra;
}
/**
* 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;
function prefix(value: number|string, length: number)
{
let 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: IConfig, address: string, done: (err: Error, page?: 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 = /\s*([^\n\r\t\f]+)\n?\s*[^0-9]*([0-9][0-9.]*)?,?\n?\s\s*[^0-9]*((PV )?[S0-9][P0-9.]*[a-fA-F]?)/;
var look = $('#showmedia_about_media').text();
var seasonTitle = $('span[itemprop="title"]').text();
var data = regexp.exec(look);
function scrapePage(config: IConfig, address: string, done: (err: Error, page?: IEpisodePage) => void)
{
const epId = parseInt((address.match(/[0-9]+$/) || ['0'])[0], 10);
if (!epId)
{
return done(new Error('Invalid address.'));
}
my_request.get(config, address, (err, result) =>
{
if (err)
{
return done(err);
}
const $ = cheerio.load(result);
const swf = /^([^?]+)/.exec($('link[rel=video_src]').attr('href'));
const regexp = /\s*([^\n\r\t\f]+)\n?\s*[^0-9]*([0-9][\-0-9.]*)?,?\n?\s\s*[^0-9]*((PV )?[S0-9][P0-9.]*[a-fA-F]?)/;
const look = $('#showmedia_about_media').text();
const seasonTitle = $('span[itemprop="title"]').text();
let episodeTitle = $('#showmedia_about_name').text().replace(/[“”]/g, '');
const data = regexp.exec(look);
if (!swf || !data)
{
log.warn('Something wrong in the page at '+address+' (data are: '+look+')');
log.warn('Setting Season to 0 and episode to \0\...');
log.warn('Somethig unexpected in the page at ' + address + ' (data are: ' + look + ')');
log.warn('Setting Season to 0 and episode to 0...');
done(null, {
id: id,
episode: "0",
series: seasonTitle,
swf: swf[1],
volume: "0"
episode: '0',
id: epId,
series: seasonTitle,
season: seasonTitle,
title: episodeTitle,
swf: swf[1],
volume: '0',
});
}
else
{
done(null, {
episode: data[3],
id: epId,
series: data[1],
season: seasonTitle,
title: episodeTitle,
swf: swf[1],
volume: data[2] || '1',
});
}
done(null, {
id: id,
episode: data[3],
series: data[1],
swf: swf[1],
volume: data[2] || "1"
});
});
}
/**
* Requests the player data and scrapes the subtitle and video data.
*/
function scrapePlayer(config: IConfig, address: string, id: number, done: (err: Error, player?: IEpisodePlayer) => void) {
var url = address.match(/^(https?:\/\/[^\/]+)/);
if (!url) return done(new Error('Invalid address.'));
request.post(config, {
function scrapePlayer(config: IConfig, address: string, id: number, done: (err: Error, player?: IEpisodePlayer) => void)
{
const url = address.match(/^(https?:\/\/[^\/]+)/);
if (!url)
{
return done(new Error('Invalid address.'));
}
my_request.post(config, {
form: {current_page: address},
url: url[1] + '/xml/?req=RpcApiVideoPlayer_GetStandardConfig&media_id=' + id
}, (err, result) => {
if (err) return done(err);
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: IEpisodePlayerConfig) => {
if (err) return done(err);
try {
var isSubtitled = Boolean(player['default:preload'].subtitle);
var streamMode="RTMP";
if (player['default:preload'].stream_info.host == "")
explicitRoot: false,
}, (errPS: Error, player: IEpisodePlayerConfig) =>
{
if (errPS)
{
return done(errPS);
}
try
{
const isSubtitled = Boolean(player['default:preload'].subtitle);
let streamMode = 'RTMP';
if (player['default:preload'].stream_info.host === '')
{
streamMode="HLS";
streamMode = 'HLS';
}
done(null, {
subtitle: isSubtitled ? {
data: player['default:preload'].subtitle.data,
id: parseInt(player['default:preload'].subtitle.$.id, 10),
iv: player['default:preload'].subtitle.iv,
data: player['default:preload'].subtitle.data
} : null,
video: {
mode: streamMode,
file: player['default:preload'].stream_info.file,
host: player['default:preload'].stream_info.host
}
host: player['default:preload'].stream_info.host,
mode: streamMode,
},
});
} catch (parseError) {
} catch (parseError)
{
done(parseError);
}
});

View File

@@ -12,5 +12,6 @@ interface IConfig {
format?: string;
output?: string;
series?: string;
filename?: string;
tag?: string;
}

View File

@@ -3,5 +3,7 @@ interface IEpisodePage {
episode: string;
series: string;
volume: string;
season: string;
title: string;
swf: string;
}

View File

@@ -3,69 +3,35 @@ import os = require('os');
export function error(str: string)
{
if (os.platform() === 'win32')
{
console.log(' * ERROR: ' + str);
}
else
{
/* Do more fancy output */
console.error(' \x1B[1;31m* ERROR\x1B[0m: ' + str);
}
/* Do fancy output */
console.error(' \x1B[1;31m* ERROR\x1B[0m: ' + str);
}
export function info(str: string)
{
if (os.platform() === 'win32')
{
console.log(' * INFO : ' + str);
}
else
{
/* Do more fancy output */
console.log(' \x1B[1;32m* INFO \x1B[0m: ' + str);
}
/* Do fancy output */
console.log(' \x1B[1;32m* INFO \x1B[0m: ' + str);
}
export function debug(str: string)
{
if (os.platform() === 'win32')
{
console.log(' * DEBUG: ' + str);
}
else
{
/* Do more fancy output */
console.log(' \x1B[1;35m* DEBUG\x1B[0m: ' + str);
}
/* Do fancy output */
console.log(' \x1B[1;35m* DEBUG\x1B[0m: ' + str);
}
export function warn(str: string)
{
if (os.platform() === 'win32')
{
console.log(' * WARN : ' + str);
}
else
{
/* Do more fancy output */
console.log(' \x1B[1;33m* WARN \x1B[0m: ' + str);
}
/* Do fancy output */
console.log(' \x1B[1;33m* WARN \x1B[0m: ' + str);
}
export function dispEpisode(name: string, status: string, addNL: boolean)
{
if (os.platform() === 'win32')
{
process.stdout.write(' > ' + name + ' : ' + status + '\x1B[0G');
}
else
{
/* Do more fancy output */
process.stdout.write(' \x1B[1;33m> \x1B[37m' + name + '\x1B[0m : \x1B[33m' + status + '\x1B[0m\x1B[0G');
}
/* Do fancy output */
process.stdout.write('\x1B[K \x1B[1;33m> \x1B[37m' + name + '\x1B[0m : \x1B[33m' + status + '\x1B[0m\x1B[0G');
if (addNL)
{
console.log('');
}
}
}

View File

@@ -2,11 +2,13 @@
import request = require('request');
import cheerio = require('cheerio');
import log = require('./log');
var cloudscraper = require('cloudscraper');
var isAuthenticated = false;
var isPremium = false;
const cloudscraper = require('cloudscraper');
var defaultHeaders: request.Headers = {
let isAuthenticated = false;
let isPremium = false;
const defaultHeaders: request.Headers =
{
'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0',
'Connection': 'keep-alive'
};
@@ -14,11 +16,22 @@ var defaultHeaders: request.Headers = {
/**
* Performs a GET request for the resource.
*/
export function get(config: IConfig, options: request.Options, done: (err: Error, result?: string) => void) {
authenticate(config, err => {
if (err) return done(err);
cloudscraper.request(modify(options, 'GET'), (err: Error, response: any, body: any) => {
if (err) return done(err);
export function get(config: IConfig, options: string|request.Options, done: (err: Error, result?: string) => void)
{
authenticate(config, err =>
{
if (err)
{
return done(err);
}
cloudscraper.request(modify(options, 'GET'), (err: Error, response: any, body: any) =>
{
if (err)
{
return done(err);
}
done(null, typeof body === 'string' ? body : String(body));
});
});
@@ -27,11 +40,22 @@ export function get(config: IConfig, options: request.Options, done: (err: Error
/**
* Performs a POST request for the resource.
*/
export function post(config: IConfig, options: request.Options, done: (err: Error, result?: string) => void) {
authenticate(config, err => {
if (err) return done(err);
cloudscraper.request(modify(options, 'POST'), (err: Error, response: any, body: any) => {
if (err) return done(err);
export function post(config: IConfig, options: request.Options, done: (err: Error, result?: string) => void)
{
authenticate(config, err =>
{
if (err)
{
return done(err);
}
cloudscraper.request(modify(options, 'POST'), (err: Error, response: any, body: any) =>
{
if (err)
{
return done(err);
}
done(null, typeof body === 'string' ? body : String(body));
});
});
@@ -40,11 +64,15 @@ export function post(config: IConfig, options: request.Options, done: (err: Erro
/**
* Authenticates using the configured pass and user.
*/
function authenticate(config: IConfig, done: (err: Error) => void) {
if (isAuthenticated || !config.pass || !config.user) return done(null);
function authenticate(config: IConfig, done: (err: Error) => void)
{
if (isAuthenticated || !config.pass || !config.user)
{
return done(null);
}
/* Bypass the login page and send a login request directly */
var options =
let options =
{
headers: defaultHeaders,
jar: true,
@@ -53,18 +81,20 @@ function authenticate(config: IConfig, done: (err: Error) => void) {
url: 'https://www.crunchyroll.com/login'
};
// request(options, (err: Error, rep: string, body: string) =>
cloudscraper.request(options, (err: Error, rep: string, body: string) =>
{
if (err) return done(err);
var $ = cheerio.load(body);
const $ = cheerio.load(body);
/* Get the token from the login page */
var token = $('input[name="login_form[_token]"]').attr('value');
if (token === '') return done(new Error('Can`t find token!'));
const token = $('input[name="login_form[_token]"]').attr('value');
if (token === '')
{
return done(new Error('Can`t find token!'));
}
var options =
let options =
{
headers: defaultHeaders,
form:
@@ -79,14 +109,18 @@ function authenticate(config: IConfig, done: (err: Error) => void) {
method: 'POST',
url: 'https://www.crunchyroll.com/login'
};
// request.post(options, (err: Error, rep: string, body: string) =>
cloudscraper.request(options, (err: Error, rep: string, body: string) =>
{
if (err) return done(err);
if (err)
{
return done(err);
}
/* The page return with a meta based redirection, as we wan't to check that everything is fine, reload
* the main page. A bit convoluted, but more sure.
*/
var options =
let options =
{
headers: defaultHeaders,
jar: true,
@@ -96,23 +130,44 @@ function authenticate(config: IConfig, done: (err: Error) => void) {
cloudscraper.request(options, (err: Error, rep: string, body: string) =>
{
if (err) return done(err);
var $ = cheerio.load(body);
/* Check if auth worked */
var regexps = /ga\(\'set\', \'dimension[5-8]\', \'([^']*)\'\);/g;
var dims = regexps.exec($('script').text());
for (var i = 1; i < 5; i++)
if (err)
{
if ((dims[i] !== undefined) && (dims[i] !== '') && (dims[i] !== 'not-registered')) { isAuthenticated = true; }
if ((dims[i] === 'premium') || (dims[i] === 'premiumplus')) { isPremium = true; }
return done(err);
}
let $ = cheerio.load(body);
/* Check if auth worked */
const regexps = /ga\('set', 'dimension[5-8]', '([^']*)'\);/g;
const dims = regexps.exec($('script').text());
for (let i = 1; i < 5; i++)
{
if ((dims[i] !== undefined) && (dims[i] !== '') && (dims[i] !== 'not-registered'))
{
isAuthenticated = true;
}
if ((dims[i] === 'premium') || (dims[i] === 'premiumplus'))
{
isPremium = true;
}
}
if (isAuthenticated === false)
{
var error = $('ul.message, li.error').text();
const error = $('ul.message, li.error').text();
return done(new Error('Authentication failed: ' + error));
}
if (isPremium === false) { log.warn('Do not use this app without a premium account.'); }
else { log.info('You have a premium account! Good!'); }
if (isPremium === false)
{
log.warn('Do not use this app without a premium account.');
}
else
{
log.info('You have a premium account! Good!');
}
done(null);
});
});
@@ -122,12 +177,14 @@ function authenticate(config: IConfig, done: (err: Error) => void) {
/**
* Modifies the options to use the authenticated cookie jar.
*/
function modify(options: string|request.Options, reqMethod: string): request.Options {
if (typeof options !== 'string') {
function modify(options: string|request.Options, reqMethod: string): request.Options
{
if (typeof options !== 'string')
{
options.jar = true;
options.headers = defaultHeaders;
options.method = reqMethod;
return options;
}
return { jar: true, headers: defaultHeaders, url: options.toString(), method: reqMethod};
return { jar: true, headers: defaultHeaders, url: options.toString(), method: reqMethod };
}

View File

@@ -2,31 +2,73 @@
import cheerio = require('cheerio');
import episode from './episode';
import fs = require('fs');
import request = require('./request');
const fse = require('fs-extra');
import my_request = require('./my_request');
import path = require('path');
import url = require('url');
import log = require('./log');
var persistent = '.crpersistent';
const persistent = '.crpersistent';
/**
* Check if a file exist..
*/
function fileExist(path: string)
{
try
{
fs.statSync(path);
return true;
} catch (e)
{
return false;
}
}
/**
* Streams the series to disk.
*/
export default function(config: IConfig, address: string, done: (err: Error) => void) {
var persistentPath = path.join(config.output || process.cwd(), persistent);
fs.readFile(persistentPath, 'utf8', (err, contents) => {
var cache = config.cache ? {} : JSON.parse(contents || '{}');
page(config, address, (err, page) => {
if (err) return done(err);
var i = 0;
(function next() {
export default function(config: IConfig, address: string, done: (err: Error) => void)
{
const persistentPath = path.join(config.output || process.cwd(), persistent);
/* Make a backup of the persistent file in case of */
if (fileExist(persistentPath))
{
fse.copySync(persistentPath, persistentPath + '.backup');
}
fs.readFile(persistentPath, 'utf8', (err: Error, contents: string) =>
{
const cache = config.cache ? {} : JSON.parse(contents || '{}');
page(config, address, (errP, page) =>
{
if (errP)
{
return done(errP);
}
let i = 0;
(function next()
{
if (i >= page.episodes.length) return done(null);
download(cache, config, address, page.episodes[i], (err, ignored) => {
if (err) return done(err);
if ((ignored == false) || (ignored == undefined))
download(cache, config, address, page.episodes[i], (errD, ignored) =>
{
if (errD)
{
var newCache = JSON.stringify(cache, null, ' ');
fs.writeFile(persistentPath, newCache, err => {
if (err) return done(err);
return done(errD);
}
if ((ignored === false) || (ignored === undefined))
{
const newCache = JSON.stringify(cache, null, ' ');
fs.writeFile(persistentPath, newCache, (errW: Error) =>
{
if (errW)
{
return done(errW);
}
i += 1;
next();
});
@@ -45,16 +87,29 @@ export default function(config: IConfig, address: string, done: (err: Error) =>
/**
* Downloads the episode.
*/
function download(cache: {[address: string]: number},
config: IConfig,
baseAddress: string,
item: ISeriesEpisode,
done: (err: Error, ign: boolean) => void) {
if (!filter(config, item)) return done(null, false);
var address = url.resolve(baseAddress, item.address);
if (cache[address]) return done(null, false);
episode(config, address, (err, ignored) => {
if (err) return done(err, false);
function download(cache: {[address: string]: number}, config: IConfig,
baseAddress: string, item: ISeriesEpisode,
done: (err: Error, ign: boolean) => void)
{
if (!filter(config, item))
{
return done(null, false);
}
const address = url.resolve(baseAddress, item.address);
if (cache[address])
{
return done(null, false);
}
episode(config, address, (err, ignored) =>
{
if (err)
{
return done(err, false);
}
cache[address] = Date.now();
done(null, ignored);
});
@@ -63,43 +118,86 @@ function download(cache: {[address: string]: number},
/**
* Filters the item based on the configuration.
*/
function filter(config: IConfig, item: ISeriesEpisode) {
function filter(config: IConfig, item: ISeriesEpisode)
{
// Filter on chapter.
var episodeFilter = config.episode;
if (episodeFilter > 0 && parseInt(item.episode, 10) <= episodeFilter) return false;
if (episodeFilter < 0 && parseInt(item.episode, 10) >= -episodeFilter) return false;
const episodeFilter = config.episode;
// Filter on volume.
var volumeFilter = config.volume;
if (volumeFilter > 0 && item.volume <= volumeFilter) return false;
if (volumeFilter < 0 && item.volume >= -volumeFilter) return false;
const volumeFilter = config.volume;
const currentEpisode = parseInt(item.episode, 10);
const currentVolume = item.volume;
if ( ( (episodeFilter > 0) && (currentEpisode <= episodeFilter) ) ||
( (episodeFilter < 0) && (currentEpisode >= -episodeFilter) ) ||
( (volumeFilter > 0) && (currentVolume <= volumeFilter ) ) ||
( (volumeFilter < 0) && (currentVolume >= -volumeFilter ) ) )
{
return false;
}
return true;
}
/**
* Requests the page and scrapes the episodes and series.
*/
function page(config: IConfig, address: string, done: (err: Error, result?: ISeries) => void) {
request.get(config, address, (err, result) => {
if (err) return done(err);
var $ = cheerio.load(result);
var title = $('span[itemprop=name]').text();
if (!title) return done(new Error('Invalid page.(' + address + ')'));
log.info("Checking availability for " + title);
var episodes: 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+((PV )?[S0-9][P0-9.]*[a-fA-F]?)\s*$/i;
var episode = regexp.exec($(el).children('.series-title').text());
var address = $(el).attr('href');
if (!address || !episode) return;
episodes.push({
address: address,
episode: episode[1],
volume: volume ? parseInt(volume[0], 10) : 1
});
function page(config: IConfig, address: string, done: (err: Error, result?: ISeries) => void)
{
if (address[0] === '@')
{
log.info('Trying to fetch from ' + address.substr(1));
const episodes: ISeriesEpisode[] = [];
episodes.push({
address: address.substr(1),
episode: '',
volume: 0,
});
done(null, {episodes: episodes.reverse(), series: title});
});
done(null, {episodes: episodes.reverse(), series: ""});
}
else
{
let episodeCount = 0;
my_request.get(config, address, (err, result) => {
if (err) {
return done(err);
}
const $ = cheerio.load(result);
const title = $('span[itemprop=name]').text();
if (!title) {
return done(new Error('Invalid page.(' + address + ')'));
}
log.info('Checking availability for ' + title);
const episodes: ISeriesEpisode[] = [];
$('.episode').each((i, el) => {
if ($(el).children('img[src*=coming_soon]').length) {
return;
}
const volume = /([0-9]+)\s*$/.exec($(el).closest('ul').prev('a').text());
const regexp = /Episode\s+((PV )?[S0-9][\-P0-9.]*[a-fA-F]?)\s*$/i;
const episode = regexp.exec($(el).children('.series-title').text());
const url = $(el).attr('href');
if ((!url) || (!episode)) {
return;
}
episodeCount += 1;
episodes.push({
address: url,
episode: episode[1],
volume: volume ? parseInt(volume[0], 10) : 1,
});
});
if (episodeCount === 0)
{
log.warn("No episodes found for " + title + ". Could it be a movie?");
}
done(null, {episodes: episodes.reverse(), series: title});
});
}
}

View File

@@ -7,10 +7,14 @@ import zlib = require('zlib');
/**
* Decodes the data.
*/
export default function(id: number, iv: Buffer|string, data: Buffer|string, done: (err?: Error, result?: Buffer) => void) {
try {
export default function(id: number, iv: Buffer|string, data: Buffer|string,
done: (err?: Error, result?: Buffer) => void)
{
try
{
decompress(decrypt(id, iv, data), done);
} catch (e) {
} catch (e)
{
done(e);
}
}
@@ -18,21 +22,27 @@ import zlib = require('zlib');
/**
* 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);
function decrypt(id: number, iv: Buffer|string, data: Buffer|string)
{
const ivBuffer = typeof iv === 'string' ? new Buffer(iv, 'base64') : iv;
const dataBuffer = typeof data === 'string' ? new Buffer(data, 'base64') : data;
const 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 {
function decompress(data: Buffer, done: (err: Error, result?: Buffer) => void)
{
try
{
zlib.inflate(data, done);
} catch (e) {
} catch (e)
{
done(null, data);
}
}
@@ -40,36 +50,45 @@ function decompress(data: Buffer, done: (err: Error, result?: Buffer) => void) {
/**
* Generates a key.
*/
function key(subtitleId: number): Buffer {
var hash = secret(20, 97, 1, 2) + magic(subtitleId);
var result = new Buffer(32);
function key(subtitleId: number): Buffer
{
const hash = secret(20, 97, 1, 2) + magic(subtitleId);
const 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();
function magic(subtitleId: number): number
{
const base = Math.floor(Math.sqrt(6.9) * Math.pow(2, 25));
const hash = bigInt(base).xor(subtitleId).toJSNumber();
const 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;
function secret(size: number, modulo: number, firstSeed: number, secondSeed: number): string
{
let currentValue = firstSeed + secondSeed;
let previousValue = secondSeed;
let result = '';
for (let i = 0; i < size; i += 1)
{
const oldValue = currentValue;
result += String.fromCharCode(currentValue % modulo + 33);
currentValue += previousValue;
previousValue = oldValue;
}
return result;
}

View File

@@ -4,17 +4,25 @@ import xml2js = require('xml2js');
/**
* Converts an input buffer to a SubStation Alpha subtitle.
*/
export default function(input: string|Buffer, done: (err: Error, subtitle?: string) => void) {
export default function(input: string|Buffer, done: (err: Error, subtitle?: string) => void)
{
xml2js.parseString(input.toString(), {
explicitArray: false,
explicitRoot: false
}, (err: Error, xml: ISubtitle) => {
if (err) return done(err);
try {
}, (err: Error, xml: ISubtitle) =>
{
if (err)
{
return done(err);
}
try
{
done(null, script(xml) + '\n' +
style(xml.styles) + '\n' +
event(xml.events));
} catch (err) {
style(xml.styles) + '\n' +
event(xml.events));
} catch (err)
{
done(err);
}
});
@@ -23,69 +31,73 @@ export default function(input: string|Buffer, done: (err: Error, subtitle?: stri
/**
* Converts the event block.
*/
function event(block: ISubtitleEvent): string {
function event(block: ISubtitleEvent): string
{
var format = 'Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text';
return '[Events]\n' +
'Format: ' + format + '\n' +
[].concat(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') + '\n';
'Format: ' + format + '\n' + [].concat(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') + '\n';
}
/**
* Converts the script block.
*/
function script(block: ISubtitle): string {
function script(block: 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 + '\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 + '\n';
}
/**
* Converts the style block.
*/
function style(block: ISubtitleStyle): string {
function style(block: 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';
'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' +
[].concat(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') + '\n';
'Format: ' + format + '\n' + [].concat(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') + '\n';
}

View File

@@ -4,18 +4,30 @@ import xml2js = require('xml2js');
/**
* Converts an input buffer to a SubRip subtitle.
*/
export default function(input: Buffer|string, done: (err: Error, subtitle?: string) => void) {
var options = {explicitArray: false, explicitRoot: false};
xml2js.parseString(input.toString(), options, (err: Error, xml: ISubtitle) => {
try {
if (err) return done(err);
done(null, xml.events.event.map((event, index) => {
var attributes = event.$;
export default function(input: Buffer|string, done: (err: Error, subtitle?: string) => void)
{
const options = {explicitArray: false, explicitRoot: false};
xml2js.parseString(input.toString(), options, (err: Error, xml: ISubtitle) =>
{
try
{
if (err)
{
return done(err);
}
done(null, xml.events.event.map((event, index) =>
{
const attributes = event.$;
return (index + 1) + '\n' +
time(attributes.start) + ' --> ' + time(attributes.end) + '\n' +
text(attributes.text) + '\n';
time(attributes.start) + ' --> ' + time(attributes.end) + '\n' +
text(attributes.text) + '\n';
}).join('\n'));
} catch (err) {
} catch (err)
{
done(err);
}
});
@@ -24,41 +36,59 @@ import xml2js = require('xml2js');
/**
* Prefixes a value.
*/
function prefix(value: string, length: number): string {
while (value.length < length) value = '0' + 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';
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 {
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();
.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);
function time(value: string): string
{
const all = value.match(/^([0-9]+):([0-9]+):([0-9]+)\.([0-9]+)$/);
if (!all)
{
throw new Error('Invalid time.');
}
const hours = prefix(all[1], 2);
const minutes = prefix(all[2], 2);
const seconds = prefix(all[3], 2);
const milliseconds = suffix(all[4], 3);
return hours + ':' + minutes + ':' + seconds + ',' + milliseconds;
}

View File

@@ -8,36 +8,55 @@ import subtitle from '../subtitle/index';
/**
* Merges the subtitle and video files into a Matroska Multimedia Container.
*/
export default function(config: IConfig, isSubtitled: boolean, rtmpInputPath: string, filePath: string, streamMode: string, done: (err: Error) => void) {
var subtitlePath = filePath + '.' + (subtitle.formats[config.format] ? config.format : 'ass');
var videoPath = filePath;
if (streamMode == "RTMP")
export default function(config: IConfig, isSubtitled: boolean, rtmpInputPath: string, filePath: string,
streamMode: string, done: (err: Error) => void)
{
const subtitlePath = filePath + '.' + (subtitle.formats[config.format] ? config.format : 'ass');
let videoPath = filePath;
if (streamMode === 'RTMP')
{
videoPath += path.extname(rtmpInputPath);
}
else
{
videoPath += ".mp4";
videoPath += '.mp4';
}
childProcess.exec(command() + ' ' +
'-o "' + filePath + '.mkv" ' +
'"' + videoPath + '" ' +
(isSubtitled ? '"' + subtitlePath + '"' : ''), {
maxBuffer: Infinity
}, err => {
if (err) return done(err);
unlink(videoPath, subtitlePath, err => {
if (err) unlinkTimeout(videoPath, subtitlePath, 5000);
done(null);
});
'-o "' + filePath + '.mkv" ' +
'"' + videoPath + '" ' +
(isSubtitled ? '"' + subtitlePath + '"' : ''), {
maxBuffer: Infinity,
}, (err) =>
{
if (err)
{
return done(err);
}
unlink(videoPath, subtitlePath, (errin) =>
{
if (errin)
{
unlinkTimeout(videoPath, subtitlePath, 5000);
}
done(null);
});
});
}
/**
* Determines the command for the operating system.
*/
function command(): string {
if (os.platform() !== 'win32') return 'mkvmerge';
function command(): string
{
if (os.platform() !== 'win32')
{
return 'mkvmerge';
}
return '"' + path.join(__dirname, '../../bin/mkvmerge.exe') + '"';
}
@@ -45,9 +64,15 @@ function command(): string {
* 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);
function unlink(videoPath: string, subtitlePath: string, done: (err: Error) => void)
{
fs.unlink(videoPath, (err) =>
{
if (err)
{
return done(err);
}
fs.unlink(subtitlePath, done);
});
}
@@ -55,10 +80,16 @@ function unlink(videoPath: string, subtitlePath: string, done: (err: Error) => v
/**
* 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);
function unlinkTimeout(videoPath: string, subtitlePath: string, timeout: number)
{
setTimeout(() =>
{
unlink(videoPath, subtitlePath, (err) =>
{
if (err)
{
unlinkTimeout(videoPath, subtitlePath, timeout);
}
});
}, timeout);
}

View File

@@ -7,38 +7,44 @@ import log = require('../log');
/**
* Streams the video to disk.
*/
export default function(rtmpUrl: string, rtmpInputPath: string, swfUrl: string, filePath: string, fileExt: string, mode: string, done: (err: Error) => void) {
if (mode == "RTMP")
export default function(rtmpUrl: string, rtmpInputPath: string, swfUrl: string, filePath: string,
fileExt: string, mode: string, done: (err: Error) => void)
{
if (mode === 'RTMP')
{
childProcess.exec(command("rtmpdump") + ' ' +
childProcess.exec(command('rtmpdump') + ' ' +
'-r "' + rtmpUrl + '" ' +
'-y "' + rtmpInputPath + '" ' +
'-W "' + swfUrl + '" ' +
'-o "' + filePath + fileExt + '"', {
maxBuffer: Infinity
maxBuffer: Infinity,
}, done);
}
else if (mode == "HLS")
else if (mode === 'HLS')
{
//log.debug("Experimental FFMPEG, MAY FAIL!!!");
var cmd=command("ffmpeg") + ' ' +
'-i "' + rtmpInputPath + '" ' +
'-c copy -bsf:a aac_adtstoasc ' +
const cmd = command('ffmpeg') + ' ' +
'-i "' + rtmpInputPath + '" ' +
'-c copy -bsf:a aac_adtstoasc ' +
'"' + filePath + '.mp4"';
childProcess.exec(cmd, {
maxBuffer: Infinity
maxBuffer: Infinity,
}, done);
}
else
{
log.error("No such mode: " + mode);
log.error('No such mode: ' + mode);
}
}
/**
* Determines the command for the operating system.
*/
function command(exe: string): string {
if (os.platform() !== 'win32') return exe;
function command(exe: string): string
{
if (os.platform() !== 'win32')
{
return exe;
}
return '"' + path.join(__dirname, '../../bin/' + exe + '.exe') + '"';
}

View File

@@ -31,7 +31,7 @@
"src/interface/ISubtitle.d.ts",
"src/interface/ISubtitleEvent.d.ts",
"src/interface/ISubtitleStyle.d.ts",
"src/request.ts",
"src/my_request.ts",
"src/series.ts",
"src/subtitle/decode.ts",
"src/subtitle/formats/ass.ts",
@@ -41,13 +41,6 @@
"src/video/index.ts",
"src/video/merge.ts",
"src/video/stream.ts",
"typings/big-integer/big-integer.d.ts",
"typings/cheerio/cheerio.d.ts",
"typings/commander/commander.d.ts",
"typings/form-data/form-data.d.ts",
"typings/mkdirp/mkdirp.d.ts",
"typings/node/node.d.ts",
"typings/request/request.d.ts",
"typings/xml2js/xml2js.d.ts"
"typings/index.d.ts"
]
}

View File

@@ -1,4 +1,5 @@
{
"extends": "tslint:latest",
"rules": {
"ban": false,
"class-name": true,
@@ -12,13 +13,13 @@
"interface-name": true,
"jsdoc-format": true,
"label-position": true,
"label-undefined": true,
"max-line-length": [true, 140],
"member-ordering": [true,
"public-before-private",
"static-before-instance",
"variables-before-functions"
],
"array-type": [true, "array"],
"no-any": false,
"no-arg": true,
"no-bitwise": true,
@@ -30,19 +31,14 @@
"trace"
],
"no-construct": true,
"no-constructor-vars": true,
"no-debugger": true,
"no-duplicate-key": true,
"no-duplicate-variable": true,
"no-empty": true,
"no-eval": true,
"no-string-literal": true,
"no-switch-case-fall-through": true,
"no-trailing-comma": true,
"no-trailing-whitespace": true,
"no-unused-expression": true,
"no-unused-variable": true,
"no-unreachable": true,
"no-use-before-declare": false,
"no-var-requires": true,
"one-line": [true,
@@ -64,7 +60,6 @@
"property-declaration": "nospace",
"variable-declaration": "nospace"
}],
"use-strict": false,
"variable-name": false,
"whitespace": [true,
"check-branch",