index.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. 'use strict';
  2. const fs = require('fs');
  3. const path = require('path');
  4. const minimist = require('minimist');
  5. const low = require('lowdb');
  6. const Queue = require('better-queue');
  7. const FileSync = require('lowdb/adapters/FileSync');
  8. const colors = require('colors');
  9. const chokidar = require('chokidar');
  10. const hbjs = require('handbrake-js');
  11. const MINUTE = 60 * 1000;
  12. const HOUR = 60 * MINUTE;
  13. const DAY = 24 * HOUR;
  14. const WEEK = 7 * DAY;
  15. const CLEANER = HOUR;
  16. colors.setTheme({
  17. silly: 'rainbow',
  18. input: 'grey',
  19. verbose: 'cyan',
  20. prompt: 'grey',
  21. info: 'grey',
  22. data: 'grey',
  23. help: 'cyan',
  24. warn: 'yellow',
  25. success: 'green',
  26. failure: 'red',
  27. debug: 'blue',
  28. delete: 'red',
  29. changed: 'cyan',
  30. error: 'red',
  31. });
  32. const settings = require('./data/settings.json');
  33. const args = minimist(process.argv.slice(2), {
  34. alias: {
  35. h: 'help',
  36. v: 'version',
  37. c: 'config',
  38. i: 'ignoreInitial',
  39. t: 'test',
  40. },
  41. });
  42. const version = () => {
  43. const pkg = require('./package.json');
  44. return console.log(colors.help(pkg.version));
  45. };
  46. const help = () => {
  47. let message = `Usage:
  48. \tnpm run movies
  49. \tnpm run tvshows
  50. \tnpm run pr0n
  51. \tnpm run all
  52. \tnpm run help
  53. \tnpm run version\n`;
  54. return console.log(colors.help(message));
  55. };
  56. String.prototype.toTitleCase = function () {
  57. var i, j, str, lowers, uppers;
  58. str = this.replace(/([^\W_]+[^\s-]*) */g, function (txt) {
  59. return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
  60. });
  61. // Certain minor words should be left lowercase unless
  62. // they are the first or last words in the string
  63. lowers = (settings && settings.titlecase && settings.titlecase.lowers) || [];
  64. for (i = 0, j = lowers.length; i < j; i++)
  65. str = str.replace(new RegExp('\\s' + lowers[i] + '\\s', 'g'), function (txt) {
  66. return txt.toLowerCase();
  67. });
  68. // Certain words such as initialisms or acronyms should be left uppercase
  69. uppers = (settings && settings.titlecase && settings.titlecase.uppers) || [];
  70. for (i = 0, j = uppers.length; i < j; i++) str = str.replace(new RegExp('\\b' + uppers[i] + '\\b', 'g'), uppers[i].toUpperCase());
  71. return str;
  72. };
  73. // get the db from the path
  74. const getDbForDir = (dir, opts) => {
  75. let options = opts || Object.assign({}, defaults, paths[dir]); // baseline the options
  76. let adapter = new FileSync(options.database); // init the db adapter
  77. let db = low(adapter); // connect the db
  78. return db;
  79. };
  80. // find a file in the db
  81. const findFile = (db, file) => {
  82. return db
  83. .get('files')
  84. .find({
  85. input: file,
  86. })
  87. .value(); // does it already exist?
  88. };
  89. // set a file in the db
  90. const setFile = (db, file, payload) => {
  91. if (!payload && typeof file === 'object') return db.get('files').push(file).write();
  92. return db
  93. .get('files')
  94. .find({
  95. input: file,
  96. })
  97. .assign(payload)
  98. .write();
  99. };
  100. // remove a file from the db
  101. const removeFile = (db, file, soft = true) => {
  102. if (soft)
  103. return db
  104. .get('files')
  105. .find({
  106. input: file,
  107. })
  108. .assign({ status: 'deleted', date: new Date() })
  109. .write(); // remove file from database
  110. return db
  111. .get('files')
  112. .remove({
  113. input: file,
  114. })
  115. .write(); // remove file from database
  116. };
  117. // write process output
  118. const processOutput = (str) => {
  119. process.stdout.clearLine();
  120. process.stdout.cursorTo(0);
  121. process.stdout.write(str);
  122. };
  123. const getFilesize = async (file) => {
  124. let stats = fs.existsSync(file) ? fs.statSync(file) : false;
  125. let bytes = stats ? stats['size'] : 0;
  126. return bytes && bytes > 0 ? bytes / 1024 : 0;
  127. };
  128. // spawn a handbrake
  129. const processWithHandbrake = (input, output, preset) => {
  130. return new Promise((resolve, reject) => {
  131. const inputName = path.basename(input);
  132. const outputName = path.basename(output);
  133. hbjs
  134. .spawn({
  135. input: input,
  136. output: output,
  137. preset: preset,
  138. })
  139. .on('start', (err) => {
  140. processOutput(colors.info(`-> Starting "${outputName}" (${new Date()} with "${preset}")`));
  141. })
  142. .on('error', (err) => {
  143. processOutput(colors.error(`-> Errored "${outputName}" (${new Date()}: ${err.message || err})\n`));
  144. reject(err);
  145. })
  146. .on('progress', (progress) => {
  147. processOutput(colors.warn(`-> Transcoding "${outputName}" (${progress.percentComplete}%, ETA: ${progress.eta})`));
  148. })
  149. .on('cancelled', () => {
  150. processOutput(colors.failure(`-> Cancelled "${outputName}" (${new Date()})\n`));
  151. reject(new Error(`Processing "${outputName}" cancelled`));
  152. })
  153. .on('complete', () => {
  154. processOutput(colors.success(`-> Completed "${outputName}" (${new Date()} with "${preset}")\n`));
  155. resolve(true);
  156. });
  157. });
  158. };
  159. const processFile = async (file) => {
  160. let db,
  161. options,
  162. preset = 'Fast 1080p30',
  163. clean = {
  164. '.': ' ',
  165. },
  166. ext = 'm4v',
  167. filename = path.parse(file).name,
  168. input = file,
  169. output = filename,
  170. titlecase = false,
  171. folder = false,
  172. destination = '';
  173. // determine the presets and clean object
  174. for (let i = 0, l = dirs.length; i < l; i++) {
  175. let dir = dirs[i]; // pointer to the dir
  176. if (file && dir && file.indexOf(dir) > -1) {
  177. // is this in this path?
  178. options = Object.assign({}, defaults, paths[dir]); // baseline the options
  179. db = getDbForDir(dir, options); // init the db connection
  180. let found = findFile(db, file); // does it already exist?
  181. if (found && ((found.status && found.status === 'success') || found.processed)) {
  182. // was it already processed?
  183. return false; // break this loop
  184. } else if (!found) {
  185. // was it found? .. nope
  186. processOutput(colors.warn(`-> Processing "${path.basename(file)}" (${new Date()})`));
  187. setFile(db, {
  188. input: file,
  189. output: '',
  190. status: '',
  191. date: new Date(),
  192. }); // push onto the list an entry
  193. } else {
  194. processOutput(colors.warn(`-> Re-processing "${path.basename(file)}" (${new Date()})`));
  195. setFile(db, file, {
  196. status: '',
  197. date: new Date(),
  198. }); // set the status to blank
  199. }
  200. preset = options.preset; // apply the specifics ...
  201. clean = options.clean; // ...
  202. ext = options.ext; // ...
  203. titlecase = !!options.titlecase; // ...
  204. folder = !!options.folder; // ...
  205. destination = options.destination; // ...
  206. break; // break the loop
  207. }
  208. }
  209. // clean the output name
  210. let cleanKeys = Object.keys(clean);
  211. for (let c in cleanKeys) {
  212. let key = cleanKeys[c];
  213. let val = clean[key];
  214. let re = new RegExp(key, 'gi');
  215. output = output.replace(re, val);
  216. }
  217. // baseline the output name
  218. output = output.trim(); // trim the whitespace
  219. output = output.replace(/(\d{4})$/, `($1)`); // if there is a date at the end wrap it
  220. // do we have a sub folder option?
  221. if (folder) {
  222. let match = output.match(/^(.*?)?\./gm); // get the name for the file before the first .
  223. folder = match && match.length > 0 ? match[0].slice(0, -1) : false; // get just the stuff before the dot ... or false
  224. }
  225. // do we have title case enabled?
  226. if (options.titlecase) output = output.toTitleCase(); // titlecase that string
  227. // baseline the target
  228. let target = destination + (folder ? '/' + folder : ''); // setup the target location
  229. output = target + '/' + output + '.' + ext; // update the new name
  230. // do we already have an existing output that matches?
  231. if (fs.existsSync(output)) {
  232. let outputSize = await getFilesize(output); //get output filesize
  233. let inputSize = await getFilesize(input); //get output filesize
  234. if (outputSize > inputSize / 4) {
  235. //make sure its bigger than 100k
  236. processOutput(colors.warn(`-> Skipping "${path.basename(file)}" (already processed)\n`));
  237. setFile(db, file, {
  238. output: output,
  239. processed: true,
  240. status: 'success',
  241. date: new Date(),
  242. }); // update database with status
  243. return false;
  244. }
  245. }
  246. //process.stdout.write('\n'); // send a new line
  247. // update database with output name
  248. setFile(db, file, {
  249. output: output,
  250. });
  251. // create parent if required
  252. if (target && !fs.existsSync(target)) {
  253. processOutput(colors.info(`-> Creating parent directory "${path.basename(file)}" ("${target}")`));
  254. fs.mkdirSync(target, {
  255. recursive: true,
  256. });
  257. }
  258. // spawn handbrake
  259. if (!test) {
  260. try {
  261. await processWithHandbrake(input, output, preset);
  262. setFile(db, file, {
  263. status: 'success',
  264. processed: true,
  265. date: new Date(),
  266. }); // update database with status
  267. } catch (err) {
  268. setFile(db, file, {
  269. status: 'failure',
  270. processed: false,
  271. date: new Date(),
  272. }); // update database with status
  273. }
  274. }
  275. return true; // when complete return true
  276. };
  277. // cleanup removes from the db
  278. const cleanup = (file) => {
  279. let db;
  280. for (let i = 0, l = dirs.length; i < l; i++) {
  281. let dir = dirs[i]; // pointer to the dir
  282. if (file && dir && file.indexOf(dir) > -1) {
  283. db = getDbForDir(dir); // init the db connection
  284. let exists = fs.existsSync(file); // check if the file actually exists
  285. if (!exists) removeFile(db, file, true); //soft remove
  286. }
  287. }
  288. };
  289. // handle args
  290. if (args.version) return version(); // show version
  291. if (args.help || !args.config) return help(); // show help
  292. const ignoreInitial = args.hasOwnProperty('ignoreInitial') ? args.ignoreInitial : false; // ignore initial files
  293. const test = args.hasOwnProperty('test') ? args.test : false; // do a dry run ... just log
  294. // get our paths
  295. const paths = require(args.config);
  296. // init our defaults
  297. const defaults = {
  298. preset: 'Fast 1080p30',
  299. clean: {},
  300. titlecase: false,
  301. folder: false,
  302. database: 'data/db.json',
  303. };
  304. // setup watcher options
  305. const opts = {
  306. ignored: /(^|[\/\\])\..|([s|S]ample\.*)/,
  307. ignoreInitial: ignoreInitial,
  308. persistent: true,
  309. usePolling: true,
  310. interval: 10 * 1000,
  311. depth: 1,
  312. awaitWriteFinish: {
  313. stabilityThreshold: 3000,
  314. pollInterval: 1000,
  315. },
  316. ignorePermissionErrors: false,
  317. atomic: true,
  318. };
  319. // parse the paths to dirs
  320. const dirs = Object.keys(paths);
  321. // initialize watches and db then start the watcher
  322. const main = async () => {
  323. var queue = new Queue(
  324. async (input, cb) => {
  325. // init the queue
  326. let result = await processFile(input); // process the queue
  327. cb(null, result);
  328. },
  329. {
  330. batchSize: 1,
  331. concurrent: 1,
  332. maxRetries: 3,
  333. retryDelay: 5000,
  334. }
  335. );
  336. var watches = []; // array of things to watch
  337. for (let d in dirs) {
  338. // loop the dirs
  339. let dir = dirs[d]; // pointer
  340. let options = Object.assign({}, defaults, paths[dir]); // baseline the options
  341. let db = getDbForDir(dir);
  342. db.defaults({
  343. files: [],
  344. }).write(); // init the database
  345. for (let e in options.exts) {
  346. // loop the exts to watch
  347. let ext = options.exts[e]; // alias the ext
  348. watches.push(`${dir}/**/*.${ext}`); // push the watch
  349. }
  350. }
  351. const watcher = chokidar.watch(watches, opts); // init our watcher
  352. console.log(colors.info('Watching:', watches));
  353. watcher
  354. .on('add', async (file) => {
  355. // when a file is added ...
  356. queue.push(file); // push the file onto the queue to be processed
  357. })
  358. .on('change', (file) => {
  359. // when a file changes ...
  360. console.log(colors.changed(`-> Changed "${file}" (${new Date()})`));
  361. })
  362. .on('unlink', async (file) => {
  363. // when a file is removed ...
  364. console.log(colors.delete(`-> Removed "${file}" (${new Date()})`));
  365. cleanup(file);
  366. })
  367. .on('error', (error) => {
  368. console.error(colors.error(`-> Errored "${file}"(${new Date()}: ${error.message || error})`));
  369. });
  370. };
  371. const purge = () => {
  372. let ago = new Date(Date.now() - DAY);
  373. let db;
  374. console.log(colors.info(`Checking for and "deleted" records older then ${ago}`));
  375. for (let i = 0, l = dirs.length; i < l; i++) {
  376. let dir = dirs[i]; // pointer to the dir
  377. db = getDbForDir(dir); // init the db connection
  378. let files = db
  379. .get('files')
  380. .filter((file) => file.status && file.status === 'deleted' && file.processed && file.date && new Date(file.date).getTime() < ago.getTime());
  381. for (let file of files) {
  382. console.log(colors.delete(`-> Purging "${file.input}" (${new Date()})`));
  383. if (file && file.input) removeFile(db, file.input, false);
  384. }
  385. }
  386. setTimeout(() => {
  387. purge();
  388. }, CLEANER);
  389. };
  390. const prune = () => {
  391. let db;
  392. console.log(colors.info('Checking for any "processed" files that need pruning.'));
  393. for (let i = 0, l = dirs.length; i < l; i++) {
  394. let dir = dirs[i]; // pointer to the dir
  395. db = getDbForDir(dir); // init the db connection
  396. let files = db.get('files').filter((file) => file && file.processed);
  397. for (let file of files) {
  398. let exists = fs.existsSync(file.input); // check if the file actually exists
  399. if (!exists) {
  400. console.log(colors.delete(`-> Pruning "${file.input}" (${new Date()})`));
  401. removeFile(db, file.input, false); //soft remove
  402. }
  403. }
  404. }
  405. };
  406. (async () => {
  407. main();
  408. purge();
  409. prune();
  410. })().catch((err) => {
  411. console.log(colors.error(`Error: ${err.message || err}`));
  412. });