crunchy/src/episode.ts
2017-08-21 16:08:58 +02:00

353 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
import cheerio = require('cheerio');
import fs = require('fs');
import mkdirp = require('mkdirp');
import my_request = require('./my_request');
import path = require('path');
import subtitle from './subtitle/index';
import video from './video/index';
import xml2js = require('xml2js');
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, (errS, player) =>
{
if (errS)
{
return done(errS, false);
}
download(config, page, player, done);
});
});
}
/**
* 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)
{
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)
{
try
{
fs.statSync(path);
return true;
} catch (e)
{
return false;
}
}
function sanitiseFileName(str: string)
{
return str.replace('/', '_').replace('\'', '_').replace(':', '_').replace('?', '_')
.replace('*', '_').replace('\"', '_').replace('<', '_').replace('>', '_');
}
/**
* Downloads the subtitle and video.
*/
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'))
{
let count = 0;
log.warn('File \'' + fileName + '\' already exist...');
do
{
count = count + 1;
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 + '\'...');
}
mkdirp(path.dirname(filePath), (errM: Error) =>
{
if (errM)
{
return done(errM, 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...', 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);
video.merge(config, isSubtited, player.video.file, filePath, player.video.mode, (errVM) =>
{
if (errVM)
{
return done(errVM, false);
}
complete(fileName, 'Finished!', now, done);
});
});
}
else
{
log.dispEpisode(fileName, 'Ignoring: not released yet', true);
done(null, true);
}
});
});
}
/**
* Saves the subtitles to disk.
*/
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);
});
});
}
/**
* Streams the video to disk.
*/
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)
{
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)
{
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)
{
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('Somethig unexpected in the page at ' + address + ' (data are: ' + look + ')');
log.warn('Setting Season to 0 and episode to 0...');
done(null, {
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',
});
}
});
}
/**
* 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)
{
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);
}
xml2js.parseString(result, {
explicitArray: false,
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';
}
done(null, {
subtitle: isSubtitled ? {
data: player['default:preload'].subtitle.data,
id: parseInt(player['default:preload'].subtitle.$.id, 10),
iv: player['default:preload'].subtitle.iv,
} : null,
video: {
file: player['default:preload'].stream_info.file,
host: player['default:preload'].stream_info.host,
mode: streamMode,
},
});
} catch (parseError)
{
done(parseError);
}
});
});
}