index.js 12 KB

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