episode.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. 'use strict';
  2. import cheerio = require('cheerio');
  3. import fs = require('fs');
  4. import mkdirp = require('mkdirp');
  5. import request = require('./request');
  6. import path = require('path');
  7. import subtitle from './subtitle/index';
  8. import video from './video/index';
  9. import xml2js = require('xml2js');
  10. import log = require('./log');
  11. /**
  12. * Streams the episode to disk.
  13. */
  14. export default function(config: IConfig, address: string, done: (err: Error, ign: boolean) => void)
  15. {
  16. scrapePage(config, address, (err, page) =>
  17. {
  18. if (err)
  19. {
  20. return done(err, false);
  21. }
  22. scrapePlayer(config, address, page.id, (errS, player) =>
  23. {
  24. if (errS)
  25. {
  26. return done(errS, false);
  27. }
  28. download(config, page, player, done);
  29. });
  30. });
  31. }
  32. /**
  33. * Completes a download and writes the message with an elapsed time.
  34. */
  35. function complete(epName: string, message: string, begin: number, done: (err: Error, ign: boolean) => void)
  36. {
  37. const timeInMs = Date.now() - begin;
  38. const seconds = prefix(Math.floor(timeInMs / 1000) % 60, 2);
  39. const minutes = prefix(Math.floor(timeInMs / 1000 / 60) % 60, 2);
  40. const hours = prefix(Math.floor(timeInMs / 1000 / 60 / 60), 2);
  41. log.dispEpisode(epName, message + ' (' + hours + ':' + minutes + ':' + seconds + ')', true);
  42. done(null, false);
  43. }
  44. /**
  45. * Check if a file exist..
  46. */
  47. function fileExist(path: string)
  48. {
  49. try
  50. {
  51. fs.statSync(path);
  52. return true;
  53. } catch (e)
  54. {
  55. return false;
  56. }
  57. }
  58. function sanitiseFileName(str: string)
  59. {
  60. return str.replace('/', '_').replace('\'', '_').replace(':', '_').replace('?', '_')
  61. .replace('*', '_').replace('\"', '_').replace('<', '_').replace('>', '_');
  62. }
  63. /**
  64. * Downloads the subtitle and video.
  65. */
  66. function download(config: IConfig, page: IEpisodePage, player: IEpisodePlayer, done: (err: Error, ign: boolean) => void)
  67. {
  68. let series = config.series || page.series;
  69. series = sanitiseFileName(series);
  70. let fileName = sanitiseFileName(name(config, page, series, ''));
  71. let filePath = path.join(config.output || process.cwd(), series, fileName);
  72. if (fileExist(filePath + '.mkv'))
  73. {
  74. let count = 0;
  75. log.warn('File \'' + fileName + '\' already exist...');
  76. do
  77. {
  78. count = count + 1;
  79. fileName = sanitiseFileName(name(config, page, series, '-' + count));
  80. filePath = path.join(config.output || process.cwd(), series, fileName);
  81. } while (fileExist(filePath + '.mkv'));
  82. log.warn('Renaming to \'' + fileName + '\'...');
  83. }
  84. mkdirp(path.dirname(filePath), (errM: Error) =>
  85. {
  86. if (errM)
  87. {
  88. return done(errM, false);
  89. }
  90. downloadSubtitle(config, player, filePath, (errDS) =>
  91. {
  92. if (errDS)
  93. {
  94. return done(errDS, false);
  95. }
  96. const now = Date.now();
  97. if (player.video.file !== undefined)
  98. {
  99. log.dispEpisode(fileName, 'Fetching...', false);
  100. downloadVideo(config, page, player, filePath, (errDV) =>
  101. {
  102. if (errDV)
  103. {
  104. return done(errDV, false);
  105. }
  106. if (config.merge)
  107. {
  108. return complete(fileName, 'Finished!', now, done);
  109. }
  110. const isSubtited = Boolean(player.subtitle);
  111. video.merge(config, isSubtited, player.video.file, filePath, player.video.mode, (errVM) =>
  112. {
  113. if (errVM)
  114. {
  115. return done(errVM, false);
  116. }
  117. complete(fileName, 'Finished!', now, done);
  118. });
  119. });
  120. }
  121. else
  122. {
  123. log.dispEpisode(fileName, 'Ignoring: not released yet', true);
  124. done(null, true);
  125. }
  126. });
  127. });
  128. }
  129. /**
  130. * Saves the subtitles to disk.
  131. */
  132. function downloadSubtitle(config: IConfig, player: IEpisodePlayer, filePath: string, done: (err?: Error) => void)
  133. {
  134. const enc = player.subtitle;
  135. if (!enc)
  136. {
  137. return done();
  138. }
  139. subtitle.decode(enc.id, enc.iv, enc.data, (errSD, data) =>
  140. {
  141. if (errSD)
  142. {
  143. return done(errSD);
  144. }
  145. const formats = subtitle.formats;
  146. const format = formats[config.format] ? config.format : 'ass';
  147. formats[format](data, (errF: Error, decodedSubtitle: string) =>
  148. {
  149. if (errF)
  150. {
  151. return done(errF);
  152. }
  153. fs.writeFile(filePath + '.' + format, '\ufeff' + decodedSubtitle, done);
  154. });
  155. });
  156. }
  157. /**
  158. * Streams the video to disk.
  159. */
  160. function downloadVideo(ignored/*config*/: IConfig, page: IEpisodePage, player: IEpisodePlayer,
  161. filePath: string, done: (err: Error) => void)
  162. {
  163. video.stream(player.video.host, player.video.file, page.swf, filePath,
  164. path.extname(player.video.file), player.video.mode, done);
  165. }
  166. /**
  167. * Names the file based on the config, page, series and tag.
  168. */
  169. function name(config: IConfig, page: IEpisodePage, series: string, extra: string)
  170. {
  171. const episodeNum = parseInt(page.episode, 10);
  172. const volumeNum = parseInt(page.volume, 10);
  173. const episode = (episodeNum < 10 ? '0' : '') + page.episode;
  174. const volume = (volumeNum < 10 ? '0' : '') + page.volume;
  175. const tag = config.tag || 'CrunchyRoll';
  176. if (!config.filename) {
  177. return page.series + ' - s' + volume + 'e' + episode + ' - [' + tag + ']' + extra;
  178. }
  179. return config.filename
  180. .replace(/{EPISODE_ID}/g, page.id.toString())
  181. .replace(/{EPISODE_NUMBER}/g, episode)
  182. .replace(/{SEASON_NUMBER}/g, volume)
  183. .replace(/{VOLUME_NUMBER}/g, volume)
  184. .replace(/{SEASON_TITLE}/g, page.season)
  185. .replace(/{VOLUME_TITLE}/g, page.season)
  186. .replace(/{SERIES_TITLE}/g, series)
  187. .replace(/{EPISODE_TITLE}/g, page.title)
  188. .replace(/{TAG}/g, tag) + extra;
  189. }
  190. /**
  191. * Prefixes a value.
  192. */
  193. function prefix(value: number|string, length: number)
  194. {
  195. let valueString = (typeof value !== 'string') ? String(value) : value;
  196. while (valueString.length < length)
  197. {
  198. valueString = '0' + valueString;
  199. }
  200. return valueString;
  201. }
  202. /**
  203. * Requests the page data and scrapes the id, episode, series and swf.
  204. */
  205. function scrapePage(config: IConfig, address: string, done: (err: Error, page?: IEpisodePage) => void)
  206. {
  207. const epId = parseInt((address.match(/[0-9]+$/) || ['0'])[0], 10);
  208. if (!epId)
  209. {
  210. return done(new Error('Invalid address.'));
  211. }
  212. request.get(config, address, (err, result) =>
  213. {
  214. if (err)
  215. {
  216. return done(err);
  217. }
  218. const $ = cheerio.load(result);
  219. const swf = /^([^?]+)/.exec($('link[rel=video_src]').attr('href'));
  220. 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]?)/;
  221. const look = $('#showmedia_about_media').text();
  222. const seasonTitle = $('span[itemprop="title"]').text();
  223. let episodeTitle = $('#showmedia_about_name').text().replace(/[“”]/g, '');
  224. const data = regexp.exec(look);
  225. if (!swf || !data)
  226. {
  227. log.warn('Somethig unexpected in the page at ' + address + ' (data are: ' + look + ')');
  228. log.warn('Setting Season to ’0’ and episode to ’0’...');
  229. done(null, {
  230. episode: '0',
  231. id: epId,
  232. series: seasonTitle,
  233. season: seasonTitle,
  234. title: episodeTitle,
  235. swf: swf[1],
  236. volume: '0',
  237. });
  238. }
  239. else
  240. {
  241. done(null, {
  242. episode: data[3],
  243. id: epId,
  244. series: data[1],
  245. season: seasonTitle,
  246. title: episodeTitle,
  247. swf: swf[1],
  248. volume: data[2] || '1',
  249. });
  250. }
  251. });
  252. }
  253. /**
  254. * Requests the player data and scrapes the subtitle and video data.
  255. */
  256. function scrapePlayer(config: IConfig, address: string, id: number, done: (err: Error, player?: IEpisodePlayer) => void)
  257. {
  258. const url = address.match(/^(https?:\/\/[^\/]+)/);
  259. if (!url)
  260. {
  261. return done(new Error('Invalid address.'));
  262. }
  263. request.post(config, {
  264. form: {current_page: address},
  265. url: url[1] + '/xml/?req=RpcApiVideoPlayer_GetStandardConfig&media_id=' + id,
  266. }, (err, result) =>
  267. {
  268. if (err)
  269. {
  270. return done(err);
  271. }
  272. xml2js.parseString(result, {
  273. explicitArray: false,
  274. explicitRoot: false,
  275. }, (errPS: Error, player: IEpisodePlayerConfig) =>
  276. {
  277. if (errPS)
  278. {
  279. return done(errPS);
  280. }
  281. try
  282. {
  283. const isSubtitled = Boolean(player['default:preload'].subtitle);
  284. let streamMode = 'RTMP';
  285. if (player['default:preload'].stream_info.host === '')
  286. {
  287. streamMode = 'HLS';
  288. }
  289. done(null, {
  290. subtitle: isSubtitled ? {
  291. data: player['default:preload'].subtitle.data,
  292. id: parseInt(player['default:preload'].subtitle.$.id, 10),
  293. iv: player['default:preload'].subtitle.iv,
  294. } : null,
  295. video: {
  296. file: player['default:preload'].stream_info.file,
  297. host: player['default:preload'].stream_info.host,
  298. mode: streamMode,
  299. },
  300. });
  301. } catch (parseError)
  302. {
  303. done(parseError);
  304. }
  305. });
  306. });
  307. }