index.js 11 KB

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