ソースを参照

refactor/feat: Huge backend rewrite

Using different module system. Old system had a memory leak. Still unstable and not finished yet.
Kristian Vos 5 年 前
コミット
ecc1f4fd98
37 ファイル変更15936 行追加8398 行削除
  1. 48 0
      backend/classes/Timer.class.js
  2. 102 70
      backend/core.js
  3. 298 160
      backend/index.js
  4. 93 56
      backend/logic/actions/activities.js
  5. 188 134
      backend/logic/actions/apis.js
  6. 54 36
      backend/logic/actions/hooks/adminRequired.js
  7. 46 29
      backend/logic/actions/hooks/loginRequired.js
  8. 63 40
      backend/logic/actions/hooks/ownerRequired.js
  9. 226 139
      backend/logic/actions/news.js
  10. 1147 658
      backend/logic/actions/playlists.js
  11. 154 108
      backend/logic/actions/punishments.js
  12. 373 229
      backend/logic/actions/queueSongs.js
  13. 342 233
      backend/logic/actions/reports.js
  14. 1022 503
      backend/logic/actions/songs.js
  15. 2342 1229
      backend/logic/actions/stations.js
  16. 2072 1315
      backend/logic/actions/users.js
  17. 61 56
      backend/logic/activities.js
  18. 44 39
      backend/logic/api.js
  19. 454 285
      backend/logic/app.js
  20. 261 206
      backend/logic/cache/index.js
  21. 324 192
      backend/logic/db/index.js
  22. 99 77
      backend/logic/discord.js
  23. 371 212
      backend/logic/io.js
  24. 0 177
      backend/logic/logger.js
  25. 45 35
      backend/logic/mail/index.js
  26. 17 12
      backend/logic/mail/schemas/passwordRequest.js
  27. 17 12
      backend/logic/mail/schemas/resetPasswordRequest.js
  28. 22 13
      backend/logic/mail/schemas/verifyEmail.js
  29. 238 154
      backend/logic/notifications.js
  30. 277 166
      backend/logic/playlists.js
  31. 313 241
      backend/logic/punishments.js
  32. 256 172
      backend/logic/songs.js
  33. 103 82
      backend/logic/spotify.js
  34. 1110 537
      backend/logic/stations.js
  35. 318 167
      backend/logic/tasks.js
  36. 752 624
      backend/logic/utils.js
  37. 2284 0
      backend/package-lock.json

+ 48 - 0
backend/classes/Timer.class.js

@@ -0,0 +1,48 @@
+module.exports = class Timer {
+    constructor(callback, delay, paused) {
+        this.callback = callback;
+        this.timerId = undefined;
+        this.start = undefined;
+        this.paused = paused;
+        this.remaining = delay;
+        this.timeWhenPaused = 0;
+        this.timePaused = Date.now();
+
+        if (!paused) {
+            this.resume();
+        }
+    }
+
+    pause() {
+        clearTimeout(this.timerId);
+        this.remaining -= Date.now() - this.start;
+        this.timePaused = Date.now();
+        this.paused = true;
+    }
+
+    ifNotPaused() {
+        if (!this.paused) {
+            this.resume();
+        }
+    }
+
+    resume() {
+        this.start = Date.now();
+        clearTimeout(this.timerId);
+        this.timerId = setTimeout(this.callback, this.remaining);
+        this.timeWhenPaused = Date.now() - this.timePaused;
+        this.paused = false;
+    }
+
+    resetTimeWhenPaused() {
+        this.timeWhenPaused = 0;
+    }
+
+    getTimePaused() {
+        if (!this.paused) {
+            return this.timeWhenPaused;
+        } else {
+            return Date.now() - this.timePaused;
+        }
+    }
+};

+ 102 - 70
backend/core.js

@@ -1,85 +1,117 @@
-const EventEmitter = require('events');
+const async = require("async");
 
-const bus = new EventEmitter();
+class DeferredPromise {
+    constructor() {
+        this.promise = new Promise((resolve, reject) => {
+            this.reject = reject;
+            this.resolve = resolve;
+        });
+    }
+}
 
-bus.setMaxListeners(1000);
+class CoreClass {
+    constructor(name) {
+        this.name = name;
+        this.status = "UNINITIALIZED";
+        // this.log("Core constructor");
+        this.jobQueue = async.priorityQueue(
+            (job, callback) => this._runJob(job, callback),
+            10 // How many jobs can run concurrently
+        );
+        this.jobQueue.pause();
+        this.runningJobs = [];
+        this.priorities = {};
+        this.stage = 0;
+    }
 
-module.exports = class {
-	constructor(name, moduleManager) {
-		this.name = name;
-		this.moduleManager = moduleManager;
-		this.lockdown = false;
-		this.dependsOn = [];
-		this.eventHandlers = [];
-		this.state = "NOT_INITIALIZED";
-		this.stage = 0;
-		this.lastTime = 0;
-		this.totalTimeInitialize = 0;
-		this.timeDifferences = [];
-		this.failed = false;
-	}
+    setStatus(status) {
+        this.status = status;
+        this.log("INFO", `Status changed to: ${status}`);
+        if (this.status === "READY") this.jobQueue.resume();
+        else if (this.status === "FAIL" || this.status === "LOCKDOWN")
+            this.jobQueue.pause();
+    }
 
-	_initialize() {
-		this.logger = this.moduleManager.modules["logger"];
-		this.setState("INITIALIZING");
+    getStatus() {
+        return this.status;
+    }
 
-		this.initialize().then(() => {
-			this.setState("INITIALIZED");
-			this.setStage(0);
-			this.moduleManager.printStatus();
-		}).catch(async (err) => {			
-			this.failed = true;
+    setStage(stage) {
+        this.stage = stage;
+    }
 
-			this.logger.error(err.stack);
+    getStage() {
+        return this.stage;
+    }
 
-			this.moduleManager.aModuleFailed(this);
-		});
-	}
+    _initialize() {
+        this.setStatus("INITIALIZING");
+        this.initialize()
+            .then(() => {
+                this.setStatus("READY");
+                this.moduleManager.onInitialize(this);
+            })
+            .catch((err) => {
+                console.error(err);
+                this.setStatus("FAILED");
+                this.moduleManager.onFail(this);
+            });
+    }
 
-	_onInitialize() {
-		return new Promise(resolve => bus.once(`stateChange:${this.name}:INITIALIZED`, resolve));
-	}
+    log() {
+        let _arguments = Array.from(arguments);
+        const type = _arguments[0];
+        _arguments.splice(0, 1);
+        const start = `|${this.name.toUpperCase()}|`;
+        const numberOfTabsNeeded = 4 - Math.ceil(start.length / 8);
+        _arguments.unshift(`${start}${Array(numberOfTabsNeeded).join("\t")}`);
 
-	_isInitialized() {
-		return new Promise(resolve => {
-			if (this.state === "INITIALIZED") resolve();
-		});
-	}
+        if (type === "INFO") {
+            _arguments[0] = _arguments[0] + "\x1b[36m";
+            _arguments.push("\x1b[0m");
+            console.log.apply(null, _arguments);
+        } else if (type === "ERROR") {
+            _arguments[0] = _arguments[0] + "\x1b[31m";
+            _arguments.push("\x1b[0m");
+            console.error.apply(null, _arguments);
+        }
+    }
 
-	_isNotLocked() {
-		return new Promise((resolve, reject) => {
-			if (this.state === "LOCKDOWN") reject();
-			else resolve();
-		});
-	}
+    runJob(name, payload, options = {}) {
+        let deferredPromise = new DeferredPromise();
+        const job = { name, payload, onFinish: deferredPromise };
 
-	setState(state) {
-		this.state = state;
-		bus.emit(`stateChange:${this.name}:${state}`);
-		this.logger.info(`MODULE_STATE`, `${state}: ${this.name}`);
-	}
+        if (options.bypassQueue) {
+            this._runJob(job, () => {});
+        } else {
+            const priority = this.priorities[name] ? this.priorities[name] : 10;
+            this.jobQueue.push(job, priority);
+        }
 
-	setStage(stage) {
-		if (stage !== 1)
-			this.totalTimeInitialize += (Date.now() - this.lastTime);
-		//this.timeDifferences.push(this.stage + ": " + (Date.now() - this.lastTime) + "ms");
-		this.timeDifferences.push(Date.now() - this.lastTime);
+        return deferredPromise.promise;
+    }
 
-		this.lastTime = Date.now();
-		this.stage = stage;
-		this.moduleManager.printStatus();
-	}
+    setModuleManager(moduleManager) {
+        this.moduleManager = moduleManager;
+    }
 
-	_validateHook() {
-		return Promise.race([this._onInitialize(), this._isInitialized()]).then(
-			() => this._isNotLocked()
-		);
-	}
+    _runJob(job, cb) {
+        this.log("INFO", `Running job ${job.name}`);
+        this.runningJobs.push(job);
+        this[job.name](job.payload)
+            .then((response) => {
+                this.log("INFO", `Ran job ${job.name} successfully`);
+                job.onFinish.resolve(response);
+            })
+            .catch((error) => {
+                this.log("INFO", `Running job ${job.name} failed`);
+                job.onFinish.reject(error);
+            })
+            .finally(() => {
+                this.runningJobs.splice(this.runningJobs.indexOf(job), 1);
+                cb();
+            });
+    }
+}
 
-	_lockdown() {
-		if (this.lockdown) return;
-		this.lockdown = true;
-		this.setState("LOCKDOWN");
-		this.moduleManager.printStatus();
-	}
-}
+module.exports = CoreClass;

+ 298 - 160
backend/index.js

@@ -1,4 +1,4 @@
-'use strict';
+"use strict";
 
 const util = require("util");
 
@@ -6,170 +6,293 @@ process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 
 const config = require("config");
 
-process.on('uncaughtException', err => {
-	if (err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
-	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
+process.on("uncaughtException", (err) => {
+    if (err.code === "ECONNREFUSED" || err.code === "UNCERTAIN_STATE") return;
+    console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 
 const fancyConsole = config.get("fancyConsole");
 
+// class ModuleManager {
+// 	constructor() {
+// 		this.modules = {};
+// 		this.modulesInitialized = 0;
+// 		this.totalModules = 0;
+// 		this.modulesLeft = [];
+// 		this.i = 0;
+// 		this.lockdown = false;
+// 		this.fancyConsole = fancyConsole;
+// 	}
+
+// 	addModule(moduleName) {
+// 		console.log("add module", moduleName);
+// 		const moduleClass = new require(`./logic/${moduleName}`);
+// 		this.modules[moduleName] = new moduleClass(moduleName, this);
+// 		this.totalModules++;
+// 		this.modulesLeft.push(moduleName);
+// 	}
+
+// 	initialize() {
+// 		if (!this.modules["logger"]) return console.error("There is no logger module");
+// 		this.logger = this.modules["logger"];
+// 		if (this.fancyConsole) {
+// 			this.replaceConsoleWithLogger();
+// 			this.logger.reservedLines = Object.keys(this.modules).length + 5;
+// 		}
+
+// 		for (let moduleName in this.modules) {
+// 			let module = this.modules[moduleName];
+// 			if (this.lockdown) break;
+
+// 			module._onInitialize().then(() => {
+// 				this.moduleInitialized(moduleName);
+// 			});
+
+// 			let dependenciesInitializedPromises = [];
+
+// 			module.dependsOn.forEach(dependencyName => {
+// 				let dependency = this.modules[dependencyName];
+// 				dependenciesInitializedPromises.push(dependency._onInitialize());
+// 			});
+
+// 			module.lastTime = Date.now();
+
+// 			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+// 				if (this.lockdown) return;
+// 				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+// 				module._initialize();
+// 			});
+// 		}
+// 	}
+
+// 	async printStatus() {
+// 		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
+// 		if (!this.fancyConsole) return;
+
+// 		let colors = this.logger.colors;
+
+// 		const rows = process.stdout.rows;
+
+// 		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+// 		process.stdout.clearScreenDown();
+
+// 		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
+
+// 		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
+
+// 		for (let moduleName in this.modules) {
+// 			let module = this.modules[moduleName];
+// 			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
+
+// 			let tabs = Array(tabsAmount).fill(`\t`).join("");
+
+// 			let timing = module.timeDifferences.map((timeDifference) => {
+// 				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
+// 			}).join(", ");
+
+// 			let stateColor;
+// 			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
+// 			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
+// 			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
+// 			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
+// 			else stateColor = colors.FgYellow;
+
+// 			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
+// 		}
+// 	}
+
+// 	moduleInitialized(moduleName) {
+// 		this.modulesInitialized++;
+// 		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
+
+// 		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
+
+// 		if (this.modulesLeft.length === 0) this.allModulesInitialized();
+// 	}
+
+// 	allModulesInitialized() {
+// 		this.logger.success("MODULE_MANAGER", "All modules have started!");
+// 		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
+// 	}
+
+// 	aModuleFailed(failedModule) {
+// 		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
+// 		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
+
+// 		this._lockdown();
+// 	}
+
+// 	replaceConsoleWithLogger() {
+// 		this.oldConsole = {
+// 			log: console.log,
+// 			debug: console.debug,
+// 			info: console.info,
+// 			warn: console.warn,
+// 			error: console.error
+// 		};
+// 		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
+// 	}
+
+// 	replaceLoggerWithConsole() {
+// 		console.log = this.oldConsole.log;
+// 		console.debug = this.oldConsole.debug;
+// 		console.info = this.oldConsole.info;
+// 		console.warn = this.oldConsole.warn;
+// 		console.error = this.oldConsole.error;
+// 	}
+
+// 	_lockdown() {
+// 		this.lockdown = true;
+
+// 		for (let moduleName in this.modules) {
+// 			let module = this.modules[moduleName];
+// 			if (module.lockdownImmune) continue;
+// 			module._lockdown();
+// 		}
+// 	}
+// }
+
+// const moduleManager = new ModuleManager();
+
+// module.exports = moduleManager;
+
+// moduleManager.addModule("cache");
+// moduleManager.addModule("db");
+// moduleManager.addModule("mail");
+// moduleManager.addModule("api");
+// moduleManager.addModule("app");
+// moduleManager.addModule("discord");
+// moduleManager.addModule("io");
+// moduleManager.addModule("logger");
+// moduleManager.addModule("notifications");
+// moduleManager.addModule("activities");
+// moduleManager.addModule("playlists");
+// moduleManager.addModule("punishments");
+// moduleManager.addModule("songs");
+// moduleManager.addModule("spotify");
+// moduleManager.addModule("stations");
+// moduleManager.addModule("tasks");
+// moduleManager.addModule("utils");
+
+// moduleManager.initialize();
+
+// process.stdin.on("data", function (data) {
+//     if(data.toString() === "lockdown\r\n"){
+//         console.log("Locking down.");
+//        	moduleManager._lockdown();
+//     }
+// });
+
+// if (fancyConsole) {
+// 	const rows = process.stdout.rows;
+
+// 	for(let i = 0; i < rows; i++) {
+// 		process.stdout.write("\n");
+// 	}
+// }
+
 class ModuleManager {
-	constructor() {
-		this.modules = {};
-		this.modulesInitialized = 0;
-		this.totalModules = 0;
-		this.modulesLeft = [];
-		this.i = 0;
-		this.lockdown = false;
-		this.fancyConsole = fancyConsole;
-	}
-
-	addModule(moduleName) {
-		console.log("add module", moduleName);
-		const moduleClass = new require(`./logic/${moduleName}`);
-		this.modules[moduleName] = new moduleClass(moduleName, this);
-		this.totalModules++;
-		this.modulesLeft.push(moduleName);
-	}
-
-	initialize() {
-		if (!this.modules["logger"]) return console.error("There is no logger module");
-		this.logger = this.modules["logger"];
-		if (this.fancyConsole) {
-			this.replaceConsoleWithLogger();
-			this.logger.reservedLines = Object.keys(this.modules).length + 5;
-		}
-		
-		for (let moduleName in this.modules) {
-			let module = this.modules[moduleName];
-			if (this.lockdown) break;
-
-			module._onInitialize().then(() => {
-				this.moduleInitialized(moduleName);
-			});
-
-			let dependenciesInitializedPromises = [];
-			
-			module.dependsOn.forEach(dependencyName => {
-				let dependency = this.modules[dependencyName];
-				dependenciesInitializedPromises.push(dependency._onInitialize());
-			});
-
-			module.lastTime = Date.now();
-
-			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
-				if (this.lockdown) return;
-				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
-				module._initialize();
-			});
-		}
-	}
-
-	async printStatus() {
-		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
-		if (!this.fancyConsole) return;
-		
-		let colors = this.logger.colors;
-
-		const rows = process.stdout.rows;
-
-		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
-		process.stdout.clearScreenDown();
-
-		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
-
-		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
-
-		for (let moduleName in this.modules) {
-			let module = this.modules[moduleName];
-			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
-
-			let tabs = Array(tabsAmount).fill(`\t`).join("");
-
-			let timing = module.timeDifferences.map((timeDifference) => {
-				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
-			}).join(", ");
-
-			let stateColor;
-			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
-			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
-			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
-			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
-			else stateColor = colors.FgYellow;
-			
-			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
-		}
-	}
-
-	moduleInitialized(moduleName) {
-		this.modulesInitialized++;
-		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
-
-		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
-
-		if (this.modulesLeft.length === 0) this.allModulesInitialized();
-	}
-
-	allModulesInitialized() {
-		this.logger.success("MODULE_MANAGER", "All modules have started!");
-		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
-	}
-
-	aModuleFailed(failedModule) {
-		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
-		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
-
-		this._lockdown();
-	}
-
-	replaceConsoleWithLogger() {
-		this.oldConsole = {
-			log: console.log,
-			debug: console.debug,
-			info: console.info,
-			warn: console.warn,
-			error: console.error
-		};
-		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
-	}
-
-	replaceLoggerWithConsole() {
-		console.log = this.oldConsole.log;
-		console.debug = this.oldConsole.debug;
-		console.info = this.oldConsole.info;
-		console.warn = this.oldConsole.warn;
-		console.error = this.oldConsole.error;
-	}
-
-	_lockdown() {
-		this.lockdown = true;
-		
-		for (let moduleName in this.modules) {
-			let module = this.modules[moduleName];
-			if (module.lockdownImmune) continue;
-			module._lockdown();
-		}
-	}
+    constructor() {
+        this.modules = {};
+        this.modulesNotInitialized = [];
+        this.i = 0;
+        this.lockdown = false;
+        this.fancyConsole = fancyConsole;
+    }
+
+    addModule(moduleName) {
+        console.log("add module", moduleName);
+        const module = require(`./logic/${moduleName}`);
+        this.modules[moduleName] = module;
+        this.modulesNotInitialized.push(module);
+    }
+
+    initialize() {
+        // if (!this.modules["logger"]) return console.error("There is no logger module");
+        // this.logger = this.modules["logger"];
+        // if (this.fancyConsole) {
+        // this.replaceConsoleWithLogger();
+        this.reservedLines = Object.keys(this.modules).length + 5;
+        // }
+
+        for (let moduleName in this.modules) {
+            let module = this.modules[moduleName];
+            module.setModuleManager(this);
+
+            if (this.lockdown) break;
+
+            module._initialize();
+
+            // let dependenciesInitializedPromises = [];
+
+            // module.dependsOn.forEach(dependencyName => {
+            // 	let dependency = this.modules[dependencyName];
+            // 	dependenciesInitializedPromises.push(dependency._onInitialize());
+            // });
+
+            // module.lastTime = Date.now();
+
+            // Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+            // 	if (this.lockdown) return;
+            // 	this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+            // 	module._initialize();
+            // });
+        }
+    }
+
+    onInitialize(module) {
+        if (this.modulesNotInitialized.indexOf(module) !== -1) {
+            this.modulesNotInitialized.splice(
+                this.modulesNotInitialized.indexOf(module),
+                1
+            );
+
+            console.log(
+                "MODULE_MANAGER",
+                `Initialized: ${Object.keys(this.modules).length -
+                    this.modulesNotInitialized.length}/${
+                    Object.keys(this.modules).length
+                }.`
+            );
+
+            if (this.modulesNotInitialized.length === 0)
+                this.onAllModulesInitialized();
+        }
+    }
+
+    onFail(module) {
+        if (this.modulesNotInitialized.indexOf(module) !== -1) {
+            console.log("A module failed to initialize!");
+        }
+    }
+
+    onAllModulesInitialized() {
+        console.log("All modules initialized!");
+        this.modules["discord"].runJob("SEND_ADMIN_ALERT_MESSAGE", {
+            message: "The backend server started successfully.",
+            color: "#00AA00",
+            type: "Startup",
+            critical: false,
+            extraFields: [],
+        });
+    }
 }
 
 const moduleManager = new ModuleManager();
 
-module.exports = moduleManager;
-
 moduleManager.addModule("cache");
 moduleManager.addModule("db");
 moduleManager.addModule("mail");
+moduleManager.addModule("activities");
 moduleManager.addModule("api");
 moduleManager.addModule("app");
 moduleManager.addModule("discord");
 moduleManager.addModule("io");
-moduleManager.addModule("logger");
 moduleManager.addModule("notifications");
-moduleManager.addModule("activities");
 moduleManager.addModule("playlists");
 moduleManager.addModule("punishments");
 moduleManager.addModule("songs");
@@ -180,18 +303,33 @@ moduleManager.addModule("utils");
 
 moduleManager.initialize();
 
-process.stdin.on("data", function (data) {
-    if(data.toString() === "lockdown\r\n"){
+process.stdin.on("data", function(data) {
+    if (data.toString() === "lockdown\r\n") {
         console.log("Locking down.");
-       	moduleManager._lockdown();
+        moduleManager._lockdown();
     }
-});
-
-
-if (fancyConsole) {
-	const rows = process.stdout.rows;
+    if (data.toString() === "status\r\n") {
+        console.log("Status:");
+
+        for (let moduleName in moduleManager.modules) {
+            let module = moduleManager.modules[moduleName];
+            const tabsNeeded = 4 - Math.ceil((moduleName.length + 1) / 8);
+            console.log(
+                `${moduleName.toUpperCase()}${Array(tabsNeeded).join(
+                    "\t"
+                )}${module.getStatus()}. Jobs in queue: ${module.jobQueue.length()}. Jobs in progress: ${module.jobQueue.running()}. Concurrency: ${
+                    module.jobQueue.concurrency
+                }. Stage: ${module.getStage()}`
+            );
+        }
+        // moduleManager._lockdown();
+    }
+    if (data.toString().startsWith("running")) {
+        const parts = data
+            .toString()
+            .substr(0, data.toString().length - 2)
+            .split(" ");
 
-	for(let i = 0; i < rows; i++) {
-		process.stdout.write("\n");
-	}
-}
+        console.log(moduleManager.modules[parts[1]].runningJobs);
+    }
+});

+ 93 - 56
backend/logic/actions/activities.js

@@ -1,62 +1,99 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
-const moduleManager = require("../../index");
+const hooks = require("./hooks");
 
-const db = moduleManager.modules["db"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const activities = moduleManager.modules["activities"];
+const db = require("../db");
+const utils = require("../utils");
+const activities = require("../activities");
+
+// const logger = moduleManager.modules["logger"];
 
 module.exports = {
-	/**
-	 * Gets a set of activities
-	 *
-	 * @param session
-	 * @param {String} userId - the user whose activities we are looking for
-	 * @param {Integer} set - the set number to return
-	 * @param cb
-	 */
-	getSet: (session, userId, set, cb) => {
-		async.waterfall([
-			next => {
-				db.models.activity.find({ userId, hidden: false }).skip(15 * (set - 1)).limit(15).sort("createdAt").exec(next);
-			},
-		], async (err, activities) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("ACTIVITIES_GET_SET", `Failed to get set ${set} from activities. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			}
-
-			logger.success("ACTIVITIES_GET_SET", `Set ${set} from activities obtained successfully.`);
-			cb({ status: "success", data: activities });
-		});
-	},
-
-	/**
-	 * Hides an activity for a user
-	 * 
-	 * @param session
-	 * @param {String} activityId - the activity which should be hidden
-	 * @param cb
-	 */
-	hideActivity: hooks.loginRequired((session, activityId, cb) => {
-		async.waterfall([
-			next => {
-				db.models.activity.updateOne({ _id: activityId }, { $set: { hidden: true } }, next);
-			}
-		], async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("ACTIVITIES_HIDE_ACTIVITY", `Failed to hide activity ${activityId}. "${err}"`);
-				return cb({ status: "failure", message: err });
-			}
-			
-			logger.success("ACTIVITIES_HIDE_ACTIVITY", `Successfully hid activity ${activityId}.`);
-			cb({ status: "success" })
-		});
-	})
+    /**
+     * Gets a set of activities
+     *
+     * @param session
+     * @param {String} userId - the user whose activities we are looking for
+     * @param {Integer} set - the set number to return
+     * @param cb
+     */
+    getSet: async (session, userId, set, cb) => {
+        const activityModel = await db.runJob("GET_MODEL", {
+            modelName: "activity",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    activityModel
+                        .find({ userId, hidden: false })
+                        .skip(15 * (set - 1))
+                        .limit(15)
+                        .sort("createdAt")
+                        .exec(next);
+                },
+            ],
+            async (err, activities) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "ACTIVITIES_GET_SET",
+                        `Failed to get set ${set} from activities. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+
+                console.log(
+                    "SUCCESS",
+                    "ACTIVITIES_GET_SET",
+                    `Set ${set} from activities obtained successfully.`
+                );
+                cb({ status: "success", data: activities });
+            }
+        );
+    },
+
+    /**
+     * Hides an activity for a user
+     *
+     * @param session
+     * @param {String} activityId - the activity which should be hidden
+     * @param cb
+     */
+    hideActivity: hooks.loginRequired(async (session, activityId, cb) => {
+        const activityModel = await db.runJob("GET_MODEL", {
+            modelName: "activity",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    activityModel.updateOne(
+                        { _id: activityId },
+                        { $set: { hidden: true } },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "ACTIVITIES_HIDE_ACTIVITY",
+                        `Failed to hide activity ${activityId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+
+                console.log(
+                    "SUCCESS",
+                    "ACTIVITIES_HIDE_ACTIVITY",
+                    `Successfully hid activity ${activityId}.`
+                );
+                cb({ status: "success" });
+            }
+        );
+    }),
 };

+ 188 - 134
backend/logic/actions/apis.js

@@ -1,152 +1,206 @@
-'use strict';
+"use strict";
 
 const request = require("request");
 const config = require("config");
 const async = require("async");
 
-const hooks = require('./hooks');
-const moduleManager = require("../../index");
+const hooks = require("./hooks");
+// const moduleManager = require("../../index");
 
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+const utils = require("../utils");
+// const logger = moduleManager.modules["logger"];
 
 module.exports = {
+    /**
+     * Fetches a list of songs from Youtubes API
+     *
+     * @param session
+     * @param query - the query we'll pass to youtubes api
+     * @param cb
+     * @return {{ status: String, data: Object }}
+     */
+    searchYoutube: (session, query, cb) => {
+        const params = [
+            "part=snippet",
+            `q=${encodeURIComponent(query)}`,
+            `key=${config.get("apis.youtube.key")}`,
+            "type=video",
+            "maxResults=15",
+        ].join("&");
 
-	/**
-	 * Fetches a list of songs from Youtubes API
-	 *
-	 * @param session
-	 * @param query - the query we'll pass to youtubes api
-	 * @param cb
-	 * @return {{ status: String, data: Object }}
-	 */
-	searchYoutube: (session, query, cb) => {
-		const params = [
-			'part=snippet',
-			`q=${encodeURIComponent(query)}`,
-			`key=${config.get('apis.youtube.key')}`,
-			'type=video',
-			'maxResults=15'
-		].join('&');
+        async.waterfall(
+            [
+                (next) => {
+                    request(
+                        `https://www.googleapis.com/youtube/v3/search?${params}`,
+                        next
+                    );
+                },
 
-		async.waterfall([
-			(next) => {
-				request(`https://www.googleapis.com/youtube/v3/search?${params}`, next);
-			},
+                (res, body, next) => {
+                    next(null, JSON.parse(body));
+                },
+            ],
+            async (err, data) => {
+                console.log(data.error);
+                if (err || data.error) {
+                    if (!err) err = data.error.message;
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "APIS_SEARCH_YOUTUBE",
+                        `Searching youtube failed with query "${query}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "APIS_SEARCH_YOUTUBE",
+                    `Searching YouTube successful with query "${query}".`
+                );
+                return cb({ status: "success", data });
+            }
+        );
+    },
 
-			(res, body, next) => {
-				next(null, JSON.parse(body));
-			}
-		], async (err, data) => {
-			console.log(data.error);
-			if (err || data.error) {
-				if (!err) err = data.error.message;
-				err = await utils.getError(err);
-				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success("APIS_SEARCH_YOUTUBE", `Searching YouTube successful with query "${query}".`);
-			return cb({ status: 'success', data });
-		});
-	},
+    /**
+     * Gets Spotify data
+     *
+     * @param session
+     * @param title - the title of the song
+     * @param artist - an artist for that song
+     * @param cb
+     */
+    getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    utils
+                        .runJob("GET_SONGS_FROM_SPOTIFY", { title, artist })
+                        .then((songs) => next(null, songs))
+                        .catch(next);
+                },
+            ],
+            (songs) => {
+                console.log(
+                    "SUCCESS",
+                    "APIS_GET_SPOTIFY_SONGS",
+                    `User "${session.userId}" got Spotify songs for title "${title}" successfully.`
+                );
+                cb({ status: "success", songs: songs });
+            }
+        );
+    }),
 
-	/**
-	 * Gets Spotify data
-	 *
-	 * @param session
-	 * @param title - the title of the song
-	 * @param artist - an artist for that song
-	 * @param cb
-	 */
-	getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
-		async.waterfall([
-			(next) => {
-				utils.getSongsFromSpotify(title, artist, next);
-			}
-		], (songs) => {
-			logger.success('APIS_GET_SPOTIFY_SONGS', `User "${session.userId}" got Spotify songs for title "${title}" successfully.`);
-			cb({status: 'success', songs: songs});
-		});
-	}),
+    /**
+     * Gets Discogs data
+     *
+     * @param session
+     * @param query - the query
+     * @param cb
+     */
+    searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    const params = [
+                        `q=${encodeURIComponent(query)}`,
+                        `per_page=20`,
+                        `page=${page}`,
+                    ].join("&");
 
-	/**
-	 * Gets Discogs data
-	 *
-	 * @param session
-	 * @param query - the query
-	 * @param cb
-	 */
-	searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
-		async.waterfall([
-			(next) => {
-				const params = [
-					`q=${encodeURIComponent(query)}`,
-					`per_page=20`,
-					`page=${page}`
-				].join('&');
-		
-				const options = {
-					url: `https://api.discogs.com/database/search?${params}`,
-					headers: {
-						"User-Agent": "Request",
-						"Authorization": `Discogs key=${config.get("apis.discogs.client")}, secret=${config.get("apis.discogs.secret")}`
-					}
-				};
-		
-				request(options, (err, res, body) => {
-					if (err) next(err);
-					body = JSON.parse(body);
-					next(null, body);
-					if (body.error) next(body.error);
-				});
-			}
-		], async (err, body) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("APIS_SEARCH_DISCOGS", `Searching discogs failed with query "${query}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success('APIS_SEARCH_DISCOGS', `User "${session.userId}" searched Discogs succesfully for query "${query}".`);
-			cb({status: 'success', results: body.results, pages: body.pagination.pages});
-		});
-	}),
+                    const options = {
+                        url: `https://api.discogs.com/database/search?${params}`,
+                        headers: {
+                            "User-Agent": "Request",
+                            Authorization: `Discogs key=${config.get(
+                                "apis.discogs.client"
+                            )}, secret=${config.get("apis.discogs.secret")}`,
+                        },
+                    };
 
-	/**
-	 * Joins a room
-	 *
-	 * @param session
-	 * @param page - the room to join
-	 * @param cb
-	 */
-	joinRoom: (session, page, cb) => {
-		if (page === 'home') {
-			utils.socketJoinRoom(session.socketId, page);
-		}
-		cb({});
-	},
+                    request(options, (err, res, body) => {
+                        if (err) next(err);
+                        body = JSON.parse(body);
+                        next(null, body);
+                        if (body.error) next(body.error);
+                    });
+                },
+            ],
+            async (err, body) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "APIS_SEARCH_DISCOGS",
+                        `Searching discogs failed with query "${query}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "APIS_SEARCH_DISCOGS",
+                    `User "${session.userId}" searched Discogs succesfully for query "${query}".`
+                );
+                cb({
+                    status: "success",
+                    results: body.results,
+                    pages: body.pagination.pages,
+                });
+            }
+        );
+    }),
 
-	/**
-	 * Joins an admin room
-	 *
-	 * @param session
-	 * @param page - the admin room to join
-	 * @param cb
-	 */
-	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users' || page === 'statistics' || page === 'punishments') {
-			utils.socketJoinRoom(session.socketId, `admin.${page}`);
-		}
-		cb({});
-	}),
+    /**
+     * Joins a room
+     *
+     * @param session
+     * @param page - the room to join
+     * @param cb
+     */
+    joinRoom: (session, page, cb) => {
+        if (page === "home") {
+            utils.runJob("SOCKET_JOIN_ROOM", {
+                socketId: session.socketId,
+                room: page,
+            });
+        }
+        cb({});
+    },
 
-	/**
-	 * Returns current date
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	ping: (session, cb) => {
-		cb({date: Date.now()});
-	}
+    /**
+     * Joins an admin room
+     *
+     * @param session
+     * @param page - the admin room to join
+     * @param cb
+     */
+    joinAdminRoom: hooks.adminRequired((session, page, cb) => {
+        if (
+            page === "queue" ||
+            page === "songs" ||
+            page === "stations" ||
+            page === "reports" ||
+            page === "news" ||
+            page === "users" ||
+            page === "statistics" ||
+            page === "punishments"
+        ) {
+            utils.runJob("SOCKET_JOIN_ROOM", {
+                socketId: session.socketId,
+                room: `admin.${page}`,
+            });
+        }
+        cb({});
+    }),
 
+    /**
+     * Returns current date
+     *
+     * @param session
+     * @param cb
+     */
+    ping: (session, cb) => {
+        cb({ date: Date.now() });
+    },
 };

+ 54 - 36
backend/logic/actions/hooks/adminRequired.js

@@ -1,39 +1,57 @@
-const async = require('async');
+const async = require("async");
 
-const moduleManager = require("../../../index");
-
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+const db = require("../../db");
+const cache = require("../../cache");
+const utils = require("../../utils");
 
 module.exports = function(next) {
-	return function(session) {
-		let args = [];
-		for (let prop in arguments) args.push(arguments[prop]);
-		let cb = args[args.length - 1];
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-			(session, next) => {
-				if (!session || !session.userId) return next('Login required.');
-				this.session = session;
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-			(user, next) => {
-				if (!user) return next('Login required.');
-				if (user.role !== 'admin') return next('Insufficient permissions.');
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.info("ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.info("ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
-			next.apply(null, args);
-		});
-	}
-};
+    return async function(session) {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        let args = [];
+        for (let prop in arguments) args.push(arguments[prop]);
+        let cb = args[args.length - 1];
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+                (session, next) => {
+                    if (!session || !session.userId)
+                        return next("Login required.");
+                    this.session = session;
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+                (user, next) => {
+                    if (!user) return next("Login required.");
+                    if (user.role !== "admin")
+                        return next("Insufficient permissions.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "INFO",
+                        "ADMIN_REQUIRED",
+                        `User failed to pass admin required check. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "INFO",
+                    "ADMIN_REQUIRED",
+                    `User "${session.userId}" passed admin required check.`,
+                    false
+                );
+                next.apply(null, args);
+            }
+        );
+    };
+};

+ 46 - 29
backend/logic/actions/hooks/loginRequired.js

@@ -1,33 +1,50 @@
-const async = require('async');
+const async = require("async");
 
-const moduleManager = require("../../../index");
+const cache = require("../../cache");
+const utils = require("../../utils");
+// const logger = moduleManager.modules["logger"];
 
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+console.log(cache);
 
 module.exports = function(next) {
-	return function(session) {
-		let args = [];
-		for (let prop in arguments) args.push(arguments[prop]);
-		let cb = args[args.length - 1];
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-			(session, next) => {
-				if (!session || !session.userId) return next('Login required.');
-				this.session = session;
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.info("LOGIN_REQUIRED", `User failed to pass login required check.`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.info("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`, false);
-			next.apply(null, args);
-		});
-	}
-};
+    return function(session) {
+        let args = [];
+        for (let prop in arguments) args.push(arguments[prop]);
+        let cb = args[args.length - 1];
+        async.waterfall(
+            [
+                next => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId
+                        })
+                        .then(session => next(null, session))
+                        .catch(next);
+                },
+                (session, next) => {
+                    if (!session || !session.userId)
+                        return next("Login required.");
+                    this.session = session;
+                    next();
+                }
+            ],
+            async err => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "LOGIN_REQUIRED",
+                        `User failed to pass login required check.`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "LOGIN_REQUIRED",
+                    `User "${session.userId}" passed login required check.`,
+                    false
+                );
+                next.apply(null, args);
+            }
+        );
+    };
+};

+ 63 - 40
backend/logic/actions/hooks/ownerRequired.js

@@ -1,45 +1,68 @@
-const async = require('async');
+const async = require("async");
 
 const moduleManager = require("../../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const stations = moduleManager.modules["stations"];
+const db = require("../../db");
+const cache = require("../../cache");
+const utils = require("../../utils");
+const stations = require("../../stations");
 
 module.exports = function(next) {
-	return function(session, stationId) {
-		let args = [];
-		for (let prop in arguments) args.push(arguments[prop]);
-		let cb = args[args.length - 1];
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-			(session, next) => {
-				if (!session || !session.userId) return next('Login required.');
-				this.session = session;
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-			(user, next) => {
-				if (!user) return next('Login required.');
-				if (user.role === 'admin') return next(true);
-				stations.getStation(stationId, next);
-			},
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type === 'community' && station.owner === session.userId) return next(true);
-				next('Invalid permissions.');
-			}
-		], async (err) => {
-			if (err !== true) {
-				err = await utils.getError(err);
-				logger.info("OWNER_REQUIRED", `User failed to pass owner required check for station "${stationId}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`, false);
-			next.apply(null, args);
-		});
-	}
-};
+    return async function(session, stationId) {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        let args = [];
+        for (let prop in arguments) args.push(arguments[prop]);
+        let cb = args[args.length - 1];
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+                (session, next) => {
+                    if (!session || !session.userId)
+                        return next("Login required.");
+                    this.session = session;
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+                (user, next) => {
+                    if (!user) return next("Login required.");
+                    if (user.role === "admin") return next(true);
+                    stations.getStation(stationId, next);
+                },
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    if (
+                        station.type === "community" &&
+                        station.owner === session.userId
+                    )
+                        return next(true);
+                    next("Invalid permissions.");
+                },
+            ],
+            async (err) => {
+                if (err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "INFO",
+                        "OWNER_REQUIRED",
+                        `User failed to pass owner required check for station "${stationId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "INFO",
+                    "OWNER_REQUIRED",
+                    `User "${session.userId}" passed owner required check for station "${stationId}"`,
+                    false
+                );
+                next.apply(null, args);
+            }
+        );
+    };
+};

+ 226 - 139
backend/logic/actions/news.js

@@ -1,155 +1,242 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+const db = require("../db");
+const cache = require("../cache");
+const utils = require("../utils");
+// const logger = require("logger");
 
-cache.sub('news.create', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.created', news);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "news.create",
+    cb: (news) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: news.createdBy,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:admin.news.created", news);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('news.remove', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.removed', news);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "news.remove",
+    cb: (news) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: news.createdBy,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:admin.news.removed", news);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('news.update', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.updated', news);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "news.update",
+    cb: (news) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: news.createdBy,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:admin.news.updated", news);
+                });
+            },
+        });
+    },
 });
 
 module.exports = {
+    /**
+     * Gets all news items
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: async (session, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        async.waterfall(
+            [
+                (next) => {
+                    newsModel
+                        .find({})
+                        .sort({ createdAt: "desc" })
+                        .exec(next);
+                },
+            ],
+            async (err, news) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "NEWS_INDEX",
+                        `Indexing news failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "NEWS_INDEX",
+                    `Indexing news successful.`,
+                    false
+                );
+                return cb({ status: "success", data: news });
+            }
+        );
+    },
 
-	/**
-	 * Gets all news items
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
-			}
-		], async (err, news) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success("NEWS_INDEX", `Indexing news successful.`, false);
-			return cb({ status: 'success', data: news });
-		});
-	},
+    /**
+     * Creates a news item
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} data - the object of the news data
+     * @param {Function} cb - gets called with the result
+     */
+    create: hooks.adminRequired(async (session, data, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        async.waterfall(
+            [
+                (next) => {
+                    data.createdBy = session.userId;
+                    data.createdAt = Date.now();
+                    newsModel.create(data, next);
+                },
+            ],
+            async (err, news) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "NEWS_CREATE",
+                        `Creating news failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", { channel: "news.create", value: news });
+                console.log(
+                    "SUCCESS",
+                    "NEWS_CREATE",
+                    `Creating news successful.`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully created News",
+                });
+            }
+        );
+    }),
 
-	/**
-	 * Creates a news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the object of the news data
-	 * @param {Function} cb - gets called with the result
-	 */
-	create: hooks.adminRequired((session, data, cb) => {
-		async.waterfall([
-			(next) => {
-				data.createdBy = session.userId;
-				data.createdAt = Date.now();
-				db.models.news.create(data, next);
-			}
-		], async (err, news) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_CREATE", `Creating news failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			cache.pub('news.create', news);
-			logger.success("NEWS_CREATE", `Creating news successful.`);
-			return cb({ 'status': 'success', 'message': 'Successfully created News' });
-		});
-	}),
+    /**
+     * Gets the latest news item
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    newest: async (session, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        async.waterfall(
+            [
+                (next) => {
+                    newsModel
+                        .findOne({})
+                        .sort({ createdAt: "desc" })
+                        .exec(next);
+                },
+            ],
+            async (err, news) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "NEWS_NEWEST",
+                        `Getting the latest news failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "NEWS_NEWEST",
+                    `Successfully got the latest news.`,
+                    false
+                );
+                return cb({ status: "success", data: news });
+            }
+        );
+    },
 
-	/**
-	 * Gets the latest news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	newest: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
-			}
-		], async (err, news) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			logger.success("NEWS_NEWEST", `Successfully got the latest news.`, false);
-			return cb({ status: 'success', data: news });
-		});
-	},
+    /**
+     * Removes a news item
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} news - the news object
+     * @param {Function} cb - gets called with the result
+     */
+    //TODO Pass in an id, not an object
+    //TODO Fix this
+    remove: hooks.adminRequired(async (session, news, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        newsModel.deleteOne({ _id: news._id }, async (err) => {
+            if (err) {
+                err = await utils.runJob("GET_ERROR", { error: err });
+                console.log(
+                    "ERROR",
+                    "NEWS_REMOVE",
+                    `Removing news "${news._id}" failed for user "${session.userId}". "${err}"`
+                );
+                return cb({ status: "failure", message: err });
+            } else {
+                cache.runJob("PUB", { channel: "news.remove", value: news });
+                console.log(
+                    "SUCCESS",
+                    "NEWS_REMOVE",
+                    `Removing news "${news._id}" successful by user "${session.userId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully removed News",
+                });
+            }
+        });
+    }),
 
-	/**
-	 * Removes a news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} news - the news object
-	 * @param {Function} cb - gets called with the result
-	 */
-	//TODO Pass in an id, not an object
-	//TODO Fix this
-	remove: hooks.adminRequired((session, news, cb) => {
-		db.models.news.deleteOne({ _id: news._id }, async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			} else {
-				cache.pub('news.remove', news);
-				logger.success("NEWS_REMOVE", `Removing news "${news._id}" successful by user "${session.userId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully removed News' });
-			}
-		});
-	}),
-
-	/**
-	 * Removes a news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} _id - the news id
-	 * @param {Object} news - the news object
-	 * @param {Function} cb - gets called with the result
-	 */
-	//TODO Fix this
-	update: hooks.adminRequired((session, _id, news, cb) => {
-		db.models.news.updateOne({ _id }, news, { upsert: true }, async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			} else {
-				cache.pub('news.update', news);
-				logger.success("NEWS_UPDATE", `Updating news "${_id}" successful for user "${session.userId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
-			}
-		});
-	}),
-
-};
+    /**
+     * Removes a news item
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} _id - the news id
+     * @param {Object} news - the news object
+     * @param {Function} cb - gets called with the result
+     */
+    //TODO Fix this
+    update: hooks.adminRequired(async (session, _id, news, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        newsModel.updateOne({ _id }, news, { upsert: true }, async (err) => {
+            if (err) {
+                err = await utils.runJob("GET_ERROR", { error: err });
+                console.log(
+                    "ERROR",
+                    "NEWS_UPDATE",
+                    `Updating news "${_id}" failed for user "${session.userId}". "${err}"`
+                );
+                return cb({ status: "failure", message: err });
+            } else {
+                cache.runJob("PUB", { channel: "news.update", value: news });
+                console.log(
+                    "SUCCESS",
+                    "NEWS_UPDATE",
+                    `Updating news "${_id}" successful for user "${session.userId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully updated News",
+                });
+            }
+        });
+    }),
+};

+ 1147 - 658
backend/logic/actions/playlists.js

@@ -1,682 +1,1171 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const playlists = moduleManager.modules["playlists"];
-const songs = moduleManager.modules["songs"];
-const activities = moduleManager.modules["activities"];
-
-cache.sub('playlist.create', playlistId => {
-	playlists.getPlaylist(playlistId, (err, playlist) => {
-		if (!err) {
-			utils.socketsFromUser(playlist.createdBy, (sockets) => {
-				sockets.forEach(socket => {
-					socket.emit('event:playlist.create', playlist);
-				});
-			});
-		}
-	});
+const db = require("../db");
+const cache = require("../cache");
+const utils = require("../utils");
+const playlists = require("../playlists");
+const songs = require("../songs");
+const activities = require("../activities");
+
+cache.runJob("SUB", {
+    channel: "playlist.create",
+    cb: (playlistId) => {
+        playlists.runJob("GET_PLAYLIST", { playlistId }).then((playlist) => {
+            utils
+                .runJob("SOCKETS_FROM_USER", { userId: playlist.createdBy })
+                .then((sockets) => {
+                    sockets.forEach((socket) => {
+                        socket.emit("event:playlist.create", playlist);
+                    });
+                });
+        });
+    },
 });
 
-cache.sub('playlist.delete', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.delete', res.playlistId);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.delete",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:playlist.delete", res.playlistId);
+                });
+            });
+    },
 });
 
-cache.sub('playlist.moveSongToTop', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.moveSongToTop', {playlistId: res.playlistId, songId: res.songId});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.moveSongToTop",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:playlist.moveSongToTop", {
+                        playlistId: res.playlistId,
+                        songId: res.songId,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.moveSongToBottom', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.moveSongToBottom', {playlistId: res.playlistId, songId: res.songId});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.moveSongToBottom",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:playlist.moveSongToBottom", {
+                        playlistId: res.playlistId,
+                        songId: res.songId,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.addSong', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.addSong', { playlistId: res.playlistId, song: res.song });
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.addSong",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:playlist.addSong", {
+                        playlistId: res.playlistId,
+                        song: res.song,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.removeSong', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.removeSong', { playlistId: res.playlistId, songId: res.songId });
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.removeSong",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:playlist.removeSong", {
+                        playlistId: res.playlistId,
+                        songId: res.songId,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.updateDisplayName', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.updateDisplayName', { playlistId: res.playlistId, displayName: res.displayName });
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.updateDisplayName",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:playlist.updateDisplayName", {
+                        playlistId: res.playlistId,
+                        displayName: res.displayName,
+                    });
+                });
+            });
+    },
 });
 
 let lib = {
+    /**
+     * Gets the first song from a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are getting the first song from
+     * @param {Function} cb - gets called with the result
+     */
+    getFirstSong: hooks.loginRequired((session, playlistId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("GET_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (playlist, next) => {
+                    if (!playlist || playlist.createdBy !== session.userId)
+                        return next("Playlist not found.");
+                    next(null, playlist.songs[0]);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_GET_FIRST_SONG",
+                        `Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_GET_FIRST_SONG",
+                    `Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    song: song,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Gets all playlists for the user requesting it
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    indexForUser: hooks.loginRequired(async (session, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    playlistModel.find({ createdBy: session.userId }, next);
+                },
+            ],
+            async (err, playlists) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_INDEX_FOR_USER",
+                        `Indexing playlists for user "${session.userId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_INDEX_FOR_USER",
+                    `Successfully indexed playlists for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    data: playlists,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Creates a new private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} data - the data for the new private playlist
+     * @param {Function} cb - gets called with the result
+     */
+    create: hooks.loginRequired(async (session, data, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    return data
+                        ? next()
+                        : cb({ status: "failure", message: "Invalid data" });
+                },
+
+                (next) => {
+                    const { displayName, songs } = data;
+                    playlistModel.create(
+                        {
+                            displayName,
+                            songs,
+                            createdBy: session.userId,
+                            createdAt: Date.now(),
+                        },
+                        next
+                    );
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_CREATE",
+                        `Creating private playlist failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", {
+                    channel: "playlist.create",
+                    value: playlist._id,
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "created_playlist",
+                    payload: [playlist._id],
+                });
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_CREATE",
+                    `Successfully created private playlist for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    message: "Successfully created playlist",
+                    data: {
+                        _id: playlist._id,
+                    },
+                });
+            }
+        );
+    }),
+
+    /**
+     * Gets a playlist from id
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are getting
+     * @param {Function} cb - gets called with the result
+     */
+    getPlaylist: hooks.loginRequired((session, playlistId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("GET_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (playlist, next) => {
+                    if (!playlist || playlist.createdBy !== session.userId)
+                        return next("Playlist not found");
+                    next(null, playlist);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_GET",
+                        `Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_GET",
+                    `Successfully got private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    data: playlist,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Obtains basic metadata of a playlist in order to format an activity
+     *
+     * @param session
+     * @param playlistId - the playlist id
+     * @param cb
+     */
+    getPlaylistForActivity: (session, playlistId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("GET_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
+                        `Failed to obtain metadata of playlist ${playlistId} for activity formatting. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
+                        `Obtained metadata of playlist ${playlistId} for activity formatting successfully.`
+                    );
+                    cb({
+                        status: "success",
+                        data: {
+                            title: playlist.displayName,
+                        },
+                    });
+                }
+            }
+        );
+    },
+
+    //TODO Remove this
+    /**
+     * Updates a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are updating
+     * @param {Object} playlist - the new private playlist object
+     * @param {Function} cb - gets called with the result
+     */
+    update: hooks.loginRequired(async (session, playlistId, playlist, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    playlistModel.updateOne(
+                        { _id: playlistId, createdBy: session.userId },
+                        playlist,
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    playlists
+                        .runJob("UPDATE_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_UPDATE",
+                        `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_UPDATE",
+                    `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    data: playlist,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Updates a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are updating
+     * @param {Function} cb - gets called with the result
+     */
+    shuffle: hooks.loginRequired(async (session, playlistId, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!playlistId) return next("No playlist id.");
+                    playlistModel.findById(playlistId, next);
+                },
+
+                (playlist, next) => {
+                    utils
+                        .runJob("SHUFFLE", { array: playlist.songs })
+                        .then((songs) => next(null, songs))
+                        .catch(next);
+                },
+
+                (songs, next) => {
+                    playlistModel.updateOne(
+                        { _id: playlistId },
+                        { $set: { songs } },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    playlists
+                        .runJob("UPDATE_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_SHUFFLE",
+                        `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_SHUFFLE",
+                    `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    message: "Successfully shuffled playlist.",
+                    data: playlist,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds a song to a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Boolean} isSet - is the song part of a set of songs to be added
+     * @param {String} songId - the id of the song we are trying to add
+     * @param {String} playlistId - the id of the playlist we are adding the song to
+     * @param {Function} cb - gets called with the result
+     */
+    addSongToPlaylist: hooks.loginRequired(
+        async (session, isSet, songId, playlistId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => {
+                                if (
+                                    !playlist ||
+                                    playlist.createdBy !== session.userId
+                                )
+                                    return next(
+                                        "Something went wrong when trying to get the playlist"
+                                    );
+
+                                async.each(
+                                    playlist.songs,
+                                    (song, next) => {
+                                        if (song.songId === songId)
+                                            return next(
+                                                "That song is already in the playlist"
+                                            );
+                                        next();
+                                    },
+                                    next
+                                );
+                            })
+                            .catch(next);
+                    },
+                    (next) => {
+                        songs
+                            .runJob("GET_SONG", { songId })
+                            .then((song) => {
+                                next(null, {
+                                    _id: song._id,
+                                    songId: songId,
+                                    title: song.title,
+                                    duration: song.duration,
+                                });
+                            })
+                            .catch(() => {
+                                utils
+                                    .runJob("GET_SONG_FROM_YOUTUBE", { songId })
+                                    .then((song) => next(null, song))
+                                    .catch(next);
+                            });
+                    },
+                    (newSong, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $push: { songs: newSong } },
+                            { runValidators: true },
+                            (err) => {
+                                if (err) return next(err);
+                                playlists
+                                    .runJob("UPDATE_PLAYLIST", { playlistId })
+                                    .then((playlist) =>
+                                        next(null, playlist, newSong)
+                                    )
+                                    .catch(next);
+                            }
+                        );
+                    },
+                ],
+                async (err, playlist, newSong) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_ADD_SONG",
+                            `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "PLAYLIST_ADD_SONG",
+                            `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${session.userId}".`
+                        );
+                        if (!isSet)
+                            activities.runJob("ADD_ACTIVITY", {
+                                userId: session.userId,
+                                activityType: "added_song_to_playlist",
+                                payload: [{ songId, playlistId }],
+                            });
+
+                        cache.runJob("PUB", {
+                            channel: "playlist.addSong",
+                            value: {
+                                playlistId: playlist._id,
+                                song: newSong,
+                                userId: session.userId,
+                            },
+                        });
+                        return cb({
+                            status: "success",
+                            message:
+                                "Song has been successfully added to the playlist",
+                            data: playlist.songs,
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Adds a set of songs to a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} url - the url of the the YouTube playlist
+     * @param {String} playlistId - the id of the playlist we are adding the set of songs to
+     * @param {Boolean} musicOnly - whether to only add music to the playlist
+     * @param {Function} cb - gets called with the result
+     */
+    addSetToPlaylist: hooks.loginRequired(
+        (session, url, playlistId, musicOnly, cb) => {
+            let videosInPlaylistTotal = 0;
+            let songsInPlaylistTotal = 0;
+            let songsSuccess = 0;
+            let songsFail = 0;
+
+            let addedSongs = [];
+
+            async.waterfall(
+                [
+                    (next) => {
+                        utils
+                            .runJob("GET_PLAYLIST_FROM_YOUTUBE", {
+                                url,
+                                musicOnly,
+                            })
+                            .then(
+                                (songIds,
+                                (otherSongIds) => {
+                                    if (otherSongIds) {
+                                        videosInPlaylistTotal = songIds.length;
+                                        songsInPlaylistTotal =
+                                            otherSongIds.length;
+                                    } else {
+                                        songsInPlaylistTotal = videosInPlaylistTotal =
+                                            songIds.length;
+                                    }
+                                    next(null, songIds);
+                                })
+                            );
+                    },
+                    (songIds, next) => {
+                        let processed = 0;
+                        function checkDone() {
+                            if (processed === songIds.length) next();
+                        }
+                        for (let s = 0; s < songIds.length; s++) {
+                            lib.addSongToPlaylist(
+                                session,
+                                true,
+                                songIds[s],
+                                playlistId,
+                                (res) => {
+                                    processed++;
+                                    if (res.status === "success") {
+                                        addedSongs.push(songIds[s]);
+                                        songsSuccess++;
+                                    } else songsFail++;
+                                    checkDone();
+                                }
+                            );
+                        }
+                    },
+
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found.");
+                        next(null, playlist);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_IMPORT",
+                            `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    } else {
+                        activities.runJob("ADD_ACTIVITY", {
+                            userId: session.userId,
+                            activityType: "added_songs_to_playlist",
+                            payload: addedSongs,
+                        });
+                        console.log(
+                            "SUCCESS",
+                            "PLAYLIST_IMPORT",
+                            `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${songsSuccess}, songs failed: ${songsFail}.`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Playlist has been successfully imported.",
+                            data: playlist.songs,
+                            stats: {
+                                videosInPlaylistTotal,
+                                songsInPlaylistTotal,
+                                songsAddedSuccessfully: songsSuccess,
+                                songsFailedToAdd: songsFail,
+                            },
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Removes a song from a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} songId - the id of the song we are removing from the private playlist
+     * @param {String} playlistId - the id of the playlist we are removing the song from
+     * @param {Function} cb - gets called with the result
+     */
+    removeSongFromPlaylist: hooks.loginRequired(
+        async (session, songId, playlistId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!songId || typeof songId !== "string")
+                            return next("Invalid song id.");
+                        if (!playlistId || typeof playlistId !== "string")
+                            return next("Invalid playlist id.");
+                        next();
+                    },
+
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found");
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $pull: { songs: { songId: songId } } },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_REMOVE_SONG",
+                            `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "PLAYLIST_REMOVE_SONG",
+                            `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${session.userId}".`
+                        );
+                        cache.runJob("PUB", {
+                            channel: "playlist.removeSong",
+                            value: {
+                                playlistId: playlist._id,
+                                songId: songId,
+                                userId: session.userId,
+                            },
+                        });
+                        return cb({
+                            status: "success",
+                            message:
+                                "Song has been successfully removed from playlist",
+                            data: playlist.songs,
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates the displayName of a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are updating the displayName for
+     * @param {Function} cb - gets called with the result
+     */
+    updateDisplayName: hooks.loginRequired(
+        async (session, playlistId, displayName, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId, createdBy: session.userId },
+                            { $set: { displayName } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_UPDATE_DISPLAY_NAME",
+                            `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLIST_UPDATE_DISPLAY_NAME",
+                        `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "playlist.updateDisplayName",
+                        value: {
+                            playlistId: playlistId,
+                            displayName: displayName,
+                            userId: session.userId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Playlist has been successfully updated",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Moves a song to the top of the list in a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are moving the song to the top from
+     * @param {String} songId - the id of the song we are moving to the top of the list
+     * @param {Function} cb - gets called with the result
+     */
+    moveSongToTop: hooks.loginRequired(
+        async (session, playlistId, songId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found");
+                        async.each(
+                            playlist.songs,
+                            (song, next) => {
+                                if (song.songId === songId) return next(song);
+                                next();
+                            },
+                            (err) => {
+                                if (err && err.songId) return next(null, err);
+                                next("Song not found");
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $pull: { songs: { songId } } },
+                            (err) => {
+                                if (err) return next(err);
+                                return next(null, song);
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            {
+                                $push: {
+                                    songs: {
+                                        $each: [song],
+                                        $position: 0,
+                                    },
+                                },
+                            },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_MOVE_SONG_TO_TOP",
+                            `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLIST_MOVE_SONG_TO_TOP",
+                        `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "playlist.moveSongToTop",
+                        value: {
+                            playlistId,
+                            songId,
+                            userId: session.userId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Playlist has been successfully updated",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Moves a song to the bottom of the list in a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are moving the song to the bottom from
+     * @param {String} songId - the id of the song we are moving to the bottom of the list
+     * @param {Function} cb - gets called with the result
+     */
+    moveSongToBottom: hooks.loginRequired(
+        async (session, playlistId, songId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found");
+                        async.each(
+                            playlist.songs,
+                            (song, next) => {
+                                if (song.songId === songId) return next(song);
+                                next();
+                            },
+                            (err) => {
+                                if (err && err.songId) return next(null, err);
+                                next("Song not found");
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $pull: { songs: { songId } } },
+                            (err) => {
+                                if (err) return next(err);
+                                return next(null, song);
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            {
+                                $push: {
+                                    songs: song,
+                                },
+                            },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_MOVE_SONG_TO_BOTTOM",
+                            `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLIST_MOVE_SONG_TO_BOTTOM",
+                        `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "playlist.moveSongToBottom",
+                        value: {
+                            playlistId,
+                            songId,
+                            userId: session.userId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Playlist has been successfully updated",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Removes a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} playlistId - the id of the playlist we are moving the song to the top from
+     * @param {Function} cb - gets called with the result
+     */
+    remove: hooks.loginRequired(async (session, playlistId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("DELETE_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (next) => {
+                    stationModel.find({ privatePlaylist: playlistId }, next);
+                },
 
-	/**
-	 * Gets the first song from a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are getting the first song from
-	 * @param {Function} cb - gets called with the result
-	 */
-	getFirstSong: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found.');
-				next(null, playlist.songs[0]);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_GET_FIRST_SONG", `Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				song: song
-			});
-		});
-	}),
-
-	/**
-	 * Gets all playlists for the user requesting it
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	indexForUser: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.find({ createdBy: session.userId }, next);
-			}
-		], async (err, playlists) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${session.userId}" failed. "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				data: playlists
-			});
-		});
-	}),
-
-	/**
-	 * Creates a new private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the data for the new private playlist
-	 * @param {Function} cb - gets called with the result
-	 */
-	create: hooks.loginRequired((session, data, cb) => {
-		async.waterfall([
-
-			(next) => {
-				return (data) ? next() : cb({ 'status': 'failure', 'message': 'Invalid data' });
-			},
-
-			(next) => {
-				const { displayName, songs } = data;
-				db.models.playlist.create({
-					displayName,
-					songs,
-					createdBy: session.userId,
-					createdAt: Date.now()
-				}, next);
-			}
-
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			cache.pub('playlist.create', playlist._id);
-			activities.addActivity(session.userId, "created_playlist", [ playlist._id ]);
-			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${session.userId}".`);
-			cb({ status: 'success', message: 'Successfully created playlist', data: {
-				_id: playlist._id
-			} });
-		});
-	}),
-
-	/**
-	 * Gets a playlist from id
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are getting
-	 * @param {Function} cb - gets called with the result
-	 */
-	getPlaylist: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				next(null, playlist);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_GET", `Successfully got private playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				data: playlist
-			});
-		});
-	}),
-
-	/**
-	 * Obtains basic metadata of a playlist in order to format an activity
-	 *
-	 * @param session
-	 * @param playlistId - the playlist id
-	 * @param cb
-	 */
-	getPlaylistForActivity: (session, playlistId, cb) => {
-		async.waterfall([
-
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			}
-
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY", `Failed to obtain metadata of playlist ${playlistId} for activity formatting. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY", `Obtained metadata of playlist ${playlistId} for activity formatting successfully.`);
-				cb({ status: "success", data: {
-					title: playlist.displayName
-				} });
-			}
-		});
-	},
-
-	//TODO Remove this
-	/**
-	 * Updates a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are updating
-	 * @param {Object} playlist - the new private playlist object
-	 * @param {Function} cb - gets called with the result
-	 */
-	update: hooks.loginRequired((session, playlistId, playlist, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, playlist, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next)
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_UPDATE", `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				data: playlist
-			});
-		});
-	}),
-
-	/**
-	 * Updates a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are updating
-	 * @param {Function} cb - gets called with the result
-	 */
-	shuffle: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!playlistId) return next("No playlist id.");
-				db.models.playlist.findById(playlistId, next);
-			},
-
-			(playlist, next) => {
-				utils.shuffle(playlist.songs).then(songs => {
-					next(null, songs);
-				}).catch(next);
-			},
-
-			(songs, next) => {
-				db.models.playlist.updateOne({ _id: playlistId }, { $set: { songs } }, { runValidators: true }, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next)
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_SHUFFLE", `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_SHUFFLE", `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				message: "Successfully shuffled playlist.",
-				data: playlist
-			});
-		});
-	}),
-
-	/**
-	 * Adds a song to a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Boolean} isSet - is the song part of a set of songs to be added
-	 * @param {String} songId - the id of the song we are trying to add
-	 * @param {String} playlistId - the id of the playlist we are adding the song to
-	 * @param {Function} cb - gets called with the result
-	 */
-	addSongToPlaylist: hooks.loginRequired((session, isSet, songId, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, (err, playlist) => {
-					if (err || !playlist || playlist.createdBy !== session.userId) return next('Something went wrong when trying to get the playlist');
-
-					async.each(playlist.songs, (song, next) => {
-						if (song.songId === songId) return next('That song is already in the playlist');
-						next();
-					}, next);
-				});
-			},
-			(next) => {
-				songs.getSong(songId, (err, song) => {
-					if (err) {
-						utils.getSongFromYouTube(songId, (song) => {
-							next(null, song);
-						});
-					} else {
-						next(null, {
-							_id: song._id,
-							songId: songId,
-							title: song.title,
-							duration: song.duration
-						});
-					}
-				});
-			},
-			(newSong, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
-					if (err) return next(err);
-					playlists.updatePlaylist(playlistId, (err, playlist) => {
-						next(err, playlist, newSong);
-					});
-				});
-			}
-		],
-		async (err, playlist, newSong) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${session.userId}".`);
-				if (!isSet) activities.addActivity(session.userId, "added_song_to_playlist", [ { songId, playlistId } ]);
-				cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: session.userId });
-				return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
-			}
-		});
-	}),
-
-	/**
-	 * Adds a set of songs to a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} url - the url of the the YouTube playlist
-	 * @param {String} playlistId - the id of the playlist we are adding the set of songs to
-	 * @param {Boolean} musicOnly - whether to only add music to the playlist
-	 * @param {Function} cb - gets called with the result
-	 */
-	addSetToPlaylist: hooks.loginRequired((session, url, playlistId, musicOnly, cb) => {
-		let videosInPlaylistTotal = 0;
-		let songsInPlaylistTotal = 0;
-		let songsSuccess = 0;
-		let songsFail = 0;
-
-		let addedSongs = [];
-
-		async.waterfall([
-			(next) => {
-				utils.getPlaylistFromYouTube(url, musicOnly, (songIds, otherSongIds) => {
-					if (otherSongIds) {
-						videosInPlaylistTotal = songIds.length;
-						songsInPlaylistTotal = otherSongIds.length;
-					} else {
-						songsInPlaylistTotal = videosInPlaylistTotal = songIds.length;
-					}
-					next(null, songIds);
-				});
-			},
-			(songIds, next) => {
-				let processed = 0;
-				function checkDone() {
-					if (processed === songIds.length) next();
-				}
-				for (let s = 0; s < songIds.length; s++) {
-					lib.addSongToPlaylist(session, true, songIds[s], playlistId, res => {
-						processed++;
-						if (res.status === "success") {
-							addedSongs.push(songIds[s]);
-							songsSuccess++;
-						} else songsFail++;
-						checkDone();
-					});
-				}
-			},
-			
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found.');
-				next(null, playlist);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				activities.addActivity(session.userId, "added_songs_to_playlist", addedSongs);
-				logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${songsSuccess}, songs failed: ${songsFail}.`);
-				cb({
-					status: 'success',
-					message: 'Playlist has been successfully imported.',
-					data: playlist.songs,
-					stats: {
-						videosInPlaylistTotal,
-						songsInPlaylistTotal,
-						songsAddedSuccessfully: songsSuccess,
-						songsFailedToAdd: songsFail
-					}
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Removes a song from a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song we are removing from the private playlist
-	 * @param {String} playlistId - the id of the playlist we are removing the song from
-	 * @param {Function} cb - gets called with the result
-	 */
-	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!songId || typeof songId !== 'string') return next('Invalid song id.');
-				if (!playlistId  || typeof playlistId !== 'string') return next('Invalid playlist id.');
-				next();
-			},
-
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${session.userId}".`);
-				cache.pub('playlist.removeSong', { playlistId: playlist._id, songId: songId, userId: session.userId });
-				return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
-			}
-		});
-	}),
-
-	/**
-	 * Updates the displayName of a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are updating the displayName for
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, { $set: { displayName } }, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_UPDATE_DISPLAY_NAME", `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: session.userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
-	}),
-
-	/**
-	 * Moves a song to the top of the list in a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
-	 * @param {String} songId - the id of the song we are moving to the top of the list
-	 * @param {Function} cb - gets called with the result
-	 */
-	moveSongToTop: hooks.loginRequired((session, playlistId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				async.each(playlist.songs, (song, next) => {
-					if (song.songId === songId) return next(song);
-					next();
-				}, (err) => {
-					if (err && err.songId) return next(null, err);
-					next('Song not found');
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {
-					$push: {
-						songs: {
-							$each: [song],
-							$position: 0
-						}
-					}
-				}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_TOP", `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: session.userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
-	}),
-
-	/**
-	 * Moves a song to the bottom of the list in a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are moving the song to the bottom from
-	 * @param {String} songId - the id of the song we are moving to the bottom of the list
-	 * @param {Function} cb - gets called with the result
-	 */
-	moveSongToBottom: hooks.loginRequired((session, playlistId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				async.each(playlist.songs, (song, next) => {
-					if (song.songId === songId) return next(song);
-					next();
-				}, (err) => {
-					if (err && err.songId) return next(null, err);
-					next('Song not found');
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {
-					$push: {
-						songs: song
-					}
-				}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: session.userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
-	}),
-
-	/**
-	 * Removes a private playlist
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
-	 * @param {Function} cb - gets called with the result
-	 */
-	remove: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.deletePlaylist(playlistId, next);
-			},
-
-			(next) => {
-				db.models.station.find({ privatePlaylist: playlistId }, next);
-			},
-
-			(stations, next) => {
-				async.each(
-					stations,
-					(station, next) => {
-						async.waterfall([
-							(next) => {
-								db.models.station.updateOne({_id: station._id}, {$set: {privatePlaylist: null}}, {runValidators: true}, next);
-							},
-
-							(res, next) => {
-								if (!station.partyMode) {
-									moduleManager.modules["stations"].updateStation(station._id, next);
-									cache.pub('privatePlaylist.selected', { playlistId: null, stationId: station._id });
-								} else next();
-							}
-						], (err) => {
-							next();
-						}
-						);
-					},
-					(err) => {
-						next();
-					}
-				);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_REMOVE", `Successfully removed private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.delete', { userId: session.userId, playlistId });
-			activities.addActivity(session.userId, "deleted_playlist", [ playlistId ]);
-			return cb({ status: 'success', message: 'Playlist successfully removed' });
-		});
-	})
+                (stations, next) => {
+                    async.each(
+                        stations,
+                        (station, next) => {
+                            async.waterfall(
+                                [
+                                    (next) => {
+                                        stationModel.updateOne(
+                                            { _id: station._id },
+                                            { $set: { privatePlaylist: null } },
+                                            { runValidators: true },
+                                            next
+                                        );
+                                    },
 
+                                    (res, next) => {
+                                        if (!station.partyMode) {
+                                            moduleManager.modules["stations"]
+                                                .runJob("UPDATE_STATION", {
+                                                    stationId: station._id,
+                                                })
+                                                .then((station) =>
+                                                    next(null, station)
+                                                )
+                                                .catch(next);
+                                            cache.runJob("PUB", {
+                                                channel:
+                                                    "privatePlaylist.selected",
+                                                value: {
+                                                    playlistId: null,
+                                                    stationId: station._id,
+                                                },
+                                            });
+                                        } else next();
+                                    },
+                                ],
+                                (err) => {
+                                    next();
+                                }
+                            );
+                        },
+                        (err) => {
+                            next();
+                        }
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_REMOVE",
+                        `Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_REMOVE",
+                    `Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cache.runJob("PUB", {
+                    channel: "playlist.delete",
+                    value: {
+                        userId: session.userId,
+                        playlistId,
+                    },
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "deleted_playlist",
+                    payload: [playlistId],
+                });
+                return cb({
+                    status: "success",
+                    message: "Playlist successfully removed",
+                });
+            }
+        );
+    }),
 };
 
 module.exports = lib;

+ 154 - 108
backend/logic/actions/punishments.js

@@ -1,120 +1,166 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
-const moduleManager = require("../../index");
+const hooks = require("./hooks");
+// const moduleManager = require("../../index");
 
-const logger = moduleManager.modules["logger"];
-const utils = moduleManager.modules["utils"];
-const cache = moduleManager.modules["cache"];
-const db = moduleManager.modules["db"];
-const punishments = moduleManager.modules["punishments"];
+// const logger = require("logger");
+const utils = require("../utils");
+const cache = require("../cache");
+const db = require("../db");
+const punishments = require("../punishments");
 
-cache.sub('ip.ban', data => {
-	utils.emitToRoom('admin.punishments', 'event:admin.punishment.added', data.punishment);
-	utils.socketsFromIP(data.ip, sockets => {
-		sockets.forEach(socket => {
-			socket.disconnect(true);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "ip.ban",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.punishments",
+            args: ["event:admin.punishment.added", data.punishment],
+        });
+        utils.runJob("SOCKETS_FROM_IP", { ip: data.ip }).then((sockets) => {
+            sockets.forEach((socket) => {
+                socket.disconnect(true);
+            });
+        });
+    },
 });
 
 module.exports = {
+    /**
+     * Gets all punishments
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: hooks.adminRequired(async (session, cb) => {
+        const punishmentModel = await db.runJob("GET_MODEL", {
+            modelName: "punishment",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    punishmentModel.find({}, next);
+                },
+            ],
+            async (err, punishments) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PUNISHMENTS_INDEX",
+                        `Indexing punishments failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PUNISHMENTS_INDEX",
+                    "Indexing punishments successful."
+                );
+                cb({ status: "success", data: punishments });
+            }
+        );
+    }),
 
-	/**
-	 * Gets all punishments
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.punishment.find({}, next);
-			}
-		], async (err, punishments) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			}
-			logger.success("PUNISHMENTS_INDEX", "Indexing punishments successful.");
-			cb({ status: 'success', data: punishments });
-		});
-	}),
+    /**
+     * Bans an IP address
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} value - the ip address that is going to be banned
+     * @param {String} reason - the reason for the ban
+     * @param {String} expiresAt - the time the ban expires
+     * @param {Function} cb - gets called with the result
+     */
+    banIP: hooks.adminRequired((session, value, reason, expiresAt, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    if (!value)
+                        return next("You must provide an IP address to ban.");
+                    else if (!reason)
+                        return next("You must provide a reason for the ban.");
+                    else return next();
+                },
 
-	/**
-	 * Bans an IP address
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} value - the ip address that is going to be banned
-	 * @param {String} reason - the reason for the ban
-	 * @param {String} expiresAt - the time the ban expires
-	 * @param {Function} cb - gets called with the result
-	 */
-	banIP: hooks.adminRequired((session, value, reason, expiresAt, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!value) return next('You must provide an IP address to ban.');
-				else if (!reason) return next('You must provide a reason for the ban.');
-				else return next();
-			},
+                (next) => {
+                    if (!expiresAt || typeof expiresAt !== "string")
+                        return next("Invalid expire date.");
+                    let date = new Date();
+                    switch (expiresAt) {
+                        case "1h":
+                            expiresAt = date.setHours(date.getHours() + 1);
+                            break;
+                        case "12h":
+                            expiresAt = date.setHours(date.getHours() + 12);
+                            break;
+                        case "1d":
+                            expiresAt = date.setDate(date.getDate() + 1);
+                            break;
+                        case "1w":
+                            expiresAt = date.setDate(date.getDate() + 7);
+                            break;
+                        case "1m":
+                            expiresAt = date.setMonth(date.getMonth() + 1);
+                            break;
+                        case "3m":
+                            expiresAt = date.setMonth(date.getMonth() + 3);
+                            break;
+                        case "6m":
+                            expiresAt = date.setMonth(date.getMonth() + 6);
+                            break;
+                        case "1y":
+                            expiresAt = date.setFullYear(
+                                date.getFullYear() + 1
+                            );
+                            break;
+                        case "never":
+                            expiresAt = new Date(3093527980800000);
+                            break;
+                        default:
+                            return next("Invalid expire date.");
+                    }
 
-			(next) => {
-				if (!expiresAt || typeof expiresAt !== 'string') return next('Invalid expire date.');
-				let date = new Date();
-				switch(expiresAt) {
-					case '1h':
-						expiresAt = date.setHours(date.getHours() + 1);
-						break;
-					case '12h':
-						expiresAt = date.setHours(date.getHours() + 12);
-						break;
-					case '1d':
-						expiresAt = date.setDate(date.getDate() + 1);
-						break;
-					case '1w':
-						expiresAt = date.setDate(date.getDate() + 7);
-						break;
-					case '1m':
-						expiresAt = date.setMonth(date.getMonth() + 1);
-						break;
-					case '3m':
-						expiresAt = date.setMonth(date.getMonth() + 3);
-						break;
-					case '6m':
-						expiresAt = date.setMonth(date.getMonth() + 6);
-						break;
-					case '1y':
-						expiresAt = date.setFullYear(date.getFullYear() + 1);
-						break;
-					case 'never':
-						expiresAt = new Date(3093527980800000);
-						break;
-					default:
-						return next('Invalid expire date.');
-				}
-
-				next();
-			},
-
-			(next) => {
-				punishments.addPunishment('banUserIp', value, reason, expiresAt, session.userId, next)
-			}
-		], async (err, punishment) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("BAN_IP", `User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
-				cb({ status: 'failure', message: err });
-			}
-			logger.success("BAN_IP", `User ${session.userId} has successfully banned IP address ${value} with the reason ${reason}.`);
-			cache.pub('ip.ban', { ip: value, punishment });
-			return cb({
-				status: 'success',
-				message: 'Successfully banned IP address.'
-			});
-		});
-	}),
+                    next();
+                },
 
+                (next) => {
+                    punishments
+                        .runJob("ADD_PUNISHMENT", {
+                            type: "banUserIp",
+                            value,
+                            reason,
+                            expiresAt,
+                            punishedBy: session.userId,
+                        })
+                        .then((punishment) => next(null, punishment))
+                        .catch(next);
+                },
+            ],
+            async (err, punishment) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "BAN_IP",
+                        `User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "BAN_IP",
+                    `User ${session.userId} has successfully banned IP address ${value} with the reason ${reason}.`
+                );
+                cache.runJob("PUB", {
+                    channel: "ip.ban",
+                    value: { ip: value, punishment },
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully banned IP address.",
+                });
+            }
+        );
+    }),
 };

+ 373 - 229
backend/logic/actions/queueSongs.js

@@ -1,249 +1,393 @@
-'use strict';
+"use strict";
 
-const config = require('config');
-const async = require('async');
-const request = require('request');
+const config = require("config");
+const async = require("async");
+const request = require("request");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 
-const moduleManager = require("../../index");
+const db = require("../db");
+const utils = require("../utils");
+const cache = require("../cache");
+// const logger = moduleManager.modules["logger"];
 
-const db = moduleManager.modules["db"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const cache = moduleManager.modules["cache"];
-
-cache.sub('queue.newSong', songId => {
-	db.models.queueSong.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
-	});
+cache.runJob("SUB", {
+    channel: "queue.newSong",
+    cb: async (songId) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        queueSongModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.queue",
+                args: ["event:admin.queueSong.added", song],
+            });
+        });
+    },
 });
 
-cache.sub('queue.removedSong', songId => {
-	utils.emitToRoom('admin.queue', 'event:admin.queueSong.removed', songId);
+cache.runJob("SUB", {
+    channel: "queue.removedSong",
+    cb: (songId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.queue",
+            args: ["event:admin.queueSong.removed", songId],
+        });
+    },
 });
 
-cache.sub('queue.update', songId => {
-	db.models.queueSong.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
-	});
+cache.runJob("SUB", {
+    channel: "queue.update",
+    cb: async (songId) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        queueSongModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.queue",
+                args: ["event:admin.queueSong.updated", song],
+            });
+        });
+    },
 });
 
 let lib = {
+    /**
+     * Returns the length of the queue songs list
+     *
+     * @param session
+     * @param cb
+     */
+    length: hooks.adminRequired(async (session, cb) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.countDocuments({}, next);
+                },
+            ],
+            async (err, count) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_SONGS_LENGTH",
+                        `Failed to get length from queue songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_SONGS_LENGTH",
+                    `Got length from queue songs successfully.`
+                );
+                cb(count);
+            }
+        );
+    }),
 
-	/**
-	 * Returns the length of the queue songs list
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	length: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.countDocuments({}, next);
-			}
-		], async (err, count) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_SONGS_LENGTH", `Failed to get length from queue songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("QUEUE_SONGS_LENGTH", `Got length from queue songs successfully.`);
-			cb(count);
-		});
-	}),
-
-	/**
-	 * Gets a set of queue songs
-	 *
-	 * @param session
-	 * @param set - the set number to return
-	 * @param cb
-	 */
-	getSet: hooks.adminRequired((session, set, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.find({}).skip(15 * (set - 1)).limit(15).exec(next);
-			},
-		], async (err, songs) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_SONGS_GET_SET", `Failed to get set from queue songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("QUEUE_SONGS_GET_SET", `Got set from queue songs successfully.`);
-			cb(songs);
-		});
-	}),
-
-	/**
-	 * Updates a queuesong
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the queuesong that gets updated
-	 * @param {Object} updatedSong - the object of the updated queueSong
-	 * @param {Function} cb - gets called with the result
-	 */
-	update: hooks.adminRequired((session, songId, updatedSong, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.findOne({_id: songId}, next);
-			},
-
-			(song, next) => {
-				if(!song) return next('Song not found');
-				let updated = false;
-				let $set = {};
-				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
-				if (!updated) return next('No properties changed');
-				db.models.queueSong.updateOne({_id: songId}, {$set}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await  utils.getError(err);
-				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			cache.pub('queue.update', songId);
-			logger.success("QUEUE_UPDATE", `User "${session.userId}" successfully update queuesong "${songId}".`);
-			return cb({status: 'success', message: 'Successfully updated song.'});
-		});
-	}),
-
-	/**
-	 * Removes a queuesong
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the queuesong that gets removed
-	 * @param {Function} cb - gets called with the result
-	 */
-	remove: hooks.adminRequired((session, songId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.deleteOne({_id: songId}, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			cache.pub('queue.removedSong', songId);
-			logger.success("QUEUE_REMOVE", `User "${session.userId}" successfully removed queuesong "${songId}".`);
-			return cb({status: 'success', message: 'Successfully updated song.'});
-		});
-	}),
-
-	/**
-	 * Creates a queuesong
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song that gets added
-	 * @param {Function} cb - gets called with the result
-	 */
-	add: hooks.loginRequired((session, songId, cb) => {
-		let requestedAt = Date.now();
-
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (song) return next('This song is already in the queue.');
-				db.models.song.findOne({songId}, next);
-			},
-
-			// Get YouTube data from id
-			(song, next) => {
-				if (song) return next('This song has already been added.');
-				//TODO Add err object as first param of callback
-				utils.getSongFromYouTube(songId, (song) => {
-					song.duration = -1;
-					song.artists = [];
-					song.genres = [];
-					song.skipDuration = 0;
-					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
-					song.explicit = false;
-					song.requestedBy = session.userId;
-					song.requestedAt = requestedAt;
-					next(null, song);
-				});
-			},
-			/*(newSong, next) => {
+    /**
+     * Gets a set of queue songs
+     *
+     * @param session
+     * @param set - the set number to return
+     * @param cb
+     */
+    getSet: hooks.adminRequired(async (session, set, cb) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel
+                        .find({})
+                        .skip(15 * (set - 1))
+                        .limit(15)
+                        .exec(next);
+                },
+            ],
+            async (err, songs) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_SONGS_GET_SET",
+                        `Failed to get set from queue songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_SONGS_GET_SET",
+                    `Got set from queue songs successfully.`
+                );
+                cb(songs);
+            }
+        );
+    }),
+
+    /**
+     * Updates a queuesong
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} songId - the id of the queuesong that gets updated
+     * @param {Object} updatedSong - the object of the updated queueSong
+     * @param {Function} cb - gets called with the result
+     */
+    update: hooks.adminRequired(async (session, songId, updatedSong, cb) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.findOne({ _id: songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("Song not found");
+                    let updated = false;
+                    let $set = {};
+                    for (let prop in updatedSong)
+                        if (updatedSong[prop] !== song[prop])
+                            $set[prop] = updatedSong[prop];
+                    updated = true;
+                    if (!updated) return next("No properties changed");
+                    queueSongModel.updateOne(
+                        { _id: songId },
+                        { $set },
+                        { runValidators: true },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_UPDATE",
+                        `Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", { channel: "queue.update", value: songId });
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_UPDATE",
+                    `User "${session.userId}" successfully update queuesong "${songId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully updated song.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Removes a queuesong
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} songId - the id of the queuesong that gets removed
+     * @param {Function} cb - gets called with the result
+     */
+    remove: hooks.adminRequired(async (session, songId, cb, userId) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.deleteOne({ _id: songId }, next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_REMOVE",
+                        `Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", {
+                    channel: "queue.removedSong",
+                    value: songId,
+                });
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_REMOVE",
+                    `User "${session.userId}" successfully removed queuesong "${songId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully updated song.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Creates a queuesong
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} songId - the id of the song that gets added
+     * @param {Function} cb - gets called with the result
+     */
+    add: hooks.loginRequired(async (session, songId, cb) => {
+        let requestedAt = Date.now();
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (song) return next("This song is already in the queue.");
+                    songModel.findOne({ songId }, next);
+                },
+
+                // Get YouTube data from id
+                (song, next) => {
+                    if (song) return next("This song has already been added.");
+                    //TODO Add err object as first param of callback
+                    utils
+                        .runJob("GET_SONG_FROM_YOUTUBE", { songId })
+                        .then((song) => {
+                            song.duration = -1;
+                            song.artists = [];
+                            song.genres = [];
+                            song.skipDuration = 0;
+                            song.thumbnail = `${config.get(
+                                "domain"
+                            )}/assets/notes.png`;
+                            song.explicit = false;
+                            song.requestedBy = session.userId;
+                            song.requestedAt = requestedAt;
+                            next(null, song);
+                        })
+                        .catch(next);
+                },
+                /*(newSong, next) => {
 				utils.getSongFromSpotify(newSong, (err, song) => {
 					if (!song) next(null, newSong);
 					else next(err, song);
 				});
 			},*/
-			(newSong, next) => {
-				const song = new db.models.queueSong(newSong);
-				song.save({ validateBeforeSave: false }, (err, song) => {
-					if (err) return next(err);
-					next(null, song);
-				});
-			},
-			(newSong, next) => {
-				db.models.user.findOne({ _id: session.userId }, (err, user) => {
-					if (err) next(err, newSong);
-					else {
-						user.statistics.songsRequested = user.statistics.songsRequested + 1;
-						user.save(err => {
-							if (err) return next(err, newSong);
-							else next(null, newSong);
-						});
-					}
-				});
-			}
-		], async (err, newSong) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			cache.pub('queue.newSong', newSong._id);
-			logger.success("QUEUE_ADD", `User "${session.userId}" successfully added queuesong "${songId}".`);
-			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
-		});
-	}),
-
-	/**
-	 * Adds a set of songs to the queue
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} url - the url of the the YouTube playlist
-	 * @param {Function} cb - gets called with the result
-	 */
-	addSetToQueue: hooks.loginRequired((session, url, cb) => {
-		async.waterfall([
-			(next) => {
-				utils.getPlaylistFromYouTube(url, false, songIds => {
-					next(null, songIds);
-				});
-			},
-			(songIds, next) => {
-				let processed = 0;
-				function checkDone() {
-					if (processed === songIds.length) next();
-				}
-				for (let s = 0; s < songIds.length; s++) {
-					lib.add(session, songIds[s], () => {
-						processed++;
-						checkDone();
-					});
-				}
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("QUEUE_IMPORT", `Successfully imported a YouTube playlist to the queue for user "${session.userId}".`);
-				cb({ status: 'success', message: 'Playlist has been successfully imported.' });
-			}
-		});
-	})
+                (newSong, next) => {
+                    const song = new queueSongModel(newSong);
+                    song.save({ validateBeforeSave: false }, (err, song) => {
+                        if (err) return next(err);
+                        next(null, song);
+                    });
+                },
+                (newSong, next) => {
+                    userModel.findOne({ _id: session.userId }, (err, user) => {
+                        if (err) next(err, newSong);
+                        else {
+                            user.statistics.songsRequested =
+                                user.statistics.songsRequested + 1;
+                            user.save((err) => {
+                                if (err) return next(err, newSong);
+                                else next(null, newSong);
+                            });
+                        }
+                    });
+                },
+            ],
+            async (err, newSong) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_ADD",
+                        `Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", {
+                    channel: "queue.newSong",
+                    value: newSong._id,
+                });
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_ADD",
+                    `User "${session.userId}" successfully added queuesong "${songId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully added that song to the queue",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds a set of songs to the queue
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} url - the url of the the YouTube playlist
+     * @param {Function} cb - gets called with the result
+     */
+    addSetToQueue: hooks.loginRequired((session, url, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    utils
+                        .runJob("GET_PLAYLIST_FROM_YOUTUBE", {
+                            url,
+                            musicOnly: false,
+                        })
+                        .then((songIds) => next(null, songIds))
+                        .catch(next);
+                },
+                (songIds, next) => {
+                    let processed = 0;
+                    function checkDone() {
+                        if (processed === songIds.length) next();
+                    }
+                    for (let s = 0; s < songIds.length; s++) {
+                        lib.add(session, songIds[s], () => {
+                            processed++;
+                            checkDone();
+                        });
+                    }
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_IMPORT",
+                        `Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "QUEUE_IMPORT",
+                        `Successfully imported a YouTube playlist to the queue for user "${session.userId}".`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Playlist has been successfully imported.",
+                    });
+                }
+            }
+        );
+    }),
 };
 
 module.exports = lib;

+ 342 - 233
backend/logic/actions/reports.js

@@ -1,250 +1,359 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 
 const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const songs = moduleManager.modules["songs"];
+const db = require("../db");
+const cache = require("../cache");
+const utils = require("../utils");
+// const logger = require("../logger");
+const songs = require("../songs");
 
 const reportableIssues = [
-	{
-		name: 'Video',
-		reasons: [
-			'Doesn\'t exist',
-			'It\'s private',
-			'It\'s not available in my country'
-		]
-	},
-	{
-		name: 'Title',
-		reasons: [
-			'Incorrect',
-			'Inappropriate'
-		]
-	},
-	{
-		name: 'Duration',
-		reasons: [
-			'Skips too soon',
-			'Skips too late',
-			'Starts too soon',
-			'Skips too late'
-		]
-	},
-	{
-		name: 'Artists',
-		reasons: [
-			'Incorrect',
-			'Inappropriate'
-		]
-	},
-	{
-		name: 'Thumbnail',
-		reasons: [
-			'Incorrect',
-			'Inappropriate',
-			'Doesn\'t exist'
-		]
-	}
+    {
+        name: "Video",
+        reasons: [
+            "Doesn't exist",
+            "It's private",
+            "It's not available in my country",
+        ],
+    },
+    {
+        name: "Title",
+        reasons: ["Incorrect", "Inappropriate"],
+    },
+    {
+        name: "Duration",
+        reasons: [
+            "Skips too soon",
+            "Skips too late",
+            "Starts too soon",
+            "Skips too late",
+        ],
+    },
+    {
+        name: "Artists",
+        reasons: ["Incorrect", "Inappropriate"],
+    },
+    {
+        name: "Thumbnail",
+        reasons: ["Incorrect", "Inappropriate", "Doesn't exist"],
+    },
 ];
 
-cache.sub('report.resolve', reportId => {
-	utils.emitToRoom('admin.reports', 'event:admin.report.resolved', reportId);
+cache.runJob("SUB", {
+    channel: "report.resolve",
+    cb: (reportId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.reports",
+            args: ["event:admin.report.resolved", reportId],
+        });
+    },
 });
 
-cache.sub('report.create', report => {
-	utils.emitToRoom('admin.reports', 'event:admin.report.created', report);
+cache.runJob("SUB", {
+    channel: "report.create",
+    cb: (report) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.reports",
+            args: ["event:admin.report.created", report],
+        });
+    },
 });
 
 module.exports = {
+    /**
+     * Gets all reports
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: hooks.adminRequired(async (session, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel
+                        .find({ resolved: false })
+                        .sort({ released: "desc" })
+                        .exec(next);
+                },
+            ],
+            async (err, reports) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_INDEX",
+                        `Indexing reports failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "REPORTS_INDEX",
+                    "Indexing reports successful."
+                );
+                cb({ status: "success", data: reports });
+            }
+        );
+    }),
 
-	/**
-	 * Gets all reports
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
-			}
-		], async (err, reports) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			}
-			logger.success("REPORTS_INDEX", "Indexing reports successful.");
-			cb({ status: 'success', data: reports });
-		});
-	}),
-
-	/**
-	 * Gets a specific report
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} reportId - the id of the report to return
-	 * @param {Function} cb - gets called with the result
-	 */
-	findOne: hooks.adminRequired((session, reportId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.findOne({ _id: reportId }).exec(next);
-			}
-		], async (err, report) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			logger.success("REPORTS_FIND_ONE", `Finding report "${reportId}" successful.`);
-			cb({ status: 'success', data: report });
-		});
-	}),
-
-	/**
-	 * Gets all reports for a songId (_id)
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song to index reports for
-	 * @param {Function} cb - gets called with the result
-	 */
-	getReportsForSong: hooks.adminRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.find({ song: { _id: songId }, resolved: false }).sort({ released: 'desc' }).exec(next);
-			},
-
-			(reports, next) => {
-				let data = [];
-				for (let i = 0; i < reports.length; i++) {
-					data.push(reports[i]._id);
-				}
-				next(null, data);
-			}
-		], async (err, data) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			} else {
-				logger.success("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" successful.`);
-				return cb({ status: 'success', data });
-			}
-		});
-	}),
-
-	/**
-	 * Resolves a report
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} reportId - the id of the report that is getting resolved
-	 * @param {Function} cb - gets called with the result
-	 */
-	resolve: hooks.adminRequired((session, reportId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.findOne({ _id: reportId }).exec(next);
-			},
-
-			(report, next) => {
-				if (!report) return next('Report not found.');
-				report.resolved = true;
-				report.save(err => {
-					if (err) next(err.message);
-					else next();
-				});
-			}
-		], async (err) => {
-			if (err) {
-				err = await  utils.getError(err);
-				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			} else {
-				cache.pub('report.resolve', reportId);
-				logger.success("REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
-				cb({ status: 'success', message: 'Successfully resolved Report' });
-			}
-		});
-	}),
-
-	/**
-	 * Creates a new report
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the object of the report data
-	 * @param {Function} cb - gets called with the result
-	 */
-	create: hooks.loginRequired((session, data, cb) => {
-		async.waterfall([
-
-			(next) => {
-				db.models.song.findOne({ songId: data.songId }).exec(next);
-			},
-
-			(song, next) => {
-				if (!song) return next('Song not found.');
-				songs.getSong(song._id, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('Song not found.');
-
-				delete data.songId;
-				data.song = {
-					_id: song._id,
-					songId: song.songId
-				}
-
-				for (let z = 0; z < data.issues.length; z++) {
-					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
-						for (let r = 0; r < reportableIssues.length; r++) {
-							if (reportableIssues[r].reasons.every(reason => data.issues[z].reasons.indexOf(reason) < -1)) {
-								return cb({ status: 'failure', message: 'Invalid data' });
-							}
-						}
-					} else return cb({ status: 'failure', message: 'Invalid data' });
-				}
-
-				next();
-			},
-
-			(next) => {
-				let issues = [];
-
-				for (let r = 0; r < data.issues.length; r++) {
-					if (!data.issues[r].reasons.length <= 0) issues.push(data.issues[r]);
-				}
-
-				data.issues = issues;
-
-				next();
-			},
-
-			(next) => {
-				data.createdBy = session.userId;
-				data.createdAt = Date.now();
-				db.models.report.create(data, next);
-			}
-
-		], async (err, report) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REPORTS_CREATE", `Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			} else {
-				cache.pub('report.create', report);
-				logger.success("REPORTS_CREATE", `User "${session.userId}" created report for "${data.songId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully created report' });
-			}
-		});
-	})
+    /**
+     * Gets a specific report
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} reportId - the id of the report to return
+     * @param {Function} cb - gets called with the result
+     */
+    findOne: hooks.adminRequired(async (session, reportId, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel.findOne({ _id: reportId }).exec(next);
+                },
+            ],
+            async (err, report) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_FIND_ONE",
+                        `Finding report "${reportId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "REPORTS_FIND_ONE",
+                    `Finding report "${reportId}" successful.`
+                );
+                cb({ status: "success", data: report });
+            }
+        );
+    }),
 
+    /**
+     * Gets all reports for a songId (_id)
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} songId - the id of the song to index reports for
+     * @param {Function} cb - gets called with the result
+     */
+    getReportsForSong: hooks.adminRequired(async (session, songId, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel
+                        .find({ song: { _id: songId }, resolved: false })
+                        .sort({ released: "desc" })
+                        .exec(next);
+                },
+
+                (reports, next) => {
+                    let data = [];
+                    for (let i = 0; i < reports.length; i++) {
+                        data.push(reports[i]._id);
+                    }
+                    next(null, data);
+                },
+            ],
+            async (err, data) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_REPORTS_FOR_SONG",
+                        `Indexing reports for song "${songId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "GET_REPORTS_FOR_SONG",
+                        `Indexing reports for song "${songId}" successful.`
+                    );
+                    return cb({ status: "success", data });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Resolves a report
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} reportId - the id of the report that is getting resolved
+     * @param {Function} cb - gets called with the result
+     */
+    resolve: hooks.adminRequired(async (session, reportId, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel.findOne({ _id: reportId }).exec(next);
+                },
+
+                (report, next) => {
+                    if (!report) return next("Report not found.");
+                    report.resolved = true;
+                    report.save((err) => {
+                        if (err) next(err.message);
+                        else next();
+                    });
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_RESOLVE",
+                        `Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    cache.runJob("PUB", {
+                        channel: "report.resolve",
+                        value: reportId,
+                    });
+                    console.log(
+                        "SUCCESS",
+                        "REPORTS_RESOLVE",
+                        `User "${session.userId}" resolved report "${reportId}".`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully resolved Report",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Creates a new report
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} data - the object of the report data
+     * @param {Function} cb - gets called with the result
+     */
+    create: hooks.loginRequired(async (session, data, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId: data.songId }).exec(next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("Song not found.");
+                    songs
+                        .runJob("GET_SONG", { songId })
+                        .then((song) => next(null, song))
+                        .catch(next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("Song not found.");
+
+                    delete data.songId;
+                    data.song = {
+                        _id: song._id,
+                        songId: song.songId,
+                    };
+
+                    for (let z = 0; z < data.issues.length; z++) {
+                        if (
+                            reportableIssues.filter((issue) => {
+                                return issue.name == data.issues[z].name;
+                            }).length > 0
+                        ) {
+                            for (let r = 0; r < reportableIssues.length; r++) {
+                                if (
+                                    reportableIssues[r].reasons.every(
+                                        (reason) =>
+                                            data.issues[z].reasons.indexOf(
+                                                reason
+                                            ) < -1
+                                    )
+                                ) {
+                                    return cb({
+                                        status: "failure",
+                                        message: "Invalid data",
+                                    });
+                                }
+                            }
+                        } else
+                            return cb({
+                                status: "failure",
+                                message: "Invalid data",
+                            });
+                    }
+
+                    next();
+                },
+
+                (next) => {
+                    let issues = [];
+
+                    for (let r = 0; r < data.issues.length; r++) {
+                        if (!data.issues[r].reasons.length <= 0)
+                            issues.push(data.issues[r]);
+                    }
+
+                    data.issues = issues;
+
+                    next();
+                },
+
+                (next) => {
+                    data.createdBy = session.userId;
+                    data.createdAt = Date.now();
+                    reportModel.create(data, next);
+                },
+            ],
+            async (err, report) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_CREATE",
+                        `Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    cache.runJob("PUB", {
+                        channel: "report.create",
+                        value: report,
+                    });
+                    console.log(
+                        "SUCCESS",
+                        "REPORTS_CREATE",
+                        `User "${session.userId}" created report for "${data.songId}".`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully created report",
+                    });
+                }
+            }
+        );
+    }),
 };

+ 1022 - 503
backend/logic/actions/songs.js

@@ -1,525 +1,1044 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
-const queueSongs = require('./queueSongs');
+const hooks = require("./hooks");
+const queueSongs = require("./queueSongs");
 
-const moduleManager = require("../../index");
+// const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const songs = moduleManager.modules["songs"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const activities = moduleManager.modules["activities"];
+const db = require("../db");
+const songs = require("../songs");
+const cache = require("../cache");
+const utils = require("../utils");
+const activities = require("../activities");
+// const logger = moduleManager.modules["logger"];
 
-cache.sub('song.removed', songId => {
-	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
+cache.runJob("SUB", {
+    channel: "song.removed",
+    cb: (songId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.songs",
+            args: ["event:admin.song.removed", songId],
+        });
+    },
 });
 
-cache.sub('song.added', songId => {
-	db.models.song.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
-	});
+cache.runJob("SUB", {
+    channel: "song.added",
+    cb: async (songId) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        songModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.songs",
+                args: ["event:admin.song.added", song],
+            });
+        });
+    },
 });
 
-cache.sub('song.updated', songId => {
-	db.models.song.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
-	});
+cache.runJob("SUB", {
+    channel: "song.updated",
+    cb: async (songId) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        songModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.songs",
+                args: ["event:admin.song.updated", song],
+            });
+        });
+    },
 });
 
-cache.sub('song.like', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.like', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: true, disliked: false});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.like",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.like",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: true,
+                        disliked: false,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('song.dislike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.dislike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: true});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.dislike",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.dislike",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: false,
+                        disliked: true,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('song.unlike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.unlike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.unlike",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.unlike",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: false,
+                        disliked: false,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('song.undislike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.undislike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.undislike",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.undislike",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: false,
+                        disliked: false,
+                    });
+                });
+            });
+    },
 });
 
 module.exports = {
+    /**
+     * Returns the length of the songs list
+     *
+     * @param session
+     * @param cb
+     */
+    length: hooks.adminRequired(async (session, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.countDocuments({}, next);
+                },
+            ],
+            async (err, count) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_LENGTH",
+                        `Failed to get length from songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_LENGTH",
+                    `Got length from songs successfully.`
+                );
+                cb(count);
+            }
+        );
+    }),
 
-	/**
-	 * Returns the length of the songs list
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	length: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.countDocuments({}, next);
-			}
-		], async (err, count) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_LENGTH", `Got length from songs successfully.`);
-			cb(count);
-		});
-	}),
-
-	/**
-	 * Gets a set of songs
-	 *
-	 * @param session
-	 * @param set - the set number to return
-	 * @param cb
-	 */
-	getSet: hooks.adminRequired((session, set, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.find({}).skip(15 * (set - 1)).limit(15).exec(next);
-			},
-		], async (err, songs) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_GET_SET", `Got set from songs successfully.`);
-			cb(songs);
-		});
-	}),
-
-	/**
-	 * Gets a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	getSong: hooks.adminRequired((session, songId, cb) => {
-		console.log(songId);
-
-		async.waterfall([
-			(next) => {
-				song.getSong(songId, next);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_SONG", `Failed to get song ${songId}. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("SONGS_GET_SONG", `Got song ${songId} successfully.`);
-				cb({ status: "success", data: song });
-			}
-		});
-	}),
-
-	/**
-	 * Obtains basic metadata of a song in order to format an activity
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	getSongForActivity: (session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				songs.getSongFromId(songId, next);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_SONG_FOR_ACTIVITY", `Failed to obtain metadata of song ${songId} for activity formatting. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				if (song) {
-					logger.success("SONGS_GET_SONG_FOR_ACTIVITY", `Obtained metadata of song ${songId} for activity formatting successfully.`);
-					cb({ status: "success", data: {
-						title: song.title,
-						thumbnail: song.thumbnail
-					} });
-				} else {
-					logger.error("SONGS_GET_SONG_FOR_ACTIVITY", `Song ${songId} does not exist so failed to obtain for activity formatting.`);
-					cb({ status: "failure" });
-				}
-			}
-		});
-	},
-
-	/**
-	 * Updates a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param song - the updated song object
-	 * @param cb
-	 */
-	update: hooks.adminRequired((session, songId, song, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.updateOne({_id: songId}, song, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				songs.updateSong(songId, next);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_UPDATE", `Successfully updated song "${songId}".`);
-			cache.pub('song.updated', song.songId);
-			cb({ status: 'success', message: 'Song has been successfully updated', data: song });
-		});
-	}),
-
-	/**
-	 * Removes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	remove: hooks.adminRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.deleteOne({_id: songId}, next);
-			},
-
-			(res, next) => {//TODO Check if res gets returned from above
-				cache.hdel('songs', songId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_UPDATE", `Successfully remove song "${songId}".`);
-			cache.pub('song.removed', songId);
-			cb({status: 'success', message: 'Song has been successfully updated'});
-		});
-	}),
-
-	/**
-	 * Adds a song
-	 *
-	 * @param session
-	 * @param song - the song object
-	 * @param cb
-	 */
-	add: hooks.adminRequired((session, song, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId: song.songId}, next);
-			},
-
-			(existingSong, next) => {
-				if (existingSong) return next('Song is already in rotation.');
-				next();
-			},
-
-			(next) => {
-				const newSong = new db.models.song(song);
-				newSong.acceptedBy = session.userId;
-				newSong.acceptedAt = Date.now();
-				newSong.save(next);
-			},
-
-			(res, next) => {
-				queueSongs.remove(session, song._id, () => {
-					next();
-				});
-			},
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_ADD", `User "${session.userId}" failed to add song. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_ADD", `User "${session.userId}" successfully added song "${song.songId}".`);
-			cache.pub('song.added', song.songId);
-			cb({status: 'success', message: 'Song has been moved from the queue successfully.'});
-		});
-		//TODO Check if video is in queue and Add the song to the appropriate stations
-	}),
-
-	/**
-	 * Likes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	like: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_LIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({ _id: session.userId }, (err, user) => {
-				if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-				db.models.user.updateOne({ _id: session.userId }, { $push: { liked: songId }, $pull: { disliked: songId } }, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-									if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-									songs.updateSong(songId, (err, song) => {});
-									cache.pub('song.like', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
-									activities.addActivity(session.userId, "liked_song", [ songId ]);
-									return cb({ status: 'success', message: 'You have successfully liked this song.' });
-								});
-							});
-						});
-					} else return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Dislikes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	dislike: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_DISLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({ _id: session.userId }, (err, user) => {
-				if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-				db.models.user.updateOne({_id: session.userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err, res) => {
-									if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-									songs.updateSong(songId, (err, song) => {});
-									cache.pub('song.dislike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
-									return cb({ status: 'success', message: 'You have successfully disliked this song.' });
-								});
-							});
-						});
-					} else return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Undislikes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	undislike: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UNDISLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (user.disliked.indexOf(songId) === -1) return cb({
-					status: 'failure',
-					message: 'You have not disliked this song.'
-				});
-				db.models.user.updateOne({_id: session.userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({
-								status: 'failure',
-								message: 'Something went wrong while undisliking this song.'
-							});
-							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
-								if (err) return cb({
-									status: 'failure',
-									message: 'Something went wrong while undisliking this song.'
-								});
-								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-									if (err) return cb({
-										status: 'failure',
-										message: 'Something went wrong while undisliking this song.'
-									});
-									songs.updateSong(songId, (err, song) => {
-									});
-									cache.pub('song.undislike', JSON.stringify({
-										songId: oldSongId,
-										userId: session.userId,
-										likes: likes,
-										dislikes: dislikes
-									}));
-									return cb({
-										status: 'success',
-										message: 'You have successfully undisliked this song.'
-									});
-								});
-							});
-						});
-					} else return cb({status: 'failure', message: 'Something went wrong while undisliking this song.'});
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Unlikes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	unlike: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UNLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({ _id: session.userId }, (err, user) => {
-				if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-				db.models.user.updateOne({_id: session.userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
-								db.models.song.updateOne({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-									if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-									songs.updateSong(songId, (err, song) => {});
-									cache.pub('song.unlike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
-									return cb({ status: 'success', message: 'You have successfully unliked this song.' });
-								});
-							});
-						});
-					} else return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Gets user's own song ratings
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	getOwnSongRatings: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_OWN_RATINGS", `User "${session.userId}" failed to get ratings for ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let newSongId = song._id;
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (!err && user) {
-					return cb({
-						status: 'success',
-						songId: songId,
-						liked: (user.liked.indexOf(newSongId) !== -1),
-						disliked: (user.disliked.indexOf(newSongId) !== -1)
-					});
-				} else {
-					return cb({
-						status: 'failure',
-						message: utils.getError(err)
-					});
-				}
-			});
-		});
-	})
+    /**
+     * Gets a set of songs
+     *
+     * @param session
+     * @param set - the set number to return
+     * @param cb
+     */
+    getSet: hooks.adminRequired(async (session, set, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel
+                        .find({})
+                        .skip(15 * (set - 1))
+                        .limit(15)
+                        .exec(next);
+                },
+            ],
+            async (err, songs) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_SET",
+                        `Failed to get set from songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_GET_SET",
+                    `Got set from songs successfully.`
+                );
+                cb(songs);
+            }
+        );
+    }),
+
+    /**
+     * Gets a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    getSong: hooks.adminRequired((session, songId, cb) => {
+        console.log(songId);
+
+        async.waterfall(
+            [
+                (next) => {
+                    song.getSong(songId, next);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_SONG",
+                        `Failed to get song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "SONGS_GET_SONG",
+                        `Got song ${songId} successfully.`
+                    );
+                    cb({ status: "success", data: song });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Obtains basic metadata of a song in order to format an activity
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    getSongForActivity: (session, songId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    songs
+                        .runJob("GET_SONG_FROM_ID", { songId })
+                        .then((song) => next(null, song))
+                        .catch(next);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_SONG_FOR_ACTIVITY",
+                        `Failed to obtain metadata of song ${songId} for activity formatting. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    if (song) {
+                        console.log(
+                            "SUCCESS",
+                            "SONGS_GET_SONG_FOR_ACTIVITY",
+                            `Obtained metadata of song ${songId} for activity formatting successfully.`
+                        );
+                        cb({
+                            status: "success",
+                            data: {
+                                title: song.title,
+                                thumbnail: song.thumbnail,
+                            },
+                        });
+                    } else {
+                        console.log(
+                            "ERROR",
+                            "SONGS_GET_SONG_FOR_ACTIVITY",
+                            `Song ${songId} does not exist so failed to obtain for activity formatting.`
+                        );
+                        cb({ status: "failure" });
+                    }
+                }
+            }
+        );
+    },
+
+    /**
+     * Updates a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param song - the updated song object
+     * @param cb
+     */
+    update: hooks.adminRequired(async (session, songId, song, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.updateOne(
+                        { _id: songId },
+                        song,
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    songs
+                        .runJob("UPDATE_SONG", { songId })
+                        .then((song) => next(null, song))
+                        .catch(next);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UPDATE",
+                        `Failed to update song "${songId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_UPDATE",
+                    `Successfully updated song "${songId}".`
+                );
+                cache.runJob("PUB", {
+                    channel: "song.updated",
+                    value: song.songId,
+                });
+                cb({
+                    status: "success",
+                    message: "Song has been successfully updated",
+                    data: song,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Removes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    remove: hooks.adminRequired(async (session, songId, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.deleteOne({ _id: songId }, next);
+                },
+
+                (res, next) => {
+                    //TODO Check if res gets returned from above
+                    cache
+                        .runJob("HDEL", { table: "songs", key: songId })
+                        .then(() => next())
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UPDATE",
+                        `Failed to remove song "${songId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_UPDATE",
+                    `Successfully remove song "${songId}".`
+                );
+                cache.runJob("PUB", { channel: "song.removed", value: songId });
+                cb({
+                    status: "success",
+                    message: "Song has been successfully updated",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds a song
+     *
+     * @param session
+     * @param song - the song object
+     * @param cb
+     */
+    add: hooks.adminRequired(async (session, song, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId: song.songId }, next);
+                },
+
+                (existingSong, next) => {
+                    if (existingSong)
+                        return next("Song is already in rotation.");
+                    next();
+                },
+
+                (next) => {
+                    const newSong = new songModel(song);
+                    newSong.acceptedBy = session.userId;
+                    newSong.acceptedAt = Date.now();
+                    newSong.save(next);
+                },
+
+                (res, next) => {
+                    queueSongs.remove(session, song._id, () => {
+                        next();
+                    });
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_ADD",
+                        `User "${session.userId}" failed to add song. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_ADD",
+                    `User "${session.userId}" successfully added song "${song.songId}".`
+                );
+                cache.runJob("PUB", {
+                    channel: "song.added",
+                    value: song.songId,
+                });
+                cb({
+                    status: "success",
+                    message: "Song has been moved from the queue successfully.",
+                });
+            }
+        );
+        //TODO Check if video is in queue and Add the song to the appropriate stations
+    }),
+
+    /**
+     * Likes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    like: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_LIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.liked.indexOf(songId) !== -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have already liked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        {
+                            $push: { liked: songId },
+                            $pull: { disliked: songId },
+                        },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while liking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while liking this song.",
+                                                    });
+                                                songModel.update(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while liking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.like",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    userId:
+                                                                        session.userId,
+                                                                    likes: likes,
+                                                                    dislikes: dislikes,
+                                                                }
+                                                            ),
+                                                        });
+                                                        activities.runJob(
+                                                            "ADD_ACTIVITY",
+                                                            {
+                                                                userId:
+                                                                    session.userId,
+                                                                activityType:
+                                                                    "liked_song",
+                                                                payload: [
+                                                                    songId,
+                                                                ],
+                                                            }
+                                                        );
+                                                        return cb({
+                                                            status: "success",
+                                                            message:
+                                                                "You have successfully liked this song.",
+                                                        });
+                                                    }
+                                                );
+                                            }
+                                        );
+                                    }
+                                );
+                            } else
+                                return cb({
+                                    status: "failure",
+                                    message:
+                                        "Something went wrong while liking this song.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Dislikes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    dislike: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_DISLIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.disliked.indexOf(songId) !== -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have already disliked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        {
+                            $push: { disliked: songId },
+                            $pull: { liked: songId },
+                        },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while disliking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while disliking this song.",
+                                                    });
+                                                songModel.update(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err, res) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while disliking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.dislike",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    userId:
+                                                                        session.userId,
+                                                                    likes: likes,
+                                                                    dislikes: dislikes,
+                                                                }
+                                                            ),
+                                                        });
+                                                        return cb({
+                                                            status: "success",
+                                                            message:
+                                                                "You have successfully disliked this song.",
+                                                        });
+                                                    }
+                                                );
+                                            }
+                                        );
+                                    }
+                                );
+                            } else
+                                return cb({
+                                    status: "failure",
+                                    message:
+                                        "Something went wrong while disliking this song.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Undislikes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    undislike: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UNDISLIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.disliked.indexOf(songId) === -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have not disliked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $pull: { liked: songId, disliked: songId } },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while undisliking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while undisliking this song.",
+                                                    });
+                                                songModel.update(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while undisliking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.undislike",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    userId:
+                                                                        session.userId,
+                                                                    likes: likes,
+                                                                    dislikes: dislikes,
+                                                                }
+                                                            ),
+                                                        });
+                                                        return cb({
+                                                            status: "success",
+                                                            message:
+                                                                "You have successfully undisliked this song.",
+                                                        });
+                                                    }
+                                                );
+                                            }
+                                        );
+                                    }
+                                );
+                            } else
+                                return cb({
+                                    status: "failure",
+                                    message:
+                                        "Something went wrong while undisliking this song.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Unlikes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    unlike: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UNLIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.liked.indexOf(songId) === -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have not liked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $pull: { liked: songId, disliked: songId } },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while unliking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while undiking this song.",
+                                                    });
+                                                songModel.updateOne(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while unliking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.unlike",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    userId:
+                                                                        session.userId,
+                                                                    likes: likes,
+                                                                    dislikes: dislikes,
+                                                                }
+                                                            ),
+                                                        });
+                                                        return cb({
+                                                            status: "success",
+                                                            message:
+                                                                "You have successfully unliked this song.",
+                                                        });
+                                                    }
+                                                );
+                                            }
+                                        );
+                                    }
+                                );
+                            } else
+                                return cb({
+                                    status: "failure",
+                                    message:
+                                        "Something went wrong while unliking this song.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Gets user's own song ratings
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    getOwnSongRatings: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_OWN_RATINGS",
+                        `User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let newSongId = song._id;
+                userModel.findOne(
+                    { _id: session.userId },
+                    async (err, user) => {
+                        if (!err && user) {
+                            return cb({
+                                status: "success",
+                                songId: songId,
+                                liked: user.liked.indexOf(newSongId) !== -1,
+                                disliked:
+                                    user.disliked.indexOf(newSongId) !== -1,
+                            });
+                        } else {
+                            return cb({
+                                status: "failure",
+                                message: await utils.runJob("GET_ERROR", {
+                                    error: err,
+                                }),
+                            });
+                        }
+                    }
+                );
+            }
+        );
+    }),
 };

+ 2342 - 1229
backend/logic/actions/stations.js

@@ -1,1264 +1,2377 @@
-'use strict';
+"use strict";
 
-const async   = require('async'),
-	  request = require('request'),
-	  config  = require('config'),
-	  _		  =  require('underscore')._;
+const async = require("async"),
+    request = require("request"),
+    config = require("config"),
+    _ = require("underscore")._;
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 
-const moduleManager = require("../../index");
+const db = require("../db");
+const cache = require("../cache");
+const notifications = require("../notifications");
+const utils = require("../utils");
+const stations = require("../stations");
+const songs = require("../songs");
+const activities = require("../activities");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const notifications = moduleManager.modules["notifications"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const stations = moduleManager.modules["stations"];
-const songs = moduleManager.modules["songs"];
-const activities = moduleManager.modules["activities"];
+// const logger = moduleManager.modules["logger"];
 
 let userList = {};
 let usersPerStation = {};
 let usersPerStationCount = {};
 
-setInterval(() => {
-	let stationsCountUpdated = [];
-	let stationsUpdated = [];
-
-	let oldUsersPerStation = usersPerStation;
-	usersPerStation = {};
-
-	let oldUsersPerStationCount = usersPerStationCount;
-	usersPerStationCount = {};
-
-	async.each(Object.keys(userList), function(socketId, next) {
-		utils.socketFromSession(socketId).then((socket) => {
-			let stationId = userList[socketId];
-			if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
-				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-				delete userList[socketId];
-				return next();
-			}
-			if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
-			usersPerStationCount[stationId]++;
-			if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
-
-			async.waterfall([
-				(next) => {
-					if (!socket.session || !socket.session.sessionId) return next('No session found.');
-					cache.hget('sessions', socket.session.sessionId, next);
-				},
-
-				(session, next) => {
-					if (!session) return next('Session not found.');
-					db.models.user.findOne({_id: session.userId}, next);
-				},
-
-				(user, next) => {
-					if (!user) return next('User not found.');
-					if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
-					next(null, user.username);
-				}
-			], (err, username) => {
-				if (!err) {
-					usersPerStation[stationId].push(username);
-				}
-				next();
-			});
-		});
-		//TODO Code to show users
-	}, (err) => {
-		for (let stationId in usersPerStationCount) {
-			if (oldUsersPerStationCount[stationId] !== usersPerStationCount[stationId]) {
-				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-			}
-		}
-
-		for (let stationId in usersPerStation) {
-			if (_.difference(usersPerStation[stationId], oldUsersPerStation[stationId]).length > 0 || _.difference(oldUsersPerStation[stationId], usersPerStation[stationId]).length > 0) {
-				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-			}
-		}
-
-		stationsCountUpdated.forEach((stationId) => {
-			//logger.info("UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
-			cache.pub('station.updateUserCount', stationId);
-		});
-
-		stationsUpdated.forEach((stationId) => {
-			//logger.info("UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
-			cache.pub('station.updateUsers', stationId);
-		});
-
-		//console.log("Userlist", usersPerStation);
-	});
+setInterval(async () => {
+    let stationsCountUpdated = [];
+    let stationsUpdated = [];
+
+    let oldUsersPerStation = usersPerStation;
+    usersPerStation = {};
+
+    let oldUsersPerStationCount = usersPerStationCount;
+    usersPerStationCount = {};
+
+    const userModel = await db.runJob("GET_MODEL", {
+        modelName: "user",
+    });
+
+    async.each(
+        Object.keys(userList),
+        function(socketId, next) {
+            utils.runJob("SOCKET_FROM_SESSION", { socketId }).then((socket) => {
+                let stationId = userList[socketId];
+                if (
+                    !socket ||
+                    Object.keys(socket.rooms).indexOf(
+                        `station.${stationId}`
+                    ) === -1
+                ) {
+                    if (stationsCountUpdated.indexOf(stationId) === -1)
+                        stationsCountUpdated.push(stationId);
+                    if (stationsUpdated.indexOf(stationId) === -1)
+                        stationsUpdated.push(stationId);
+                    delete userList[socketId];
+                    return next();
+                }
+                if (!usersPerStationCount[stationId])
+                    usersPerStationCount[stationId] = 0;
+                usersPerStationCount[stationId]++;
+                if (!usersPerStation[stationId])
+                    usersPerStation[stationId] = [];
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            if (!socket.session || !socket.session.sessionId)
+                                return next("No session found.");
+                            cache
+                                .runJob("HGET", {
+                                    table: "sessions",
+                                    key: socket.session.sessionId,
+                                })
+                                .then((session) => next(null, session))
+                                .catch(next);
+                        },
+
+                        (session, next) => {
+                            if (!session) return next("Session not found.");
+                            userModel.findOne({ _id: session.userId }, next);
+                        },
+
+                        (user, next) => {
+                            if (!user) return next("User not found.");
+                            if (
+                                usersPerStation[stationId].indexOf(
+                                    user.username
+                                ) !== -1
+                            )
+                                return next("User already in the list.");
+                            next(null, user.username);
+                        },
+                    ],
+                    (err, username) => {
+                        if (!err) {
+                            usersPerStation[stationId].push(username);
+                        }
+                        next();
+                    }
+                );
+            });
+            //TODO Code to show users
+        },
+        (err) => {
+            for (let stationId in usersPerStationCount) {
+                if (
+                    oldUsersPerStationCount[stationId] !==
+                    usersPerStationCount[stationId]
+                ) {
+                    if (stationsCountUpdated.indexOf(stationId) === -1)
+                        stationsCountUpdated.push(stationId);
+                }
+            }
+
+            for (let stationId in usersPerStation) {
+                if (
+                    _.difference(
+                        usersPerStation[stationId],
+                        oldUsersPerStation[stationId]
+                    ).length > 0 ||
+                    _.difference(
+                        oldUsersPerStation[stationId],
+                        usersPerStation[stationId]
+                    ).length > 0
+                ) {
+                    if (stationsUpdated.indexOf(stationId) === -1)
+                        stationsUpdated.push(stationId);
+                }
+            }
+
+            stationsCountUpdated.forEach((stationId) => {
+                //console.log("INFO", "UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
+                cache.runJob("PUB", {
+                    table: "station.updateUserCount",
+                    value: stationId,
+                });
+            });
+
+            stationsUpdated.forEach((stationId) => {
+                //console.log("INFO", "UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
+                cache.runJob("PUB", {
+                    table: "station.updateUsers",
+                    value: stationId,
+                });
+            });
+
+            //console.log("Userlist", usersPerStation);
+        }
+    );
 }, 3000);
 
-cache.sub('station.updateUsers', stationId => {
-	let list = usersPerStation[stationId] || [];
-	utils.emitToRoom(`station.${stationId}`, "event:users.updated", list);
+cache.runJob("SUB", {
+    channel: "station.updateUsers",
+    cb: (stationId) => {
+        let list = usersPerStation[stationId] || [];
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:users.updated", list],
+        });
+    },
 });
 
-cache.sub('station.updateUserCount', stationId => {
-	let count = usersPerStationCount[stationId] || 0;
-	utils.emitToRoom(`station.${stationId}`, "event:userCount.updated", count);
-	stations.getStation(stationId, async (err, station) => {
-		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
-		else {
-			let sockets = await utils.getRoomSockets('home');
-			for (let socketId in sockets) {
-				let socket = sockets[socketId];
-				let session = sockets[socketId].session;
-				if (session.sessionId) {
-					cache.hget('sessions', session.sessionId, (err, session) => {
-						if (!err && session) {
-							db.models.user.findOne({_id: session.userId}, (err, user) => {
-								if (user.role === 'admin') socket.emit("event:userCount.updated", stationId, count);
-								else if (station.type === "community" && station.owner === session.userId) socket.emit("event:userCount.updated", stationId, count);
-							});
-						}
-					});
-				}
-			}
-		}
-	})
+cache.runJob("SUB", {
+    channel: "station.updateUserCount",
+    cb: (stationId) => {
+        let count = usersPerStationCount[stationId] || 0;
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:userCount.updated", count],
+        });
+        stations.runJob("GET_STATION", { stationId }).then(async (station) => {
+            if (station.privacy === "public")
+                utils.runJob("EMIT_TO_ROOM", {
+                    room: "home",
+                    args: ["event:userCount.updated", stationId, count],
+                });
+            else {
+                let sockets = await utils.runJob("GET_ROOM_SOCKETS", {
+                    room: "home",
+                });
+                for (let socketId in sockets) {
+                    let socket = sockets[socketId];
+                    let session = sockets[socketId].session;
+                    if (session.sessionId) {
+                        cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: session.sessionId,
+                            })
+                            .then((session) => {
+                                if (session)
+                                    db.runJob("GET_MODEL", {
+                                        modelName: "user",
+                                    }).then((userModel) =>
+                                        userModel.findOne(
+                                            { _id: session.userId },
+                                            (err, user) => {
+                                                if (user.role === "admin")
+                                                    socket.emit(
+                                                        "event:userCount.updated",
+                                                        stationId,
+                                                        count
+                                                    );
+                                                else if (
+                                                    station.type ===
+                                                        "community" &&
+                                                    station.owner ===
+                                                        session.userId
+                                                )
+                                                    socket.emit(
+                                                        "event:userCount.updated",
+                                                        stationId,
+                                                        count
+                                                    );
+                                            }
+                                        )
+                                    );
+                            });
+                    }
+                }
+            }
+        });
+    },
 });
 
-cache.sub('station.queueLockToggled', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:queueLockToggled", data.locked)
+cache.runJob("SUB", {
+    channel: "station.queueLockToggled",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${data.stationId}`,
+            args: ["event:queueLockToggled", data.locked],
+        });
+    },
 });
 
-cache.sub('station.updatePartyMode', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:partyMode.updated", data.partyMode);
+cache.runJob("SUB", {
+    channel: "station.updatePartyMode",
+    cb: (data) => {
+        utils.runJob(
+            "EMIT_TO_ROOM",
+            {
+                room: `station.${data.stationId}`,
+                args: ["event:partyMode.updated", data],
+            }.partyMode
+        );
+    },
 });
 
-cache.sub('privatePlaylist.selected', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:privatePlaylist.selected", data.playlistId);
+cache.runJob("SUB", {
+    channel: "privatePlaylist.selected",
+    cb: (data) => {
+        utils.runJob(
+            "EMIT_TO_ROOM",
+            {
+                room: `station.${data.stationId}`,
+                args: ["event:privatePlaylist.selected", data],
+            }.playlistId
+        );
+    },
 });
 
-cache.sub('station.pause', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		utils.emitToRoom(`station.${stationId}`, "event:stations.pause", { pausedAt: station.pausedAt });
-	});
+cache.runJob("SUB", {
+    channel: "station.pause",
+    cb: (stationId) => {
+        stations.runJob("GET_STATION", { stationId }).then((station) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: `station.${stationId}`,
+                args: ["event:stations.pause", { pausedAt: station.pausedAt }],
+            });
+        });
+    },
 });
 
-cache.sub('station.resume', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		utils.emitToRoom(`station.${stationId}`, "event:stations.resume", { timePaused: station.timePaused });
-	});
+cache.runJob("SUB", {
+    channel: "station.resume",
+    cb: (stationId) => {
+        stations.runJob("GET_STATION", { stationId }).then((station) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: `station.${stationId}`,
+                args: [
+                    "event:stations.resume",
+                    { timePaused: station.timePaused },
+                ],
+            });
+        });
+    },
 });
 
-cache.sub('station.queueUpdate', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		if (!err) utils.emitToRoom(`station.${stationId}`, "event:queue.update", station.queue);
-	});
+cache.runJob("SUB", {
+    channel: "station.queueUpdate",
+    cb: (stationId) => {
+        stations.runJob("GET_STATION", { stationId }).then((station) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: `station.${stationId}`,
+                args: ["event:queue.update", station.queue],
+            });
+        });
+    },
 });
 
-cache.sub('station.voteSkipSong', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:song.voteSkipSong");
+cache.runJob("SUB", {
+    channel: "station.voteSkipSong",
+    cb: (stationId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:song.voteSkipSong"],
+        });
+    },
 });
 
-cache.sub('station.remove', stationId => {
-	utils.emitToRoom(`station.${stationId}`, 'event:stations.remove');
-	utils.emitToRoom('admin.stations', 'event:admin.station.removed', stationId);
+cache.runJob("SUB", {
+    channel: "station.remove",
+    cb: (stationId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:stations.remove"],
+        });
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.stations",
+            args: ["event:admin.station.removed", stationId],
+        });
+    },
 });
 
-cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, async (err, station) => {
-		station.userCount = usersPerStationCount[stationId] || 0;
-		if (err) console.error(err);
-		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
-		// TODO If community, check if on whitelist
-		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
-		else {
-			let sockets = await utils.getRoomSockets('home');
-			for (let socketId in sockets) {
-				let socket = sockets[socketId];
-				let session = sockets[socketId].session;
-				if (session.sessionId) {
-					cache.hget('sessions', session.sessionId, (err, session) => {
-						if (!err && session) {
-							db.models.user.findOne({_id: session.userId}, (err, user) => {
-								if (user.role === 'admin') socket.emit("event:stations.created", station);
-								else if (station.type === "community" && station.owner === session.userId) socket.emit("event:stations.created", station);
-							});
-						}
-					});
-				}
-			}
-		}
-	});
+cache.runJob("SUB", {
+    channel: "station.create",
+    cb: async (stationId) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
+        stations
+            .runJob("INITIALIZE_STATION", { stationId })
+            .then(async (station) => {
+                station.userCount = usersPerStationCount[stationId] || 0;
+                utils.runJob("EMIT_TO_ROOM", {
+                    room: "admin.stations",
+                    args: ["event:admin.station.added", station],
+                });
+                // TODO If community, check if on whitelist
+                if (station.privacy === "public")
+                    utils.runJob("EMIT_TO_ROOM", {
+                        room: "home",
+                        args: ["event:stations.created", station],
+                    });
+                else {
+                    let sockets = await utils.runJob("GET_ROOM_SOCKETS", {
+                        room: "home",
+                    });
+                    for (let socketId in sockets) {
+                        let socket = sockets[socketId];
+                        let session = sockets[socketId].session;
+                        if (session.sessionId) {
+                            cache
+                                .runJob("HGET", {
+                                    table: "sessions",
+                                    key: session.sessionId,
+                                })
+                                .then(async (session) => {
+                                    if (session) {
+                                        const userModel = await db.runJob(
+                                            "GET_MODEL",
+                                            {}
+                                        );
+                                        userModel.findOne(
+                                            { _id: session.userId },
+                                            (err, user) => {
+                                                if (user.role === "admin")
+                                                    socket.emit(
+                                                        "event:stations.created",
+                                                        station
+                                                    );
+                                                else if (
+                                                    station.type ===
+                                                        "community" &&
+                                                    station.owner ===
+                                                        session.userId
+                                                )
+                                                    socket.emit(
+                                                        "event:stations.created",
+                                                        station
+                                                    );
+                                            }
+                                        );
+                                    }
+                                });
+                        }
+                    }
+                }
+            });
+    },
 });
 
 module.exports = {
-
-	/**
-	 * Get a list of all the stations
-	 *
-	 * @param session
-	 * @param cb
-	 * @return {{ status: String, stations: Array }}
-	 */
-	index: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('stations', next);
-			},
-
-			(stations, next) => {
-				let resultStations = [];
-				for (let id in stations) {
-					resultStations.push(stations[id]);
-				}
-				next(null, stations);
-			},
-
-			(stationsArray, next) => {
-				let resultStations = [];
-				async.each(stationsArray, (station, next) => {
-					async.waterfall([
-						(next) => {
-							stations.canUserViewStation(station, session.userId, (err, exists) => {
-								next(err, exists);
-							});
-						}
-					], (err, exists) => {
-						station.userCount = usersPerStationCount[station._id] || 0;
-						if (exists) resultStations.push(station);
-						next();
-					});
-				}, () => {
-					next(null, resultStations);
-				});
-			}
-		], async (err, stations) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_INDEX", `Indexing stations successful.`, false);
-			return cb({'status': 'success', 'stations': stations});
-		});
-	},
-
-	/**
-	 * Obtains basic metadata of a station in order to format an activity
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	getStationForActivity: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_GET_STATION_FOR_ACTIVITY", `Failed to obtain metadata of station ${stationId} for activity formatting. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("STATIONS_GET_STATION_FOR_ACTIVITY", `Obtained metadata of station ${stationId} for activity formatting successfully.`);
-				return cb({ status: "success", data: {
-					title: station.displayName,
-					thumbnail: station.currentSong ? station.currentSong.thumbnail : ""
-				} });
-			}
-		});
-	},
-
-	/**
-	 * Verifies that a station exists
-	 *
-	 * @param session
-	 * @param stationName - the station name
-	 * @param cb
-	 */
-	existsByName: (session, stationName, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStationByName(stationName, next);
-			},
-
-			(station, next) => {
-				if (!station) return next(null, false);
-				stations.canUserViewStation(station, session.userId, (err, exists) => {
-					next(err, exists);
-				});
-			}
-		], async (err, exists) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATION_EXISTS_BY_NAME", `Checking if station "${stationName}" exists failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATION_EXISTS_BY_NAME", `Station "${stationName}" exists successfully.`/*, false*/);
-			cb({status: 'success', exists});
-		});
-	},
-
-	/**
-	 * Gets the official playlist for a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	getPlaylist: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				else if (station.type !== 'official') return next('This is not an official station.');
-				else next();
-			},
-
-			(next) => {
-				cache.hget('officialPlaylists', stationId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist) return next('Playlist not found.');
-				next(null, playlist);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("STATIONS_GET_PLAYLIST", `Got playlist for station "${stationId}" successfully.`, false);
-				cb({ status: 'success', data: playlist.songs });
-			}
-		});
-	},
-
-	/**
-	 * Joins the station by its name
-	 *
-	 * @param session
-	 * @param stationName - the station name
-	 * @param cb
-	 * @return {{ status: String, userCount: Integer }}
-	 */
-	join: (session, stationName, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStationByName(stationName, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (!canView) next("Not allowed to join station.");
-					else next(null, station);
-				});
-			},
-
-			(station, next) => {
-				utils.socketJoinRoom(session.socketId, `station.${station._id}`);
-				let data = {
-					_id: station._id,
-					type: station.type,
-					currentSong: station.currentSong,
-					startedAt: station.startedAt,
-					paused: station.paused,
-					timePaused: station.timePaused,
-					pausedAt: station.pausedAt,
-					description: station.description,
-					displayName: station.displayName,
-					privacy: station.privacy,
-					locked: station.locked,
-					partyMode: station.partyMode,
-					owner: station.owner,
-					privatePlaylist: station.privatePlaylist
-				};
-				userList[session.socketId] = station._id;
-				next(null, data);
-			},
-
-			(data, next) => {
-				data = JSON.parse(JSON.stringify(data));
-				data.userCount = usersPerStationCount[data._id] || 0;
-				data.users = usersPerStation[data._id] || [];
-				if (!data.currentSong || !data.currentSong.title) return next(null, data);
-				utils.socketJoinSongRoom(session.socketId, `song.${data.currentSong.songId}`);
-				data.currentSong.skipVotes = data.currentSong.skipVotes.length;
-				songs.getSongFromId(data.currentSong.songId, (err, song) => {
-					if (!err && song) {
-						data.currentSong.likes = song.likes;
-						data.currentSong.dislikes = song.dislikes;
-					} else {
-						data.currentSong.likes = -1;
-						data.currentSong.dislikes = -1;
-					}
-					next(null, data);
-				});
-			}
-		], async (err, data) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_JOIN", `Joined station "${data._id}" successfully.`);
-			cb({status: 'success', data});
-		});
-	},
-
-	/**
-	 * Toggles if a station is locked
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	toggleLock: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				db.models.station.updateOne({ _id: stationId }, { $set: { locked: !station.locked} }, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_LOCKED_STATUS", `Toggling the queue lock for station "${stationId}" failed. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("STATIONS_UPDATE_LOCKED_STATUS", `Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`);
-				cache.pub('station.queueLockToggled', {stationId, locked: station.locked});
-				return cb({ status: 'success', data: station.locked });
-			}
-		});
-	}),
-
-	/**
-	 * Votes to skip a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	voteSkip: hooks.loginRequired((session, stationId, cb) => {
-		let skipVotes = 0;
-		let shouldSkip = false;
-
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(station, next) => {
-				if (!station.currentSong) return next('There is currently no song to skip.');
-				if (station.currentSong.skipVotes.indexOf(session.userId) !== -1) return next('You have already voted to skip this song.');
-				next(null, station);
-			},
-
-			(station, next) => {
-				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": session.userId}}, next)
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				next(null, station);
-			},
-
-			(station, next) => {
-				skipVotes = station.currentSong.skipVotes.length;
-				utils.getRoomSockets(`station.${stationId}`).then(sockets => {
-					next(null, sockets);
-				}).catch(next);
-			},
-
-			(sockets, next) => {
-				if (sockets.length <= skipVotes) shouldSkip = true;
-				next();
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_VOTE_SKIP", `Vote skipping "${stationId}" successful.`);
-			cache.pub('station.voteSkipSong', stationId);
-			cb({ status: 'success', message: 'Successfully voted to skip the song.' });
-			if (shouldSkip) stations.skipStation(stationId)();
-		});
-	}),
-
-	/**
-	 * Force skips a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	forceSkip: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			notifications.unschedule(`stations.nextSong?id=${stationId}`);
-			stations.skipStation(stationId)();
-			logger.success("STATIONS_FORCE_SKIP", `Force skipped station "${stationId}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully skipped station.'});
-		});
-	}),
-
-	/**
-	 * Leaves the user's current station
-	 *
-	 * @param session
-	 * @param stationId
-	 * @param cb
-	 * @return {{ status: String, userCount: Integer }}
-	 */
-	leave: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				next();
-			}
-		], async (err, userCount) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_LEAVE", `Left station "${stationId}" successfully.`);
-			utils.socketLeaveRooms(session);
-			delete userList[session.socketId];
-			return cb({'status': 'success', 'message': 'Successfully left station.', userCount});
-		});
-	},
-
-	/**
-	 * Updates a station's name
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newName - the new station name
-	 * @param cb
-	 */
-	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_NAME", `Updating station "${stationId}" name to "${newName}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_NAME", `Updated station "${stationId}" name to "${newName}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully updated the name.'});
-		});
-	}),
-
-	/**
-	 * Updates a station's display name
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newDisplayName - the new station display name
-	 * @param cb
-	 */
-	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_DISPLAY_NAME", `Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully updated the display name.'});
-		});
-	}),
-
-	/**
-	 * Updates a station's description
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newDescription - the new station description
-	 * @param cb
-	 */
-	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_DESCRIPTION", `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_DESCRIPTION", `Updated station "${stationId}" description to "${newDescription}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully updated the description.'});
-		});
-	}),
-
-	/**
-	 * Updates a station's privacy
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newPrivacy - the new station privacy
-	 * @param cb
-	 */
-	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_PRIVACY", `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_PRIVACY", `Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully updated the privacy.'});
-		});
-	}),
-
-	/**
-	 * Updates a station's genres
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newGenres - the new station genres
-	 * @param cb
-	 */
-	updateGenres: hooks.ownerRequired((session, stationId, newGenres, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {genres: newGenres}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_GENRES", `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_GENRES", `Updated station "${stationId}" genres to "${newGenres}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully updated the genres.'});
-		});
-	}),
-
-	/**
-	 * Updates a station's blacklisted genres
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newBlacklistedGenres - the new station blacklisted genres
-	 * @param cb
-	 */
-	updateBlacklistedGenres: hooks.ownerRequired((session, stationId, newBlacklistedGenres, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {blacklistedGenres: newBlacklistedGenres}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully updated the blacklisted genres.'});
-		});
-	}),
-
-	/**
-	 * Updates a station's party mode
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newPartyMode - the new station party mode
-	 * @param cb
-	 */
-	updatePartyMode: hooks.ownerRequired((session, stationId, newPartyMode, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.partyMode === newPartyMode) return next('The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.'));
-				db.models.station.updateOne({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_PARTY_MODE", `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_PARTY_MODE", `Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`);
-			cache.pub('station.updatePartyMode', {stationId: stationId, partyMode: newPartyMode});
-			stations.skipStation(stationId)();
-			return cb({'status': 'success', 'message': 'Successfully updated the party mode.'});
-		});
-	}),
-
-	/**
-	 * Pauses a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	pause: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.paused) return next('That station was already paused.');
-				db.models.station.updateOne({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_PAUSE", `Paused station "${stationId}" successfully.`);
-			cache.pub('station.pause', stationId);
-			notifications.unschedule(`stations.nextSong?id=${stationId}`);
-			return cb({'status': 'success', 'message': 'Successfully paused.'});
-		});
-	}),
-
-	/**
-	 * Resumes a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	resume: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (!station.paused) return next('That station is not paused.');
-				station.timePaused += (Date.now() - station.pausedAt);
-				db.models.station.updateOne({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_RESUME", `Resuming station "${stationId}" successfully.`);
-			cache.pub('station.resume', stationId);
-			return cb({'status': 'success', 'message': 'Successfully resumed.'});
-		});
-	}),
-
-	/**
-	 * Removes a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	remove: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.deleteOne({ _id: stationId }, err => next(err));
-			},
-
-			(next) => {
-				cache.hdel('stations', stationId, err => next(err));
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			logger.success("STATIONS_REMOVE", `Removing station "${stationId}" successfully.`);
-			cache.pub('station.remove', stationId);
-			activities.addActivity(session.userId, "deleted_station", [ stationId ]);
-			return cb({ 'status': 'success', 'message': 'Successfully removed.' });
-		});
-	}),
-
-	/**
-	 * Create a station
-	 *
-	 * @param session
-	 * @param data - the station data
-	 * @param cb
-	 */
-	create: hooks.loginRequired((session, data, cb) => {
-		data.name = data.name.toLowerCase();
-		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth", "reset_password"];
-		async.waterfall([
-			(next) => {
-				if (!data) return next('Invalid data.');
-				next();
-			},
-
-			(next) => {
-				db.models.station.findOne({ $or: [{name: data.name}, {displayName: new RegExp(`^${data.displayName}$`, 'i')}] }, next);
-			},
-
-			(station, next) => {
-				if (station) return next('A station with that name or display name already exists.');
-				const { name, displayName, description, genres, playlist, type, blacklistedGenres } = data;
-				if (type === 'official') {
-					db.models.user.findOne({_id: session.userId}, (err, user) => {
-						if (err) return next(err);
-						if (!user) return next('User not found.');
-						if (user.role !== 'admin') return next('Admin required.');
-						db.models.station.create({
-							name,
-							displayName,
-							description,
-							type,
-							privacy: 'private',
-							playlist,
-							genres,
-							blacklistedGenres,
-							currentSong: stations.defaultSong
-						}, next);
-					});
-				} else if (type === 'community') {
-					if (blacklist.indexOf(name) !== -1) return next('That name is blacklisted. Please use a different name.');
-					db.models.station.create({
-						name,
-						displayName,
-						description,
-						type,
-						privacy: 'private',
-						owner: session.userId,
-						queue: [],
-						currentSong: null
-					}, next);
-				}
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_CREATE", `Created station "${station._id}" successfully.`);
-			cache.pub('station.create', station._id);
-			activities.addActivity(session.userId, "created_station", [ station._id ]);
-			return cb({'status': 'success', 'message': 'Successfully created station.'});
-		});
-	}),
-
-	/**
-	 * Adds song to station queue
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	addToQueue: hooks.loginRequired((session, stationId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.locked) {
-					db.models.user.findOne({ _id: session.userId }, (err, user) => {
-						if (user.role !== 'admin' && station.owner !== session.userId) return next('Only owners and admins can add songs to a locked queue.');
-						else return next(null, station);
-					});
-				} else {
-					return next(null, station);
-				}
-			},
-
-			(station, next) => {
-				if (station.type !== 'community') return next('That station is not a community station.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(station, next) => {
-				if (station.currentSong && station.currentSong.songId === songId) return next('That song is currently playing.');
-				async.each(station.queue, (queueSong, next) => {
-					if (queueSong.songId === songId) return next('That song is already in the queue.');
-					next();
-				}, (err) => {
-					next(err, station);
-				});
-			},
-
-			(station, next) => {
-				songs.getSong(songId, (err, song) => {
-					if (!err && song) return next(null, song, station);
-					utils.getSongFromYouTube(songId, (song) => {
-						song.artists = [];
-						song.skipDuration = 0;
-						song.likes = -1;
-						song.dislikes = -1;
-						song.thumbnail = "empty";
-						song.explicit = false;
-						next(null, song, station);
-					});
-				});
-			},
-
-			(song, station, next) => {
-				let queue = station.queue;
-				song.requestedBy = session.userId;
-				queue.push(song);
-
-				let totalDuration = 0;
-				queue.forEach((song) => {
-					totalDuration += song.duration;
-				});
-				if (totalDuration >= 3600 * 3) return next('The max length of the queue is 3 hours.');
-				next(null, song, station);
-			},
-
-			(song, station, next) => {
-				let queue = station.queue;
-				if (queue.length === 0) return next(null, song, station);
-				let totalDuration = 0;
-				const userId = queue[queue.length - 1].requestedBy;
-				station.queue.forEach((song) => {
-					if (userId === song.requestedBy) {
-						totalDuration += song.duration;
-					}
-				});
-
-				if(totalDuration >= 900) return next('The max length of songs per user is 15 minutes.');
-				next(null, song, station);
-			},
-
-			(song, station, next) => {
-				let queue = station.queue;
-				if (queue.length === 0) return next(null, song);
-				let totalSongs = 0;
-				const userId = queue[queue.length - 1].requestedBy;
-				queue.forEach((song) => {
-					if (userId === song.requestedBy) {
-						totalSongs++;
-					}
-				});
-
-				if (totalSongs <= 2) return next(null, song);
-				if (totalSongs > 3) return next('The max amount of songs per user is 3, and only 2 in a row is allowed.');
-				if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return next('The max amount of songs per user is 3, and only 2 in a row is allowed.');
-				next(null, song);
-			},
-
-			(song, next) => {
-				db.models.station.updateOne({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_ADD_SONG_TO_QUEUE", `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_ADD_SONG_TO_QUEUE", `Added song "${songId}" to station "${stationId}" successfully.`);
-			cache.pub('station.queueUpdate', stationId);
-			return cb({'status': 'success', 'message': 'Successfully added song to queue.'});
-		});
-	}),
-
-	/**
-	 * Removes song from station queue
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!songId) return next('Invalid song id.');
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type !== 'community') return next('Station is not a community station.');
-				async.each(station.queue, (queueSong, next) => {
-					if (queueSong.songId === songId) return next(true);
-					next();
-				}, (err) => {
-					if (err === true) return next();
-					next('Song is not currently in the queue.');
-				});
-			},
-
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_REMOVE_SONG_TO_QUEUE", `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_REMOVE_SONG_TO_QUEUE", `Removed song "${songId}" from station "${stationId}" successfully.`);
-			cache.pub('station.queueUpdate', stationId);
-			return cb({'status': 'success', 'message': 'Successfully removed song from queue.'});
-		});
-	}),
-
-	/**
-	 * Gets the queue from a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	getQueue: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type !== 'community') return next('Station is not a community station.');
-				next(null, station);
-			},
-
-			(station, next) => {
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_GET_QUEUE", `Got queue for station "${stationId}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully got queue.', queue: station.queue});
-		});
-	},
-
-	/**
-	 * Selects a private playlist for a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param playlistId - the private playlist id
-	 * @param cb
-	 */
-	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type !== 'community') return next('Station is not a community station.');
-				if (station.privatePlaylist === playlistId) return next('That private playlist is already selected.');
-				db.models.playlist.findOne({_id: playlistId}, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist) return next('Playlist not found.');
-				let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
-				db.models.station.updateOne({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selected private playlist "${playlistId}" for station "${stationId}" successfully.`);
-			notifications.unschedule(`stations.nextSong?id${stationId}`);
-			if (!station.partyMode) stations.skipStation(stationId)();
-			cache.pub('privatePlaylist.selected', {playlistId, stationId});
-			return cb({'status': 'success', 'message': 'Successfully selected playlist.'});
-		});
-	}),
-
-	favoriteStation: hooks.loginRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next();
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(next) => {
-				db.models.user.updateOne({ _id: session.userId }, { $addToSet: { favoriteStations: stationId } }, next);
-			},
-
-			(res, next) => {
-				if (res.nModified === 0) return next("The station was already favorited.");
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
-			cache.pub('user.favoritedStation', { userId: session.userId, stationId });
-			return cb({'status': 'success', 'message': 'Succesfully favorited station.'});
-		});
-	}),
-
-	unfavoriteStation: hooks.loginRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.updateOne({ _id: session.userId }, { $pull: { favoriteStations: stationId } }, next);
-			},
-
-			(res, next) => {
-				if (res.nModified === 0) return next("The station wasn't favorited.");
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
-			cache.pub('user.unfavoritedStation', { userId: session.userId, stationId });
-			return cb({'status': 'success', 'message': 'Succesfully unfavorited station.'});
-		});
-	}),
+    /**
+     * Get a list of all the stations
+     *
+     * @param session
+     * @param cb
+     * @return {{ status: String, stations: Array }}
+     */
+    index: (session, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGETALL", { table: "stations" })
+                        .then((stations) => {
+                            next(null, stations);
+                        });
+                },
+
+                (stations, next) => {
+                    let resultStations = [];
+                    for (let id in stations) {
+                        resultStations.push(stations[id]);
+                    }
+                    next(null, stations);
+                },
+
+                (stationsArray, next) => {
+                    let resultStations = [];
+                    async.each(
+                        stationsArray,
+                        (station, next) => {
+                            async.waterfall(
+                                [
+                                    (next) => {
+                                        stations
+                                            .runJob("CAN_USER_VIEW_STATION", {
+                                                station,
+                                                userId: session.userId,
+                                            })
+                                            .then((exists) => {
+                                                next(null, exists);
+                                            })
+                                            .catch(next);
+                                    },
+                                ],
+                                (err, exists) => {
+                                    station.userCount =
+                                        usersPerStationCount[station._id] || 0;
+                                    if (exists) resultStations.push(station);
+                                    next();
+                                }
+                            );
+                        },
+                        () => {
+                            next(null, resultStations);
+                        }
+                    );
+                },
+            ],
+            async (err, stations) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_INDEX",
+                        `Indexing stations failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_INDEX",
+                    `Indexing stations successful.`,
+                    false
+                );
+                return cb({ status: "success", stations: stations });
+            }
+        );
+    },
+
+    /**
+     * Obtains basic metadata of a station in order to format an activity
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    getStationForActivity: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_GET_STATION_FOR_ACTIVITY",
+                        `Failed to obtain metadata of station ${stationId} for activity formatting. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_GET_STATION_FOR_ACTIVITY",
+                        `Obtained metadata of station ${stationId} for activity formatting successfully.`
+                    );
+                    return cb({
+                        status: "success",
+                        data: {
+                            title: station.displayName,
+                            thumbnail: station.currentSong
+                                ? station.currentSong.thumbnail
+                                : "",
+                        },
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Verifies that a station exists
+     *
+     * @param session
+     * @param stationName - the station name
+     * @param cb
+     */
+    existsByName: (session, stationName, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION_BY_NAME", { stationName })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next(null, false);
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((exists) => next(null, exists))
+                        .catch(next);
+                },
+            ],
+            async (err, exists) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATION_EXISTS_BY_NAME",
+                        `Checking if station "${stationName}" exists failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATION_EXISTS_BY_NAME",
+                    `Station "${stationName}" exists successfully.` /*, false*/
+                );
+                cb({ status: "success", exists });
+            }
+        );
+    },
+
+    /**
+     * Gets the official playlist for a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    getPlaylist: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    else if (station.type !== "official")
+                        return next("This is not an official station.");
+                    else next();
+                },
+
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "officialPlaylists",
+                            key: stationId,
+                        })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (playlist, next) => {
+                    if (!playlist) return next("Playlist not found.");
+                    next(null, playlist);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_GET_PLAYLIST",
+                        `Getting playlist for station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_GET_PLAYLIST",
+                        `Got playlist for station "${stationId}" successfully.`,
+                        false
+                    );
+                    cb({ status: "success", data: playlist.songs });
+                }
+            }
+        );
+    },
+
+    /**
+     * Joins the station by its name
+     *
+     * @param session
+     * @param stationName - the station name
+     * @param cb
+     * @return {{ status: String, userCount: Integer }}
+     */
+    join: (session, stationName, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION_BY_NAME", { stationName })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (!canView) next("Not allowed to join station.");
+                            else next(null, station);
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    utils.runJob("SOCKET_JOIN_ROOM", {
+                        socketId: session.socketId,
+                        room: `station.${station._id}`,
+                    });
+                    let data = {
+                        _id: station._id,
+                        type: station.type,
+                        currentSong: station.currentSong,
+                        startedAt: station.startedAt,
+                        paused: station.paused,
+                        timePaused: station.timePaused,
+                        pausedAt: station.pausedAt,
+                        description: station.description,
+                        displayName: station.displayName,
+                        privacy: station.privacy,
+                        locked: station.locked,
+                        partyMode: station.partyMode,
+                        owner: station.owner,
+                        privatePlaylist: station.privatePlaylist,
+                    };
+                    userList[session.socketId] = station._id;
+                    next(null, data);
+                },
+
+                (data, next) => {
+                    data = JSON.parse(JSON.stringify(data));
+                    data.userCount = usersPerStationCount[data._id] || 0;
+                    data.users = usersPerStation[data._id] || [];
+                    if (!data.currentSong || !data.currentSong.title)
+                        return next(null, data);
+                    utils.runJob("SOCKET_JOIN_SONG_ROOM", {
+                        socketId: session.socketId,
+                        room: `song.${data.currentSong.songId}`,
+                    });
+                    data.currentSong.skipVotes =
+                        data.currentSong.skipVotes.length;
+                    songs
+                        .runJob("GET_SONG_FROM_ID", {
+                            songId: data.currentSong.songId,
+                        })
+                        .then((song) => {
+                            if (song) {
+                                data.currentSong.likes = song.likes;
+                                data.currentSong.dislikes = song.dislikes;
+                            } else {
+                                data.currentSong.likes = -1;
+                                data.currentSong.dislikes = -1;
+                            }
+                        })
+                        .catch((err) => {
+                            data.currentSong.likes = -1;
+                            data.currentSong.dislikes = -1;
+                        })
+                        .finally(() => {
+                            next(null, data);
+                        });
+                },
+            ],
+            async (err, data) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_JOIN",
+                        `Joining station "${stationName}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_JOIN",
+                    `Joined station "${data._id}" successfully.`
+                );
+                cb({ status: "success", data });
+            }
+        );
+    },
+
+    /**
+     * Toggles if a station is locked
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    toggleLock: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $set: { locked: !station.locked } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_UPDATE_LOCKED_STATUS",
+                        `Toggling the queue lock for station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_LOCKED_STATUS",
+                        `Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "station.queueLockToggled",
+                        value: {
+                            stationId,
+                            locked: station.locked,
+                        },
+                    });
+                    return cb({ status: "success", data: station.locked });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Votes to skip a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    voteSkip: hooks.loginRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+
+        let skipVotes = 0;
+        let shouldSkip = false;
+
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    if (!station.currentSong)
+                        return next("There is currently no song to skip.");
+                    if (
+                        station.currentSong.skipVotes.indexOf(
+                            session.userId
+                        ) !== -1
+                    )
+                        return next(
+                            "You have already voted to skip this song."
+                        );
+                    next(null, station);
+                },
+
+                (station, next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $push: { "currentSong.skipVotes": session.userId } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    next(null, station);
+                },
+
+                (station, next) => {
+                    skipVotes = station.currentSong.skipVotes.length;
+                    utils
+                        .runJob("GET_ROOM_SOCKETS", {
+                            room: `station.${stationId}`,
+                        })
+                        .then((sockets) => next(null, sockets))
+                        .catch(next);
+                },
+
+                (sockets, next) => {
+                    if (sockets.length <= skipVotes) shouldSkip = true;
+                    next();
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_VOTE_SKIP",
+                        `Vote skipping station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_VOTE_SKIP",
+                    `Vote skipping "${stationId}" successful.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.voteSkipSong",
+                    value: stationId,
+                });
+                cb({
+                    status: "success",
+                    message: "Successfully voted to skip the song.",
+                });
+                if (shouldSkip) stations.runJob("SKIP_STATION", { stationId });
+            }
+        );
+    }),
+
+    /**
+     * Force skips a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    forceSkip: hooks.ownerRequired((session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_FORCE_SKIP",
+                        `Force skipping station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                notifications.runJob("UNSCHEDULE", {
+                    name: `stations.nextSong?id=${stationId}`,
+                });
+                stations.runJob("SKIP_STATION", { stationId });
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_FORCE_SKIP",
+                    `Force skipped station "${stationId}" successfully.`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully skipped station.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Leaves the user's current station
+     *
+     * @param session
+     * @param stationId
+     * @param cb
+     * @return {{ status: String, userCount: Integer }}
+     */
+    leave: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    next();
+                },
+            ],
+            async (err, userCount) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_LEAVE",
+                        `Leaving station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_LEAVE",
+                    `Left station "${stationId}" successfully.`
+                );
+                utils.runJob("SOCKET_LEAVE_ROOMS", { socketId: session });
+                delete userList[session.socketId];
+                return cb({
+                    status: "success",
+                    message: "Successfully left station.",
+                    userCount,
+                });
+            }
+        );
+    },
+
+    /**
+     * Updates a station's name
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newName - the new station name
+     * @param cb
+     */
+    updateName: hooks.ownerRequired(async (session, stationId, newName, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $set: { name: newName } },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_UPDATE_NAME",
+                        `Updating station "${stationId}" name to "${newName}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_UPDATE_NAME",
+                    `Updated station "${stationId}" name to "${newName}" successfully.`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully updated the name.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Updates a station's display name
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newDisplayName - the new station display name
+     * @param cb
+     */
+    updateDisplayName: hooks.ownerRequired(
+        async (session, stationId, newDisplayName, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { displayName: newDisplayName } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_DISPLAY_NAME",
+                            `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_DISPLAY_NAME",
+                        `Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the display name.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a station's description
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newDescription - the new station description
+     * @param cb
+     */
+    updateDescription: hooks.ownerRequired(
+        async (session, stationId, newDescription, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { description: newDescription } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_DESCRIPTION",
+                            `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_DESCRIPTION",
+                        `Updated station "${stationId}" description to "${newDescription}" successfully.`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the description.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a station's privacy
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newPrivacy - the new station privacy
+     * @param cb
+     */
+    updatePrivacy: hooks.ownerRequired(
+        async (session, stationId, newPrivacy, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { privacy: newPrivacy } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_PRIVACY",
+                            `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_PRIVACY",
+                        `Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the privacy.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a station's genres
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newGenres - the new station genres
+     * @param cb
+     */
+    updateGenres: hooks.ownerRequired(
+        async (session, stationId, newGenres, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { genres: newGenres } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_GENRES",
+                            `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_GENRES",
+                        `Updated station "${stationId}" genres to "${newGenres}" successfully.`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the genres.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a station's blacklisted genres
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newBlacklistedGenres - the new station blacklisted genres
+     * @param cb
+     */
+    updateBlacklistedGenres: hooks.ownerRequired(
+        async (session, stationId, newBlacklistedGenres, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            {
+                                $set: {
+                                    blacklistedGenres: newBlacklistedGenres,
+                                },
+                            },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_BLACKLISTED_GENRES",
+                            `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_BLACKLISTED_GENRES",
+                        `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the blacklisted genres.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a station's party mode
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newPartyMode - the new station party mode
+     * @param cb
+     */
+    updatePartyMode: hooks.ownerRequired(
+        async (session, stationId, newPartyMode, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stations
+                            .runJob("GET_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+                        if (station.partyMode === newPartyMode)
+                            return next(
+                                "The party mode was already " +
+                                    (newPartyMode ? "enabled." : "disabled.")
+                            );
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { partyMode: newPartyMode } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_PARTY_MODE",
+                            `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_PARTY_MODE",
+                        `Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "station.updatePartyMode",
+                        value: {
+                            stationId: stationId,
+                            partyMode: newPartyMode,
+                        },
+                    });
+                    stations.runJob("SKIP_STATION", { stationId });
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the party mode.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Pauses a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    pause: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    if (station.paused)
+                        return next("That station was already paused.");
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $set: { paused: true, pausedAt: Date.now() } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_PAUSE",
+                        `Pausing station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_PAUSE",
+                    `Paused station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.pause",
+                    value: stationId,
+                });
+                notifications.runJob("UNSCHEDULE", {
+                    name: `stations.nextSong?id=${stationId}`,
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully paused.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Resumes a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    resume: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    if (!station.paused)
+                        return next("That station is not paused.");
+                    station.timePaused += Date.now() - station.pausedAt;
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        {
+                            $set: { paused: false },
+                            $inc: { timePaused: Date.now() - station.pausedAt },
+                        },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_RESUME",
+                        `Resuming station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_RESUME",
+                    `Resuming station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.resume",
+                    value: stationId,
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully resumed.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Removes a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    remove: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stationModel.deleteOne({ _id: stationId }, (err) =>
+                        next(err)
+                    );
+                },
+
+                (next) => {
+                    cache
+                        .runJob("HDEL", { table: "stations", key: stationId })
+                        .then(next)
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_REMOVE",
+                        `Removing station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_REMOVE",
+                    `Removing station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.remove",
+                    value: stationId,
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "deleted_station",
+                    payload: [stationId],
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully removed.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Create a station
+     *
+     * @param session
+     * @param data - the station data
+     * @param cb
+     */
+    create: hooks.loginRequired(async (session, data, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+
+        data.name = data.name.toLowerCase();
+        let blacklist = [
+            "country",
+            "edm",
+            "musare",
+            "hip-hop",
+            "rap",
+            "top-hits",
+            "todays-hits",
+            "old-school",
+            "christmas",
+            "about",
+            "support",
+            "staff",
+            "help",
+            "news",
+            "terms",
+            "privacy",
+            "profile",
+            "c",
+            "community",
+            "tos",
+            "login",
+            "register",
+            "p",
+            "official",
+            "o",
+            "trap",
+            "faq",
+            "team",
+            "donate",
+            "buy",
+            "shop",
+            "forums",
+            "explore",
+            "settings",
+            "admin",
+            "auth",
+            "reset_password",
+        ];
+        async.waterfall(
+            [
+                (next) => {
+                    if (!data) return next("Invalid data.");
+                    next();
+                },
+
+                (next) => {
+                    stationModel.findOne(
+                        {
+                            $or: [
+                                { name: data.name },
+                                {
+                                    displayName: new RegExp(
+                                        `^${data.displayName}$`,
+                                        "i"
+                                    ),
+                                },
+                            ],
+                        },
+                        next
+                    );
+                },
+
+                (station, next) => {
+                    if (station)
+                        return next(
+                            "A station with that name or display name already exists."
+                        );
+                    const {
+                        name,
+                        displayName,
+                        description,
+                        genres,
+                        playlist,
+                        type,
+                        blacklistedGenres,
+                    } = data;
+                    if (type === "official") {
+                        userModel.findOne(
+                            { _id: session.userId },
+                            (err, user) => {
+                                if (err) return next(err);
+                                if (!user) return next("User not found.");
+                                if (user.role !== "admin")
+                                    return next("Admin required.");
+                                stationModel.create(
+                                    {
+                                        name,
+                                        displayName,
+                                        description,
+                                        type,
+                                        privacy: "private",
+                                        playlist,
+                                        genres,
+                                        blacklistedGenres,
+                                        currentSong: stations.defaultSong,
+                                    },
+                                    next
+                                );
+                            }
+                        );
+                    } else if (type === "community") {
+                        if (blacklist.indexOf(name) !== -1)
+                            return next(
+                                "That name is blacklisted. Please use a different name."
+                            );
+                        stationModel.create(
+                            {
+                                name,
+                                displayName,
+                                description,
+                                type,
+                                privacy: "private",
+                                owner: session.userId,
+                                queue: [],
+                                currentSong: null,
+                            },
+                            next
+                        );
+                    }
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_CREATE",
+                        `Creating station failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_CREATE",
+                    `Created station "${station._id}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.create",
+                    value: station._id,
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "created_station",
+                    payload: [station._id],
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully created station.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds song to station queue
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param songId - the song id
+     * @param cb
+     */
+    addToQueue: hooks.loginRequired(async (session, stationId, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    if (station.locked) {
+                        userModel.findOne(
+                            { _id: session.userId },
+                            (err, user) => {
+                                if (
+                                    user.role !== "admin" &&
+                                    station.owner !== session.userId
+                                )
+                                    return next(
+                                        "Only owners and admins can add songs to a locked queue."
+                                    );
+                                else return next(null, station);
+                            }
+                        );
+                    } else {
+                        return next(null, station);
+                    }
+                },
+
+                (station, next) => {
+                    if (station.type !== "community")
+                        return next("That station is not a community station.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    if (
+                        station.currentSong &&
+                        station.currentSong.songId === songId
+                    )
+                        return next("That song is currently playing.");
+                    async.each(
+                        station.queue,
+                        (queueSong, next) => {
+                            if (queueSong.songId === songId)
+                                return next(
+                                    "That song is already in the queue."
+                                );
+                            next();
+                        },
+                        (err) => {
+                            next(err, station);
+                        }
+                    );
+                },
+
+                (station, next) => {
+                    songs
+                        .runJob("GET_SONG", { songId })
+                        .then((song) => {
+                            if (song) return next(null, song, station);
+                            else {
+                                utils
+                                    .runJob("GET_SONG_FROM_YOUTUBE", { songId })
+                                    .then((song) => {
+                                        song.artists = [];
+                                        song.skipDuration = 0;
+                                        song.likes = -1;
+                                        song.dislikes = -1;
+                                        song.thumbnail = "empty";
+                                        song.explicit = false;
+                                        next(null, song, station);
+                                    })
+                                    .catch((err) => {
+                                        next(err);
+                                    });
+                            }
+                        })
+                        .catch((err) => {
+                            next(err);
+                        });
+                },
+
+                (song, station, next) => {
+                    let queue = station.queue;
+                    song.requestedBy = session.userId;
+                    queue.push(song);
+
+                    let totalDuration = 0;
+                    queue.forEach((song) => {
+                        totalDuration += song.duration;
+                    });
+                    if (totalDuration >= 3600 * 3)
+                        return next("The max length of the queue is 3 hours.");
+                    next(null, song, station);
+                },
+
+                (song, station, next) => {
+                    let queue = station.queue;
+                    if (queue.length === 0) return next(null, song, station);
+                    let totalDuration = 0;
+                    const userId = queue[queue.length - 1].requestedBy;
+                    station.queue.forEach((song) => {
+                        if (userId === song.requestedBy) {
+                            totalDuration += song.duration;
+                        }
+                    });
+
+                    if (totalDuration >= 900)
+                        return next(
+                            "The max length of songs per user is 15 minutes."
+                        );
+                    next(null, song, station);
+                },
+
+                (song, station, next) => {
+                    let queue = station.queue;
+                    if (queue.length === 0) return next(null, song);
+                    let totalSongs = 0;
+                    const userId = queue[queue.length - 1].requestedBy;
+                    queue.forEach((song) => {
+                        if (userId === song.requestedBy) {
+                            totalSongs++;
+                        }
+                    });
+
+                    if (totalSongs <= 2) return next(null, song);
+                    if (totalSongs > 3)
+                        return next(
+                            "The max amount of songs per user is 3, and only 2 in a row is allowed."
+                        );
+                    if (
+                        queue[queue.length - 2].requestedBy !== userId ||
+                        queue[queue.length - 3] !== userId
+                    )
+                        return next(
+                            "The max amount of songs per user is 3, and only 2 in a row is allowed."
+                        );
+                    next(null, song);
+                },
+
+                (song, next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $push: { queue: song } },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_ADD_SONG_TO_QUEUE",
+                        `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_ADD_SONG_TO_QUEUE",
+                    `Added song "${songId}" to station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.queueUpdate",
+                    value: stationId,
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully added song to queue.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Removes song from station queue
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param songId - the song id
+     * @param cb
+     */
+    removeFromQueue: hooks.ownerRequired(
+        async (session, stationId, songId, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!songId) return next("Invalid song id.");
+                        stations
+                            .runJob("GET_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+                        if (station.type !== "community")
+                            return next("Station is not a community station.");
+                        async.each(
+                            station.queue,
+                            (queueSong, next) => {
+                                if (queueSong.songId === songId)
+                                    return next(true);
+                                next();
+                            },
+                            (err) => {
+                                if (err === true) return next();
+                                next("Song is not currently in the queue.");
+                            }
+                        );
+                    },
+
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $pull: { queue: { songId: songId } } },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err, station) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_REMOVE_SONG_TO_QUEUE",
+                            `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_REMOVE_SONG_TO_QUEUE",
+                        `Removed song "${songId}" from station "${stationId}" successfully.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "station.queueUpdate",
+                        value: stationId,
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Successfully removed song from queue.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Gets the queue from a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    getQueue: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    if (station.type !== "community")
+                        return next("Station is not a community station.");
+                    next(null, station);
+                },
+
+                (station, next) => {
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_GET_QUEUE",
+                        `Getting queue for station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_GET_QUEUE",
+                    `Got queue for station "${stationId}" successfully.`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully got queue.",
+                    queue: station.queue,
+                });
+            }
+        );
+    },
+
+    /**
+     * Selects a private playlist for a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param playlistId - the private playlist id
+     * @param cb
+     */
+    selectPrivatePlaylist: hooks.ownerRequired(
+        async (session, stationId, playlistId, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stations
+                            .runJob("GET_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+                        if (station.type !== "community")
+                            return next("Station is not a community station.");
+                        if (station.privatePlaylist === playlistId)
+                            return next(
+                                "That private playlist is already selected."
+                            );
+                        db.models.playlist.findOne({ _id: playlistId }, next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist) return next("Playlist not found.");
+                        let currentSongIndex =
+                            playlist.songs.length > 0
+                                ? playlist.songs.length - 1
+                                : 0;
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            {
+                                $set: {
+                                    privatePlaylist: playlistId,
+                                    currentSongIndex: currentSongIndex,
+                                },
+                            },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err, station) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_SELECT_PRIVATE_PLAYLIST",
+                            `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_SELECT_PRIVATE_PLAYLIST",
+                        `Selected private playlist "${playlistId}" for station "${stationId}" successfully.`
+                    );
+                    notifications.runJob("UNSCHEDULE", {
+                        name: `stations.nextSong?id${stationId}`,
+                    });
+                    if (!station.partyMode)
+                        stations.runJob("SKIP_STATION", { stationId });
+                    cache.runJob("PUB", {
+                        channel: "privatePlaylist.selected",
+                        value: {
+                            playlistId,
+                            stationId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Successfully selected playlist.",
+                    });
+                }
+            );
+        }
+    ),
+
+    favoriteStation: hooks.loginRequired(async (session, stationId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next();
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (next) => {
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $addToSet: { favoriteStations: stationId } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    if (res.nModified === 0)
+                        return next("The station was already favorited.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "FAVORITE_STATION",
+                        `Favoriting station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "FAVORITE_STATION",
+                    `Favorited station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "user.favoritedStation",
+                    value: {
+                        userId: session.userId,
+                        stationId,
+                    },
+                });
+                return cb({
+                    status: "success",
+                    message: "Succesfully favorited station.",
+                });
+            }
+        );
+    }),
+
+    unfavoriteStation: hooks.loginRequired(async (session, stationId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $pull: { favoriteStations: stationId } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    if (res.nModified === 0)
+                        return next("The station wasn't favorited.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UNFAVORITE_STATION",
+                        `Unfavoriting station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "UNFAVORITE_STATION",
+                    `Unfavorited station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "user.unfavoritedStation",
+                    value: {
+                        userId: session.userId,
+                        stationId,
+                    },
+                });
+                return cb({
+                    status: "success",
+                    message: "Succesfully unfavorited station.",
+                });
+            }
+        );
+    }),
 };

+ 2072 - 1315
backend/logic/actions/users.js

@@ -1,1335 +1,2092 @@
-'use strict';
-
-const async = require('async');
-const config = require('config');
-const request = require('request');
-const bcrypt = require('bcrypt');
-const sha256 = require('sha256');
-
-const hooks = require('./hooks');
-
-const moduleManager = require("../../index");
-
-const db = moduleManager.modules["db"];
-const mail = moduleManager.modules["mail"];
-const cache = moduleManager.modules["cache"];
-const punishments = moduleManager.modules["punishments"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const activities = moduleManager.modules["activities"];
-
-cache.sub('user.updateUsername', user => {
-	utils.socketsFromUser(user._id, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.username.changed', user.username);
-		});
-	});
+"use strict";
+
+const async = require("async");
+const config = require("config");
+const request = require("request");
+const bcrypt = require("bcrypt");
+const sha256 = require("sha256");
+
+const hooks = require("./hooks");
+
+// const moduleManager = require("../../index");
+
+const db = require("../db");
+const mail = require("../mail");
+const cache = require("../cache");
+const punishments = require("../punishments");
+const utils = require("../utils");
+// const logger = require("../logger");
+const activities = require("../activities");
+
+cache.runJob("SUB", {
+    channel: "user.updateUsername",
+    cb: (user) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: user._id,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:user.username.changed", user.username);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.removeSessions', userId => {
-	utils.socketsFromUserWithoutCache(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('keep.event:user.session.removed');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.removeSessions",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER_WITHOUT_CACHE", {
+            userId: userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("keep.event:user.session.removed");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.linkPassword', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.linkPassword');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.linkPassword",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:user.linkPassword");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.linkGitHub', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.linkGitHub');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.linkGitHub",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:user.linkGitHub");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.unlinkPassword', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.unlinkPassword');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.unlinkPassword",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:user.unlinkPassword");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.unlinkGitHub', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.unlinkGitHub');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.unlinkGitHub",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:user.unlinkGitHub");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.ban', data => {
-	utils.socketsFromUser(data.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('keep.event:banned', data.punishment);
-			socket.disconnect(true);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.ban",
+    cb: (data) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: data.userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("keep.event:banned", data.punishment);
+                    socket.disconnect(true);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.favoritedStation', data => {
-	utils.socketsFromUser(data.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.favoritedStation', data.stationId);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.favoritedStation",
+    cb: (data) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: data.userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit("event:user.favoritedStation", data.stationId);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.unfavoritedStation', data => {
-	utils.socketsFromUser(data.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.unfavoritedStation', data.stationId);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.unfavoritedStation",
+    cb: (data) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: data.userId,
+            cb: (sockets) => {
+                sockets.forEach((socket) => {
+                    socket.emit(
+                        "event:user.unfavoritedStation",
+                        data.stationId
+                    );
+                });
+            },
+        });
+    },
 });
 
 module.exports = {
-
-	/**
-	 * Lists all Users
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.find({}).exec(next);
-			}
-		], async (err, users) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
-				return cb({status: 'failure', message: err});
-			} else {
-				logger.success("USER_INDEX", `Indexing users successful.`);
-				let filteredUsers = [];
-				users.forEach(user => {
-					filteredUsers.push({
-						_id: user._id,
-						username: user.username,
-						role: user.role,
-						liked: user.liked,
-						disliked: user.disliked,
-						songsRequested: user.statistics.songsRequested,
-						email: {
-							address: user.email.address,
-							verified: user.email.verified
-						},
-						hasPassword: !!user.services.password,
-						services: { github: user.services.github }
-					});
-				});
-				return cb({ status: 'success', data: filteredUsers });
-			}
-		});
-	}),
-
-	/**
-	 * Logs user in
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} identifier - the email of the user
-	 * @param {String} password - the plaintext of the user
-	 * @param {Function} cb - gets called with the result
-	 */
-	login: (session, identifier, password, cb) => {
-
-		identifier = identifier.toLowerCase();
-
-		async.waterfall([
-
-			// check if a user with the requested identifier exists
-			(next) => {
-				db.models.user.findOne({
-					$or: [{ 'email.address': identifier }]
-				}, next)
-			},
-
-			// if the user doesn't exist, respond with a failure
-			// otherwise compare the requested password and the actual users password
-			(user, next) => {
-				if (!user) return next('User not found');
-				if (!user.services.password || !user.services.password.password) return next('The account you are trying to access uses GitHub to log in.');
-				bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
-					if (err) return next(err);
-					if (!match) return next('Incorrect password');
-					next(null, user);
-				});
-			},
-
-			(user, next) => {
-				utils.guid().then((sessionId) => {
-					next(null, user, sessionId);
-				});
-			},
-
-			(user, sessionId, next) => {
-				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
-					if (err) return next(err);
-					next(null, sessionId);
-				});
-			}
-
-		], async (err, sessionId) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success("USER_PASSWORD_LOGIN", `Login successful with password for user "${identifier}"`);
-			cb({ status: 'success', message: 'Login successful', user: {}, SID: sessionId });
-		});
-
-	},
-
-	/**
-	 * Registers a new user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} username - the username for the new user
-	 * @param {String} email - the email for the new user
-	 * @param {String} password - the plaintext password for the new user
-	 * @param {Object} recaptcha - the recaptcha data
-	 * @param {Function} cb - gets called with the result
-	 */
-	register: async function(session, username, email, password, recaptcha, cb) {
-
-		email = email.toLowerCase();
-		let verificationToken = await utils.generateRandomString(64);
-
-		async.waterfall([
-
-			// verify the request with google recaptcha
-			(next) => {
-				if (!db.passwordValid(password)) return next('Invalid password. Check if it meets all the requirements.');
-				return next();
-			},
-
-			(next) => {
-				request({
-					url: 'https://www.google.com/recaptcha/api/siteverify',
-					method: 'POST',
-					form: {
-						'secret': config.get("apis").recaptcha.secret,
-						'response': recaptcha
-					}
-				}, next);
-			},
-
-			// check if the response from Google recaptcha is successful
-			// if it is, we check if a user with the requested username already exists
-			(response, body, next) => {
-				let json = JSON.parse(body);
-				if (json.success !== true) return next('Response from recaptcha was not successful.');
-				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
-			},
-
-			// if the user already exists, respond with that
-			// otherwise check if a user with the requested email already exists
-			(user, next) => {
-				if (user) return next('A user with that username already exists.');
-				db.models.user.findOne({ 'email.address': email }, next);
-			},
-
-			// if the user already exists, respond with that
-			// otherwise, generate a salt to use with hashing the new users password
-			(user, next) => {
-				if (user) return next('A user with that email already exists.');
-				bcrypt.genSalt(10, next);
-			},
-
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(password), salt, next)
-			},
-
-			(hash, next) => {
-				utils.generateRandomString(12).then((_id) => {
-					next(null, hash, _id);
-				});
-			},
-
-			// create the user object
-			(hash, _id, next) => {
-				next(null, {
-					_id,
-					username,
-					email: {
-						address: email,
-						verificationToken
-					},
-					services: {
-						password: {
-							password: hash
-						}
-					}
-				});
-			},
-
-			// generate the url for gravatar avatar
-			(user, next) => {
-				utils.createGravatar(user.email.address).then(url => {
-					user.avatar = url;
-					next(null, user);
-				});
-			},
-
-			// save the new user to the database
-			(user, next) => {
-				db.models.user.create(user, next);
-			},
-
-			// respond with the new user
-			(newUser, next) => {
-				mail.schemas.verifyEmail(email, username, verificationToken, () => {
-					next(newUser);
-				});
-			}
-
-		], async (user, err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
-				return cb({status: 'failure', message: err});
-			} else {
-				module.exports.login(session, email, password, (result) => {
-					let obj = {status: 'success', message: 'Successfully registered.'};
-					if (result.status === 'success') {
-						obj.SID = result.SID;
-					}
-					activities.addActivity(user._id, "created_account");
-					logger.success("USER_PASSWORD_REGISTER", `Register successful with password for user "${username}".`);
-					return cb(obj);
-				});
-			}
-		});
-
-	},
-
-	/**
-	 * Logs out a user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	logout: (session, cb) => {
-
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found');
-				next(null, session);
-			},
-
-			(session, next) => {
-				cache.hdel('sessions', session.sessionId, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
-				cb({ status: 'failure', message: err });
-			} else {
-				logger.success("USER_LOGOUT", `Logout successful.`);
-				cb({ status: 'success', message: 'Successfully logged out.' });
-			}
-		});
-
-	},
-
-	/**
-	 * Removes all sessions for a user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} userId - the id of the user we are trying to delete the sessions of
-	 * @param {Function} cb - gets called with the result
-	 */
-	removeSessions:  hooks.loginRequired((session, userId, cb) => {
-
-		async.waterfall([
-
-			(next) => {
-				db.models.user.findOne({ _id: session.userId }, (err, user) => {
-					if (err) return next(err);
-					if (user.role !== 'admin' && session.userId !== userId) return next('Only admins and the owner of the account can remove their sessions.');
-					else return next();
-				});
-			},
-
-			(next) => {
-				cache.hgetall('sessions', next);
-			},
-
-			(sessions, next) => {
-				if (!sessions) return next('There are no sessions for this user to remove.');
-				else {
-					let keys = Object.keys(sessions);
-					next(null, keys, sessions);
-				}
-			},
-
-			(keys, sessions, next) => {
-				cache.pub('user.removeSessions', userId);
-				async.each(keys, (sessionId, callback) => {
-					let session = sessions[sessionId];
-					if (session.userId === userId) {
-						cache.hdel('sessions', sessionId, err => {
-							if (err) return callback(err);
-							else callback(null);
-						});
-					}
-				}, err => {
-					next(err);
-				});
-			}
-
-		], async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REMOVE_SESSIONS_FOR_USER", `Couldn't remove all sessions for user "${userId}". "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("REMOVE_SESSIONS_FOR_USER", `Removed all sessions for user "${userId}".`);
-				return cb({ status: 'success', message: 'Successfully removed all sessions.' });
-			}
-		});
-
-	}),
-
-	/**
-	 * Gets user object from username (only a few properties)
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} username - the username of the user we are trying to find
-	 * @param {Function} cb - gets called with the result
-	 */
-	findByUsername: (session, username, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
-			},
-
-			(account, next) => {
-				if (!account) return next('User not found.');
-				next(null, account);
-			}
-		], async (err, account) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("FIND_BY_USERNAME", `User found for username "${username}".`);
-				return cb({
-					status: 'success',
-					data: {
-						_id: account._id,
-						name: account.name,
-						username: account.username,
-						location: account.location,
-						bio: account.bio,
-						role: account.role,
-						avatar: account.avatar,
-						createdAt: account.createdAt
-					}
-				});
-			}
-		});
-	},
-
-
-	/**
-	 * Gets a username from an userId
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} userId - the userId of the person we are trying to get the username from
-	 * @param {Function} cb - gets called with the result
-	 */
-	getUsernameFromId: (session, userId, cb) => {
-		db.models.user.findById(userId).then(user => {
-			if (user) {
-				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
-				return cb({
-					status: 'success',
-					data: user.username
-				});
-			} else {
-				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. User not found.`);
-				cb({
-					status: 'failure',
-					message: "Couldn't find the user."
-				});
-			}
-			
-		}).catch(async err => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
-				cb({ status: 'failure', message: err });
-			}
-		});
-	},
-
-	//TODO Fix security issues
-	/**
-	 * Gets user info from session
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	findBySession: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found.');
-				next(null, session);
-			},
-
-			(session, next) => {
-				db.models.user.findOne({ _id: session.userId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				next(null, user);
-			}
-		], async (err, user) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
-				cb({ status: 'failure', message: err });
-			} else {
-				let data = {
-					email: {
-						address: user.email.address
-					},
-					avatar: user.avatar,
-					username: user.username,
-					name: user.name,
-					location: user.location,
-					bio: user.bio
-				};
-				if (user.services.password && user.services.password.password) data.password = true;
-				if (user.services.github && user.services.github.id) data.github = true;
-				logger.success("FIND_BY_SESSION", `User found. "${user.username}".`);
-				return cb({
-					status: 'success',
-					data
-				});
-			}
-		});
-	},
-
-	/**
-	 * Updates a user's username
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newUsername - the new username
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateUsername: hooks.loginRequired((session, updatingUserId, newUsername, cb) => {
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (user.username === newUsername) return next('New username can\'t be the same as the old username.');
-				next(null);
-			},
-
-			(next) => {
-				db.models.user.findOne({ username: new RegExp(`^${newUsername}$`, 'i') }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next();
-				if (user._id === updatingUserId) return next();
-				next('That username is already in use.');
-			},
-
-			(next) => {
-				db.models.user.updateOne({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_USERNAME", `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				cache.pub('user.updateUsername', {
-					username: newUsername,
-					_id: updatingUserId
-				});
-				logger.success("UPDATE_USERNAME", `Updated username for user "${updatingUserId}" to username "${newUsername}".`);
-				cb({ status: 'success', message: 'Username updated successfully' });
-			}
-		});
-	}),
-
-	/**
-	 * Updates a user's email
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newEmail - the new email
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateEmail: hooks.loginRequired(async (session, updatingUserId, newEmail, cb) => {
-		newEmail = newEmail.toLowerCase();
-		let verificationToken = await utils.generateRandomString(64);
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (user.email.address === newEmail) return next('New email can\'t be the same as your the old email.');
-				next();
-			},
-
-			(next) => {
-				db.models.user.findOne({"email.address": newEmail}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next();
-				if (user._id === updatingUserId) return next();
-				next('That email is already in use.');
-			},
-
-			// regenerate the url for gravatar avatar
-			(next) => {
-				utils.createGravatar(newEmail).then(url => next(null, url));
-			},
-
-			(avatar, next) => {
-				db.models.user.updateOne({ _id: updatingUserId }, {
-					$set: {
-						"avatar": avatar,
-						"email.address": newEmail,
-						"email.verified": false,
-						"email.verificationToken": verificationToken
-					}
-				}, { runValidators: true }, next);
-			},
-
-			(res, next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				mail.schemas.verifyEmail(newEmail, user.username, verificationToken, () => {
-					next();
-				});
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_EMAIL", `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_EMAIL", `Updated email for user "${updatingUserId}" to email "${newEmail}".`);
-				cb({ status: 'success', message: 'Email updated successfully.' });
-			}
-		});
-	}),
-
-	/**
-	 * Updates a user's name
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newBio - the new name
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateName: hooks.loginRequired((session, updatingUserId, newName, cb) => {
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				db.models.user.updateOne({ _id: updatingUserId }, {$set: { name: newName }}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_NAME", `Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_NAME", `Updated name for user "${updatingUserId}" to name "${newName}".`);
-				cb({ status: 'success', message: 'Name updated successfully' });
-			}
-		});
-	}),
-
-	/**
-	 * Updates a user's location
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newLocation - the new location
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateLocation: hooks.loginRequired((session, updatingUserId, newLocation, cb) => {
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				db.models.user.updateOne({ _id: updatingUserId }, {$set: {location: newLocation}}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_LOCATION", `Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_LOCATION", `Updated location for user "${updatingUserId}" to location "${newLocation}".`);
-				cb({ status: 'success', message: 'Location updated successfully' });
-			}
-		});
-	}),
-	
-	/**
-	 * Updates a user's bio
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newBio - the new bio
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateBio: hooks.loginRequired((session, updatingUserId, newBio, cb) => {
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				db.models.user.updateOne({ _id: updatingUserId }, {$set: {bio: newBio}}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_BIO", `Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_BIO", `Updated bio for user "${updatingUserId}" to bio "${newBio}".`);
-				cb({ status: 'success', message: 'Bio updated successfully' });
-			}
-		});
-	}),
-
-	/**
-	 * Updates the type of a user's avatar
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newType - the new type
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateAvatarType: hooks.loginRequired((session, updatingUserId, newType, cb) => {
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({ _id: session.userId }, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				db.models.user.updateOne({ _id: updatingUserId }, {$set: { "avatar.type": newType }}, { runValidators: true }, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_AVATAR_TYPE", `Couldn't update avatar type for user "${updatingUserId}" to type "${newType}". "${err}"`);
-				cb({ status: 'failure', message: err });
-			} else {
-				logger.success("UPDATE_AVATAR_TYPE", `Updated avatar type for user "${updatingUserId}" to type "${newType}".`);
-				cb({ status: 'success', message: 'Avatar type updated successfully' });
-			}
-		});
-	}),
-
-	/**
-	 * Updates a user's role
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newRole - the new role
-	 * @param {Function} cb - gets called with the result
-	 */
-	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb) => {
-		newRole = newRole.toLowerCase();
-		async.waterfall([
-
-			(next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				else if (user.role === newRole) return next('New role can\'t be the same as the old role.');
-				else return next();
-			},
-			(next) => {
-				db.models.user.updateOne({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
-			}
-
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_ROLE", `User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_ROLE", `User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`);
-				cb({
-					status: 'success',
-					message: 'Role successfully updated.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Updates a user's password
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} newPassword - the new password
-	 * @param {Function} cb - gets called with the result
-	 */
-	updatePassword: hooks.loginRequired((session, newPassword, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user.services.password) return next('This account does not have a password set.');
-				next();
-			},
-
-			(next) => {
-				if (!db.passwordValid(newPassword)) return next('Invalid password. Check if it meets all the requirements.');
-				return next();
-			},
-
-			(next) => {
-				bcrypt.genSalt(10, next);
-			},
-
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(newPassword), salt, next);
-			},
-
-			(hashedPassword, next) => {
-				db.models.user.updateOne({_id: session.userId}, {$set: {"services.password.password": hashedPassword}}, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${session.userId}'. '${err}'.`);
-				return cb({ status: 'failure', message: err });
-			}
-
-			logger.success("UPDATE_PASSWORD", `User '${session.userId}' updated their password.`);
-			cb({
-				status: 'success',
-				message: 'Password successfully updated.'
-			});
-		});
-	}),
-
-	/**
-	 * Requests a password for a session
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} email - the email of the user that requests a password reset
-	 * @param {Function} cb - gets called with the result
-	 */
-	requestPassword: hooks.loginRequired(async (session, cb) => {
-		let code = await utils.generateRandomString(8);
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (user.services.password && user.services.password.password) return next('You already have a password set.');
-				next(null, user);
-			},
-
-			(user, next) => {
-				let expires = new Date();
-				expires.setDate(expires.getDate() + 1);
-				db.models.user.findOneAndUpdate({"email.address": user.email.address}, {$set: {"services.password": {set: {code: code, expires}}}}, {runValidators: true}, next);
-			},
-
-			(user, next) => {
-				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("REQUEST_PASSWORD", `UserId '${session.userId}' failed to request password. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("REQUEST_PASSWORD", `UserId '${session.userId}' successfully requested a password.`);
-				cb({
-					status: 'success',
-					message: 'Successfully requested password.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Verifies a password code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password code
-	 * @param {Function} cb - gets called with the result
-	 */
-	verifyPasswordCode: hooks.loginRequired((session, code, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code1.');
-				db.models.user.findOne({"services.password.set.code": code, _id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code2.');
-				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
-				next(null);
-			}
-		], async(err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("VERIFY_PASSWORD_CODE", `Code '${code}' successfully verified.`);
-				cb({
-					status: 'success',
-					message: 'Successfully verified password code.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Adds a password to a user with a code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password code
-	 * @param {String} newPassword - the new password code
-	 * @param {Function} cb - gets called with the result
-	 */
-	changePasswordWithCode: hooks.loginRequired((session, code, newPassword, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code1.');
-				db.models.user.findOne({"services.password.set.code": code}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code2.');
-				if (!user.services.password.set.expires > new Date()) return next('That code has expired.');
-				next();
-			},
-
-			(next) => {
-				if (!db.passwordValid(newPassword)) return next('Invalid password. Check if it meets all the requirements.');
-				return next();
-			},
-
-			(next) => {
-				bcrypt.genSalt(10, next);
-			},
-
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(newPassword), salt, next);
-			},
-
-			(hashedPassword, next) => {
-				db.models.user.updateOne({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
-				cache.pub('user.linkPassword', session.userId);
-				cb({
-					status: 'success',
-					message: 'Successfully added password.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Unlinks password from user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	unlinkPassword: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Not logged in.');
-				if (!user.services.github || !user.services.github.id) return next('You can\'t remove password login without having GitHub login.');
-				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.password": ''}}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${session.userId}'. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UNLINK_PASSWORD", `Unlinking password successful for userId '${session.userId}'.`);
-				cache.pub('user.unlinkPassword', session.userId);
-				cb({
-					status: 'success',
-					message: 'Successfully unlinked password.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Unlinks GitHub from user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	unlinkGitHub: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Not logged in.');
-				if (!user.services.password || !user.services.password.password) return next('You can\'t remove GitHub login without having password login.');
-				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.github": ''}}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${session.userId}'. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UNLINK_GITHUB", `Unlinking GitHub successful for userId '${session.userId}'.`);
-				cache.pub('user.unlinkGitHub', session.userId);
-				cb({
-					status: 'success',
-					message: 'Successfully unlinked GitHub.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Requests a password reset for an email
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} email - the email of the user that requests a password reset
-	 * @param {Function} cb - gets called with the result
-	 */
-	requestPasswordReset: async (session, email, cb) => {
-		let code = await utils.generateRandomString(8);
-		async.waterfall([
-			(next) => {
-				if (!email || typeof email !== 'string') return next('Invalid email.');
-				email = email.toLowerCase();
-				db.models.user.findOne({"email.address": email}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (!user.services.password || !user.services.password.password) return next('User does not have a password set, and probably uses GitHub to log in.');
-				next(null, user);
-			},
-
-			(user, next) => {
-				let expires = new Date();
-				expires.setDate(expires.getDate() + 1);
-				db.models.user.findOneAndUpdate({"email.address": email}, {$set: {"services.password.reset": {code: code, expires}}}, {runValidators: true}, next);
-			},
-
-			(user, next) => {
-				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("REQUEST_PASSWORD_RESET", `Email '${email}' successfully requested a password reset.`);
-				cb({
-					status: 'success',
-					message: 'Successfully requested password reset.'
-				});
-			}
-		});
-	},
-
-	/**
-	 * Verifies a reset code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password reset code
-	 * @param {Function} cb - gets called with the result
-	 */
-	verifyPasswordResetCode: (session, code, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code.');
-				db.models.user.findOne({"services.password.reset.code": code}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code.');
-				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
-				next(null);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
-				cb({
-					status: 'success',
-					message: 'Successfully verified password reset code.'
-				});
-			}
-		});
-	},
-
-	/**
-	 * Changes a user's password with a reset code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password reset code
-	 * @param {String} newPassword - the new password reset code
-	 * @param {Function} cb - gets called with the result
-	 */
-	changePasswordWithResetCode: (session, code, newPassword, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code.');
-				db.models.user.findOne({"services.password.reset.code": code}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code.');
-				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
-				next();
-			},
-
-			(next) => {
-				if (!db.passwordValid(newPassword)) return next('Invalid password. Check if it meets all the requirements.');
-				return next();
-			},
-
-			(next) => {
-				bcrypt.genSalt(10, next);
-			},
-
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(newPassword), salt, next);
-			},
-
-			(hashedPassword, next) => {
-				db.models.user.updateOne({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
-				cb({
-					status: 'success',
-					message: 'Successfully changed password.'
-				});
-			}
-		});
-	},
-
-	/**
-	 * Bans a user by userId
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} value - the user id that is going to be banned
-	 * @param {String} reason - the reason for the ban
-	 * @param {String} expiresAt - the time the ban expires
-	 * @param {Function} cb - gets called with the result
-	 */
-	banUserById: hooks.adminRequired((session, userId, reason, expiresAt, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!userId) return next('You must provide a userId to ban.');
-				else if (!reason) return next('You must provide a reason for the ban.');
-				else return next();
-			},
-
-			(next) => {
-				if (!expiresAt || typeof expiresAt !== 'string') return next('Invalid expire date.');
-				let date = new Date();
-				switch(expiresAt) {
-					case '1h':
-						expiresAt = date.setHours(date.getHours() + 1);
-						break;
-					case '12h':
-						expiresAt = date.setHours(date.getHours() + 12);
-						break;
-					case '1d':
-						expiresAt = date.setDate(date.getDate() + 1);
-						break;
-					case '1w':
-						expiresAt = date.setDate(date.getDate() + 7);
-						break;
-					case '1m':
-						expiresAt = date.setMonth(date.getMonth() + 1);
-						break;
-					case '3m':
-						expiresAt = date.setMonth(date.getMonth() + 3);
-						break;
-					case '6m':
-						expiresAt = date.setMonth(date.getMonth() + 6);
-						break;
-					case '1y':
-						expiresAt = date.setFullYear(date.getFullYear() + 1);
-						break;
-					case 'never':
-						expiresAt = new Date(3093527980800000);
-						break;
-					default:
-						return next('Invalid expire date.');
-				}
-
-				next();
-			},
-
-			(next) => {
-				punishments.addPunishment('banUserId', userId, reason, expiresAt, userId, next)
-			},
-
-			(punishment, next) => {
-				cache.pub('user.ban', { userId, punishment });
-				next();
-			},
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("BAN_USER_BY_ID", `User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("BAN_USER_BY_ID", `User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`);
-				cb({
-					status: 'success',
-					message: 'Successfully banned user.'
-				});
-			}
-		});
-	}),
-
-	getFavoriteStations: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ _id: session.userId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next("User not found.");
-				next(null, user);
-			}
-		], async (err, user) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("GET_FAVORITE_STATIONS", `User ${session.userId} failed to get favorite stations. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("GET_FAVORITE_STATIONS", `User ${session.userId} got favorite stations.`);
-				cb({
-					status: 'success',
-					favoriteStations: user.favoriteStations
-				});
-			}
-		});
-	})
+    /**
+     * Lists all Users
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: hooks.adminRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.find({}).exec(next);
+                },
+            ],
+            async (err, users) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_INDEX",
+                        `Indexing users failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "USER_INDEX",
+                        `Indexing users successful.`
+                    );
+                    let filteredUsers = [];
+                    users.forEach((user) => {
+                        filteredUsers.push({
+                            _id: user._id,
+                            username: user.username,
+                            role: user.role,
+                            liked: user.liked,
+                            disliked: user.disliked,
+                            songsRequested: user.statistics.songsRequested,
+                            email: {
+                                address: user.email.address,
+                                verified: user.email.verified,
+                            },
+                            hasPassword: !!user.services.password,
+                            services: { github: user.services.github },
+                        });
+                    });
+                    return cb({ status: "success", data: filteredUsers });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Logs user in
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} identifier - the email of the user
+     * @param {String} password - the plaintext of the user
+     * @param {Function} cb - gets called with the result
+     */
+    login: async (session, identifier, password, cb) => {
+        identifier = identifier.toLowerCase();
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const sessionSchema = await cache.runJob("GET_SCHEMA", {
+            schemaName: "session",
+        });
+
+        async.waterfall(
+            [
+                // check if a user with the requested identifier exists
+                (next) => {
+                    userModel.findOne(
+                        {
+                            $or: [{ "email.address": identifier }],
+                        },
+                        next
+                    );
+                },
+
+                // if the user doesn't exist, respond with a failure
+                // otherwise compare the requested password and the actual users password
+                (user, next) => {
+                    if (!user) return next("User not found");
+                    if (
+                        !user.services.password ||
+                        !user.services.password.password
+                    )
+                        return next(
+                            "The account you are trying to access uses GitHub to log in."
+                        );
+                    bcrypt.compare(
+                        sha256(password),
+                        user.services.password.password,
+                        (err, match) => {
+                            if (err) return next(err);
+                            if (!match) return next("Incorrect password");
+                            next(null, user);
+                        }
+                    );
+                },
+
+                (user, next) => {
+                    utils.runJob("GUID", {}).then((sessionId) => {
+                        next(null, user, sessionId);
+                    });
+                },
+
+                (user, sessionId, next) => {
+                    cache
+                        .runJob("HSET", {
+                            table: "sessions",
+                            key: sessionId,
+                            value: sessionSchema(sessionId, user._id),
+                        })
+                        .then(() => next(null, sessionId))
+                        .catch(next);
+                },
+            ],
+            async (err, sessionId) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_PASSWORD_LOGIN",
+                        `Login failed with password for user "${identifier}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "USER_PASSWORD_LOGIN",
+                    `Login successful with password for user "${identifier}"`
+                );
+                cb({
+                    status: "success",
+                    message: "Login successful",
+                    user: {},
+                    SID: sessionId,
+                });
+            }
+        );
+    },
+
+    /**
+     * Registers a new user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} username - the username for the new user
+     * @param {String} email - the email for the new user
+     * @param {String} password - the plaintext password for the new user
+     * @param {Object} recaptcha - the recaptcha data
+     * @param {Function} cb - gets called with the result
+     */
+    register: async function(
+        session,
+        username,
+        email,
+        password,
+        recaptcha,
+        cb
+    ) {
+        email = email.toLowerCase();
+        let verificationToken = await utils.runJob("GENERATE_RANDOM_STRING", {
+            length: 64,
+        });
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
+            schemaName: "verifyEmail",
+        });
+
+        async.waterfall(
+            [
+                // verify the request with google recaptcha
+                (next) => {
+                    if (!db.passwordValid(password))
+                        return next(
+                            "Invalid password. Check if it meets all the requirements."
+                        );
+                    return next();
+                },
+
+                (next) => {
+                    request(
+                        {
+                            url:
+                                "https://www.google.com/recaptcha/api/siteverify",
+                            method: "POST",
+                            form: {
+                                secret: config.get("apis").recaptcha.secret,
+                                response: recaptcha,
+                            },
+                        },
+                        next
+                    );
+                },
+
+                // check if the response from Google recaptcha is successful
+                // if it is, we check if a user with the requested username already exists
+                (response, body, next) => {
+                    let json = JSON.parse(body);
+                    if (json.success !== true)
+                        return next(
+                            "Response from recaptcha was not successful."
+                        );
+                    userModel.findOne(
+                        { username: new RegExp(`^${username}$`, "i") },
+                        next
+                    );
+                },
+
+                // if the user already exists, respond with that
+                // otherwise check if a user with the requested email already exists
+                (user, next) => {
+                    if (user)
+                        return next(
+                            "A user with that username already exists."
+                        );
+                    userModel.findOne({ "email.address": email }, next);
+                },
+
+                // if the user already exists, respond with that
+                // otherwise, generate a salt to use with hashing the new users password
+                (user, next) => {
+                    if (user)
+                        return next("A user with that email already exists.");
+                    bcrypt.genSalt(10, next);
+                },
+
+                // hash the password
+                (salt, next) => {
+                    bcrypt.hash(sha256(password), salt, next);
+                },
+
+                (hash, next) => {
+                    utils
+                        .runJob("GENERATE_RANDOM_STRONG", { length: 12 })
+                        .then((_id) => {
+                            next(null, hash, _id);
+                        });
+                },
+
+                // create the user object
+                (hash, _id, next) => {
+                    next(null, {
+                        _id,
+                        username,
+                        email: {
+                            address: email,
+                            verificationToken,
+                        },
+                        services: {
+                            password: {
+                                password: hash,
+                            },
+                        },
+                    });
+                },
+
+                // generate the url for gravatar avatar
+                (user, next) => {
+                    utils
+                        .runJob("CREATE_GRAVATAR", {
+                            email: user.email.address,
+                        })
+                        .then((url) => {
+                            user.avatar = url;
+                            next(null, user);
+                        });
+                },
+
+                // save the new user to the database
+                (user, next) => {
+                    userModel.create(user, next);
+                },
+
+                // respond with the new user
+                (newUser, next) => {
+                    verifyEmailSchema(
+                        email,
+                        username,
+                        verificationToken,
+                        () => {
+                            next(newUser);
+                        }
+                    );
+                },
+            ],
+            async (user, err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_PASSWORD_REGISTER",
+                        `Register failed with password for user "${username}"."${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    module.exports.login(session, email, password, (result) => {
+                        let obj = {
+                            status: "success",
+                            message: "Successfully registered.",
+                        };
+                        if (result.status === "success") {
+                            obj.SID = result.SID;
+                        }
+                        activities.runJob("ADD_ACTIVITY", {
+                            userId: user._id,
+                            activityType: "created_account",
+                        });
+                        console.log(
+                            "SUCCESS",
+                            "USER_PASSWORD_REGISTER",
+                            `Register successful with password for user "${username}".`
+                        );
+                        return cb(obj);
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Logs out a user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    logout: (session, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+
+                (session, next) => {
+                    if (!session) return next("Session not found");
+                    next(null, session);
+                },
+
+                (session, next) => {
+                    cache
+                        .runJob("HDEL", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then(() => next())
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_LOGOUT",
+                        `Logout failed. "${err}" `
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log("SUCCESS", "USER_LOGOUT", `Logout successful.`);
+                    cb({
+                        status: "success",
+                        message: "Successfully logged out.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Removes all sessions for a user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} userId - the id of the user we are trying to delete the sessions of
+     * @param {Function} cb - gets called with the result
+     */
+    removeSessions: hooks.loginRequired(async (session, userId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, (err, user) => {
+                        if (err) return next(err);
+                        if (user.role !== "admin" && session.userId !== userId)
+                            return next(
+                                "Only admins and the owner of the account can remove their sessions."
+                            );
+                        else return next();
+                    });
+                },
+
+                (next) => {
+                    cache
+                        .runJob("HGETALL", { table: "sessions" })
+                        .then((sessions) => next(null, sessions))
+                        .catch(next);
+                },
+
+                (sessions, next) => {
+                    if (!sessions)
+                        return next(
+                            "There are no sessions for this user to remove."
+                        );
+                    else {
+                        let keys = Object.keys(sessions);
+                        next(null, keys, sessions);
+                    }
+                },
+
+                (keys, sessions, next) => {
+                    cache.runJob("PUB", {
+                        channel: "user.removeSessions",
+                        value: userId,
+                    });
+                    async.each(
+                        keys,
+                        (sessionId, callback) => {
+                            let session = sessions[sessionId];
+                            if (session.userId === userId) {
+                                cache
+                                    .runJob("HDEL", {
+                                        channel: "sessions",
+                                        key: sessionId,
+                                    })
+                                    .then(() => callback(null))
+                                    .catch(next);
+                            }
+                        },
+                        (err) => {
+                            next(err);
+                        }
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REMOVE_SESSIONS_FOR_USER",
+                        `Couldn't remove all sessions for user "${userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "REMOVE_SESSIONS_FOR_USER",
+                        `Removed all sessions for user "${userId}".`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully removed all sessions.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Gets user object from username (only a few properties)
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} username - the username of the user we are trying to find
+     * @param {Function} cb - gets called with the result
+     */
+    findByUsername: async (session, username, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne(
+                        { username: new RegExp(`^${username}$`, "i") },
+                        next
+                    );
+                },
+
+                (account, next) => {
+                    if (!account) return next("User not found.");
+                    next(null, account);
+                },
+            ],
+            async (err, account) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "FIND_BY_USERNAME",
+                        `User not found for username "${username}". "${err}"`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "FIND_BY_USERNAME",
+                        `User found for username "${username}".`
+                    );
+                    return cb({
+                        status: "success",
+                        data: {
+                            _id: account._id,
+                            name: account.name,
+                            username: account.username,
+                            location: account.location,
+                            bio: account.bio,
+                            role: account.role,
+                            avatar: account.avatar,
+                            createdAt: account.createdAt,
+                        },
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Gets a username from an userId
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} userId - the userId of the person we are trying to get the username from
+     * @param {Function} cb - gets called with the result
+     */
+    getUsernameFromId: async (session, userId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        userModel
+            .findById(userId)
+            .then((user) => {
+                if (user) {
+                    console.log(
+                        "SUCCESS",
+                        "GET_USERNAME_FROM_ID",
+                        `Found username for userId "${userId}".`
+                    );
+                    return cb({
+                        status: "success",
+                        data: user.username,
+                    });
+                } else {
+                    console.log(
+                        "ERROR",
+                        "GET_USERNAME_FROM_ID",
+                        `Getting the username from userId "${userId}" failed. User not found.`
+                    );
+                    cb({
+                        status: "failure",
+                        message: "Couldn't find the user.",
+                    });
+                }
+            })
+            .catch(async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_USERNAME_FROM_ID",
+                        `Getting the username from userId "${userId}" failed. "${err}"`
+                    );
+                    cb({ status: "failure", message: err });
+                }
+            });
+    },
+
+    //TODO Fix security issues
+    /**
+     * Gets user info from session
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    findBySession: async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+
+                (session, next) => {
+                    if (!session) return next("Session not found.");
+                    next(null, session);
+                },
+
+                (session, next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("User not found.");
+                    next(null, user);
+                },
+            ],
+            async (err, user) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "FIND_BY_SESSION",
+                        `User not found. "${err}"`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    let data = {
+                        email: {
+                            address: user.email.address,
+                        },
+                        avatar: user.avatar,
+                        username: user.username,
+                        name: user.name,
+                        location: user.location,
+                        bio: user.bio,
+                    };
+                    if (
+                        user.services.password &&
+                        user.services.password.password
+                    )
+                        data.password = true;
+                    if (user.services.github && user.services.github.id)
+                        data.github = true;
+                    console.log(
+                        "SUCCESS",
+                        "FIND_BY_SESSION",
+                        `User found. "${user.username}".`
+                    );
+                    return cb({
+                        status: "success",
+                        data,
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Updates a user's username
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newUsername - the new username
+     * @param {Function} cb - gets called with the result
+     */
+    updateUsername: hooks.loginRequired(
+        async (session, updatingUserId, newUsername, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        if (user.username === newUsername)
+                            return next(
+                                "New username can't be the same as the old username."
+                            );
+                        next(null);
+                    },
+
+                    (next) => {
+                        userModel.findOne(
+                            { username: new RegExp(`^${newUsername}$`, "i") },
+                            next
+                        );
+                    },
+
+                    (user, next) => {
+                        if (!user) return next();
+                        if (user._id === updatingUserId) return next();
+                        next("That username is already in use.");
+                    },
+
+                    (next) => {
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { username: newUsername } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_USERNAME",
+                            `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        cache.runJob("PUB", {
+                            channel: "user.updateUsername",
+                            value: {
+                                username: newUsername,
+                                _id: updatingUserId,
+                            },
+                        });
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_USERNAME",
+                            `Updated username for user "${updatingUserId}" to username "${newUsername}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Username updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's email
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newEmail - the new email
+     * @param {Function} cb - gets called with the result
+     */
+    updateEmail: hooks.loginRequired(
+        async (session, updatingUserId, newEmail, cb) => {
+            newEmail = newEmail.toLowerCase();
+            let verificationToken = await utils.runJob(
+                "GENERATE_RANDOM_STRING",
+                { length: 64 }
+            );
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
+                schemaName: "verifyEmail",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        if (user.email.address === newEmail)
+                            return next(
+                                "New email can't be the same as your the old email."
+                            );
+                        next();
+                    },
+
+                    (next) => {
+                        userModel.findOne({ "email.address": newEmail }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next();
+                        if (user._id === updatingUserId) return next();
+                        next("That email is already in use.");
+                    },
+
+                    // regenerate the url for gravatar avatar
+                    (next) => {
+                        utils
+                            .runJob("CREATE_GRAVATAR", { email: newEmail })
+                            .then((url) => next(null, url));
+                    },
+
+                    (avatar, next) => {
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            {
+                                $set: {
+                                    avatar: avatar,
+                                    "email.address": newEmail,
+                                    "email.verified": false,
+                                    "email.verificationToken": verificationToken,
+                                },
+                            },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        verifyEmailSchema(
+                            newEmail,
+                            user.username,
+                            verificationToken,
+                            () => {
+                                next();
+                            }
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_EMAIL",
+                            `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_EMAIL",
+                            `Updated email for user "${updatingUserId}" to email "${newEmail}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Email updated successfully.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's name
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newBio - the new name
+     * @param {Function} cb - gets called with the result
+     */
+    updateName: hooks.loginRequired(
+        async (session, updatingUserId, newName, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { name: newName } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_NAME",
+                            `Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_NAME",
+                            `Updated name for user "${updatingUserId}" to name "${newName}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Name updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's location
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newLocation - the new location
+     * @param {Function} cb - gets called with the result
+     */
+    updateLocation: hooks.loginRequired(
+        async (session, updatingUserId, newLocation, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { location: newLocation } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_LOCATION",
+                            `Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_LOCATION",
+                            `Updated location for user "${updatingUserId}" to location "${newLocation}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Location updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's bio
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newBio - the new bio
+     * @param {Function} cb - gets called with the result
+     */
+    updateBio: hooks.loginRequired(
+        async (session, updatingUserId, newBio, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { bio: newBio } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_BIO",
+                            `Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_BIO",
+                            `Updated bio for user "${updatingUserId}" to bio "${newBio}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Bio updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates the type of a user's avatar
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newType - the new type
+     * @param {Function} cb - gets called with the result
+     */
+    updateAvatarType: hooks.loginRequired(
+        async (session, updatingUserId, newType, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { "avatar.type": newType } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_AVATAR_TYPE",
+                            `Couldn't update avatar type for user "${updatingUserId}" to type "${newType}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_AVATAR_TYPE",
+                            `Updated avatar type for user "${updatingUserId}" to type "${newType}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Avatar type updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's role
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newRole - the new role
+     * @param {Function} cb - gets called with the result
+     */
+    updateRole: hooks.adminRequired(
+        async (session, updatingUserId, newRole, cb) => {
+            newRole = newRole.toLowerCase();
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        else if (user.role === newRole)
+                            return next(
+                                "New role can't be the same as the old role."
+                            );
+                        else return next();
+                    },
+                    (next) => {
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { role: newRole } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_ROLE",
+                            `User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_ROLE",
+                            `User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Role successfully updated.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's password
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} newPassword - the new password
+     * @param {Function} cb - gets called with the result
+     */
+    updatePassword: hooks.loginRequired(async (session, newPassword, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user.services.password)
+                        return next(
+                            "This account does not have a password set."
+                        );
+                    next();
+                },
+
+                (next) => {
+                    if (!db.passwordValid(newPassword))
+                        return next(
+                            "Invalid password. Check if it meets all the requirements."
+                        );
+                    return next();
+                },
+
+                (next) => {
+                    bcrypt.genSalt(10, next);
+                },
+
+                // hash the password
+                (salt, next) => {
+                    bcrypt.hash(sha256(newPassword), salt, next);
+                },
+
+                (hashedPassword, next) => {
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        {
+                            $set: {
+                                "services.password.password": hashedPassword,
+                            },
+                        },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UPDATE_PASSWORD",
+                        `Failed updating user password of user '${session.userId}'. '${err}'.`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+
+                console.log(
+                    "SUCCESS",
+                    "UPDATE_PASSWORD",
+                    `User '${session.userId}' updated their password.`
+                );
+                cb({
+                    status: "success",
+                    message: "Password successfully updated.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Requests a password for a session
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} email - the email of the user that requests a password reset
+     * @param {Function} cb - gets called with the result
+     */
+    requestPassword: hooks.loginRequired(async (session, cb) => {
+        let code = await utils.runJob("GENERATE_RANDOM_STRING", { length: 8 });
+        const passwordRequestSchema = await mail.runJob("GET_SCHEMA", {
+            schemaName: "passwordRequest",
+        });
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("User not found.");
+                    if (
+                        user.services.password &&
+                        user.services.password.password
+                    )
+                        return next("You already have a password set.");
+                    next(null, user);
+                },
+
+                (user, next) => {
+                    let expires = new Date();
+                    expires.setDate(expires.getDate() + 1);
+                    userModel.findOneAndUpdate(
+                        { "email.address": user.email.address },
+                        {
+                            $set: {
+                                "services.password": {
+                                    set: { code: code, expires },
+                                },
+                            },
+                        },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    passwordRequestSchema(
+                        user.email.address,
+                        user.username,
+                        code,
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REQUEST_PASSWORD",
+                        `UserId '${session.userId}' failed to request password. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "REQUEST_PASSWORD",
+                        `UserId '${session.userId}' successfully requested a password.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully requested password.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Verifies a password code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password code
+     * @param {Function} cb - gets called with the result
+     */
+    verifyPasswordCode: hooks.loginRequired(async (session, code, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!code || typeof code !== "string")
+                        return next("Invalid code1.");
+                    userModel.findOne(
+                        {
+                            "services.password.set.code": code,
+                            _id: session.userId,
+                        },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    if (!user) return next("Invalid code2.");
+                    if (user.services.password.set.expires < new Date())
+                        return next("That code has expired.");
+                    next(null);
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "VERIFY_PASSWORD_CODE",
+                        `Code '${code}' failed to verify. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "VERIFY_PASSWORD_CODE",
+                        `Code '${code}' successfully verified.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully verified password code.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Adds a password to a user with a code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password code
+     * @param {String} newPassword - the new password code
+     * @param {Function} cb - gets called with the result
+     */
+    changePasswordWithCode: hooks.loginRequired(
+        async (session, code, newPassword, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!code || typeof code !== "string")
+                            return next("Invalid code1.");
+                        userModel.findOne(
+                            { "services.password.set.code": code },
+                            next
+                        );
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("Invalid code2.");
+                        if (!user.services.password.set.expires > new Date())
+                            return next("That code has expired.");
+                        next();
+                    },
+
+                    (next) => {
+                        if (!db.passwordValid(newPassword))
+                            return next(
+                                "Invalid password. Check if it meets all the requirements."
+                            );
+                        return next();
+                    },
+
+                    (next) => {
+                        bcrypt.genSalt(10, next);
+                    },
+
+                    // hash the password
+                    (salt, next) => {
+                        bcrypt.hash(sha256(newPassword), salt, next);
+                    },
+
+                    (hashedPassword, next) => {
+                        userModel.updateOne(
+                            { "services.password.set.code": code },
+                            {
+                                $set: {
+                                    "services.password.password": hashedPassword,
+                                },
+                                $unset: { "services.password.set": "" },
+                            },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "ADD_PASSWORD_WITH_CODE",
+                            `Code '${code}' failed to add password. '${err}'`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "ADD_PASSWORD_WITH_CODE",
+                            `Code '${code}' successfully added password.`
+                        );
+                        cache.runJob("PUB", {
+                            channel: "user.linkPassword",
+                            value: session.userId,
+                        });
+                        cb({
+                            status: "success",
+                            message: "Successfully added password.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Unlinks password from user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    unlinkPassword: hooks.loginRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("Not logged in.");
+                    if (!user.services.github || !user.services.github.id)
+                        return next(
+                            "You can't remove password login without having GitHub login."
+                        );
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $unset: { "services.password": "" } },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UNLINK_PASSWORD",
+                        `Unlinking password failed for userId '${session.userId}'. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "UNLINK_PASSWORD",
+                        `Unlinking password successful for userId '${session.userId}'.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "user.unlinkPassword",
+                        value: session.userId,
+                    });
+                    cb({
+                        status: "success",
+                        message: "Successfully unlinked password.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Unlinks GitHub from user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    unlinkGitHub: hooks.loginRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("Not logged in.");
+                    if (
+                        !user.services.password ||
+                        !user.services.password.password
+                    )
+                        return next(
+                            "You can't remove GitHub login without having password login."
+                        );
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $unset: { "services.github": "" } },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UNLINK_GITHUB",
+                        `Unlinking GitHub failed for userId '${session.userId}'. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "UNLINK_GITHUB",
+                        `Unlinking GitHub successful for userId '${session.userId}'.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "user.unlinkGithub",
+                        value: session.userId,
+                    });
+                    cb({
+                        status: "success",
+                        message: "Successfully unlinked GitHub.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Requests a password reset for an email
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} email - the email of the user that requests a password reset
+     * @param {Function} cb - gets called with the result
+     */
+    requestPasswordReset: async (session, email, cb) => {
+        let code = await utils.runJob("GENERATE_RANDOM_STRING", { length: 8 });
+        console.log(111, code);
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const resetPasswordRequestSchema = await mail.runJob("GET_SCHEMA", {
+            schemaName: "resetPasswordRequest",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!email || typeof email !== "string")
+                        return next("Invalid email.");
+                    email = email.toLowerCase();
+                    userModel.findOne({ "email.address": email }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("User not found.");
+                    if (
+                        !user.services.password ||
+                        !user.services.password.password
+                    )
+                        return next(
+                            "User does not have a password set, and probably uses GitHub to log in."
+                        );
+                    next(null, user);
+                },
+
+                (user, next) => {
+                    let expires = new Date();
+                    expires.setDate(expires.getDate() + 1);
+                    userModel.findOneAndUpdate(
+                        { "email.address": email },
+                        {
+                            $set: {
+                                "services.password.reset": {
+                                    code: code,
+                                    expires,
+                                },
+                            },
+                        },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    resetPasswordRequestSchema(
+                        user.email.address,
+                        user.username,
+                        code,
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REQUEST_PASSWORD_RESET",
+                        `Email '${email}' failed to request password reset. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "REQUEST_PASSWORD_RESET",
+                        `Email '${email}' successfully requested a password reset.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully requested password reset.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Verifies a reset code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password reset code
+     * @param {Function} cb - gets called with the result
+     */
+    verifyPasswordResetCode: async (session, code, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!code || typeof code !== "string")
+                        return next("Invalid code.");
+                    userModel.findOne(
+                        { "services.password.reset.code": code },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    if (!user) return next("Invalid code.");
+                    if (!user.services.password.reset.expires > new Date())
+                        return next("That code has expired.");
+                    next(null);
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "VERIFY_PASSWORD_RESET_CODE",
+                        `Code '${code}' failed to verify. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "VERIFY_PASSWORD_RESET_CODE",
+                        `Code '${code}' successfully verified.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully verified password reset code.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Changes a user's password with a reset code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password reset code
+     * @param {String} newPassword - the new password reset code
+     * @param {Function} cb - gets called with the result
+     */
+    changePasswordWithResetCode: async (session, code, newPassword, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!code || typeof code !== "string")
+                        return next("Invalid code.");
+                    userModel.findOne(
+                        { "services.password.reset.code": code },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    if (!user) return next("Invalid code.");
+                    if (!user.services.password.reset.expires > new Date())
+                        return next("That code has expired.");
+                    next();
+                },
+
+                (next) => {
+                    if (!db.passwordValid(newPassword))
+                        return next(
+                            "Invalid password. Check if it meets all the requirements."
+                        );
+                    return next();
+                },
+
+                (next) => {
+                    bcrypt.genSalt(10, next);
+                },
+
+                // hash the password
+                (salt, next) => {
+                    bcrypt.hash(sha256(newPassword), salt, next);
+                },
+
+                (hashedPassword, next) => {
+                    userModel.updateOne(
+                        { "services.password.reset.code": code },
+                        {
+                            $set: {
+                                "services.password.password": hashedPassword,
+                            },
+                            $unset: { "services.password.reset": "" },
+                        },
+                        { runValidators: true },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "CHANGE_PASSWORD_WITH_RESET_CODE",
+                        `Code '${code}' failed to change password. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "CHANGE_PASSWORD_WITH_RESET_CODE",
+                        `Code '${code}' successfully changed password.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully changed password.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Bans a user by userId
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} value - the user id that is going to be banned
+     * @param {String} reason - the reason for the ban
+     * @param {String} expiresAt - the time the ban expires
+     * @param {Function} cb - gets called with the result
+     */
+    banUserById: hooks.adminRequired(
+        (session, userId, reason, expiresAt, cb) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!userId)
+                            return next("You must provide a userId to ban.");
+                        else if (!reason)
+                            return next(
+                                "You must provide a reason for the ban."
+                            );
+                        else return next();
+                    },
+
+                    (next) => {
+                        if (!expiresAt || typeof expiresAt !== "string")
+                            return next("Invalid expire date.");
+                        let date = new Date();
+                        switch (expiresAt) {
+                            case "1h":
+                                expiresAt = date.setHours(date.getHours() + 1);
+                                break;
+                            case "12h":
+                                expiresAt = date.setHours(date.getHours() + 12);
+                                break;
+                            case "1d":
+                                expiresAt = date.setDate(date.getDate() + 1);
+                                break;
+                            case "1w":
+                                expiresAt = date.setDate(date.getDate() + 7);
+                                break;
+                            case "1m":
+                                expiresAt = date.setMonth(date.getMonth() + 1);
+                                break;
+                            case "3m":
+                                expiresAt = date.setMonth(date.getMonth() + 3);
+                                break;
+                            case "6m":
+                                expiresAt = date.setMonth(date.getMonth() + 6);
+                                break;
+                            case "1y":
+                                expiresAt = date.setFullYear(
+                                    date.getFullYear() + 1
+                                );
+                                break;
+                            case "never":
+                                expiresAt = new Date(3093527980800000);
+                                break;
+                            default:
+                                return next("Invalid expire date.");
+                        }
+
+                        next();
+                    },
+
+                    (next) => {
+                        punishments
+                            .runJob("ADD_PUNISHMENT", {
+                                type: "banUserId",
+                                value: userId,
+                                reason,
+                                expiresAt,
+                                punishedBy,
+                            })
+                            .then((punishment) => next(null, punishment))
+                            .catch(next);
+                    },
+
+                    (punishment, next) => {
+                        cache.runJob("PUB", {
+                            channel: "user.ban",
+                            value: { userId, punishment },
+                        });
+                        next();
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "BAN_USER_BY_ID",
+                            `User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "BAN_USER_BY_ID",
+                            `User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Successfully banned user.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    getFavoriteStations: hooks.loginRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("User not found.");
+                    next(null, user);
+                },
+            ],
+            async (err, user) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_FAVORITE_STATIONS",
+                        `User ${session.userId} failed to get favorite stations. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "GET_FAVORITE_STATIONS",
+                        `User ${session.userId} got favorite stations.`
+                    );
+                    cb({
+                        status: "success",
+                        favoriteStations: user.favoriteStations,
+                    });
+                }
+            }
+        );
+    }),
 };

+ 61 - 56
backend/logic/activities.js

@@ -1,63 +1,68 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
+class ActivitiesModule extends CoreClass {
+    constructor() {
+        super("activities");
+    }
 
-const async = require('async');
-const mongoose = require('mongoose');
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.db = this.moduleManager.modules["db"];
+            this.io = this.moduleManager.modules["io"];
+            this.utils = this.moduleManager.modules["utils"];
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
+            resolve();
+        });
+    }
 
-		this.dependsOn = ["db", "utils"];
-	}
+    // TODO: Migrate
+    ADD_ACTIVITY(payload) {
+        //userId, activityType, payload
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.db
+                            .runJob("GET_MODEL", { modelName: "activity" })
+                            .then((res) => {
+                                next(null, res);
+                            })
+                            .catch(next);
+                    },
+                    (activityModel, next) => {
+                        const activity = new activityModel({
+                            userId: payload.userId,
+                            activityType: payload.activityType,
+                            payload: payload.payload,
+                        });
 
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
+                        activity.save((err, activity) => {
+                            if (err) return next(err);
+                            next(null, activity);
+                        });
+                    },
 
-			this.db = this.moduleManager.modules["db"];
-			this.io = this.moduleManager.modules["io"];
-			this.utils = this.moduleManager.modules["utils"];
-
-			resolve();
-		});
-	}
-
-	/**
-	 * 
-	 * @param {String} userId - the id of the user 
-	 * @param {String} activityType - what type of activity the user has completed e.g. liked a song
-	 * @param {Array} payload - what the activity was specifically related to e.g. the liked song(s)
-	 */
-	async addActivity(userId, activityType, payload) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			next => {
-				const activity = new this.db.models.activity({
-					userId,
-					activityType,
-					payload
-				});
-
-				activity.save((err, activity) => {
-					if (err) return next(err);
-					next(null, activity);
-				});
-			},
-
-			(activity, next) => {
-				this.utils.socketsFromUser(activity.userId, sockets => {
-					sockets.forEach(socket => {
-						socket.emit('event:activity.create', activity);
-					});
-				});
-			}
-
-		], (err, activity) => {
-			// cb(err, activity);
-		});
-	}
+                    (activity, next) => {
+                        this.utils
+                            .runJob("SOCKETS_FROM_USER", {
+                                userId: activity.userId,
+                            })
+                            .then((sockets) =>
+                                sockets.forEach((socket) => {
+                                    socket.emit(
+                                        "event:activity.create",
+                                        activity
+                                    );
+                                })
+                            );
+                    },
+                ],
+                (err, activity) => {
+                    // cb(err, activity);
+                }
+            );
+        });
+    }
 }
+
+module.exports = new ActivitiesModule();

+ 44 - 39
backend/logic/api.js

@@ -1,40 +1,45 @@
-const coreClass = require("../core");
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["app", "db", "cache"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.app = this.moduleManager.modules["app"];
-
-			this.app.app.get('/', (req, res) => {
-				res.json({
-					status: 'success',
-					message: 'Coming Soon'
-				});
-			});
-
-			const actions = require("../logic/actions");
-	
-			Object.keys(actions).forEach((namespace) => {
-				Object.keys(actions[namespace]).forEach((action) => {
-					let name = `/${namespace}/${action}`;
-	
-					this.app.app.get(name, (req, res) => {
-						actions[namespace][action](null, (result) => {
-							if (typeof cb === 'function') return res.json(result);
-						});
-					});
-				})
-			});
-
-			resolve();
-		});
-	}
+const CoreClass = require("../core.js");
+
+class APIModule extends CoreClass {
+    constructor() {
+        super("api");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            const app = this.moduleManager.modules["app"];
+
+            const actions = require("./actions");
+
+            app.runJob("GET_APP", {})
+                .then((response) => {
+                    response.app.get("/", (req, res) => {
+                        res.json({
+                            status: "success",
+                            message: "Coming Soon",
+                        });
+                    });
+
+                    // Object.keys(actions).forEach(namespace => {
+                    //     Object.keys(actions[namespace]).forEach(action => {
+                    //         let name = `/${namespace}/${action}`;
+
+                    //         response.app.get(name, (req, res) => {
+                    //             actions[namespace][action](null, result => {
+                    //                 if (typeof cb === "function")
+                    //                     return res.json(result);
+                    //             });
+                    //         });
+                    //     });
+                    // });
+
+                    resolve();
+                })
+                .catch((err) => {
+                    reject(err);
+                });
+        });
+    }
 }
+
+module.exports = new APIModule();

+ 454 - 285
backend/logic/app.js

@@ -1,286 +1,455 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const express = require('express');
-const bodyParser = require('body-parser');
-const cookieParser = require('cookie-parser');
-const cors = require('cors');
-const config = require('config');
-const async = require('async');
-const request = require('request');
-const OAuth2 = require('oauth').OAuth2;
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			const 	logger 	    = this.logger,
-					mail	    = this.moduleManager.modules["mail"],
-					cache	    = this.moduleManager.modules["cache"],
-					db		    = this.moduleManager.modules["db"],
-					activities	= this.moduleManager.modules["activities"];
-			
-			this.utils = this.moduleManager.modules["utils"];
-
-			let app = this.app = express();
-			const SIDname = config.get("cookie.SIDname");
-			this.server = app.listen(config.get('serverPort'));
-
-			app.use(cookieParser());
-
-			app.use(bodyParser.json());
-			app.use(bodyParser.urlencoded({ extended: true }));
-
-			let corsOptions = Object.assign({}, config.get('cors'));
-
-			app.use(cors(corsOptions));
-			app.options('*', cors(corsOptions));
-
-			let oauth2 = new OAuth2(
-				config.get('apis.github.client'),
-				config.get('apis.github.secret'),
-				'https://github.com/',
-				'login/oauth/authorize',
-				'login/oauth/access_token',
-				null
-			);
-
-			let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
-
-			app.get('/auth/github/authorize', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-				let params = [
-					`client_id=${config.get('apis.github.client')}`,
-					`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-					`scope=user:email`
-				].join('&');
-				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-			});
-
-			app.get('/auth/github/link', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-				let params = [
-					`client_id=${config.get('apis.github.client')}`,
-					`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-					`scope=user:email`,
-					`state=${req.cookies[SIDname]}`
-				].join('&');
-				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-			});
-
-			function redirectOnErr (res, err){
-				return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
-			}
-
-			app.get('/auth/github/authorize/callback', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-
-				let code = req.query.code;
-				let access_token;
-				let body;
-				let address;
-
-				const state = req.query.state;
-
-				const verificationToken = await this.utils.generateRandomString(64);
-
-				async.waterfall([
-					(next) => {
-						if (req.query.error) return next(req.query.error_description);
-						next();
-					},
-
-					(next) => {
-						oauth2.getOAuthAccessToken(code, { redirect_uri }, next);
-					},
-
-					(_access_token, refresh_token, results, next) => {
-						if (results.error) return next(results.error_description);
-						access_token = _access_token;
-						request.get({
-							url: `https://api.github.com/user?access_token=${access_token}`,
-							headers: {'User-Agent': 'request'}
-						}, next);
-					},
-
-					(httpResponse, _body, next) => {
-						body = _body = JSON.parse(_body);
-						if (httpResponse.statusCode !== 200) return next(body.message);
-						if (state) {
-							return async.waterfall([
-								(next) => {
-									cache.hget('sessions', state, next);
-								},
-
-								(session, next) => {
-									if (!session) return next('Invalid session.');
-									db.models.user.findOne({ _id: session.userId }, next);
-								},
-
-								(user, next) => {
-									if (!user) return next('User not found.');
-									if (user.services.github && user.services.github.id) return next('Account already has GitHub linked.');
-									db.models.user.updateOne({ _id: user._id }, { $set: {
-										"services.github": { id: body.id, access_token } }
-									}, { runValidators: true }, (err) => {
-										if (err) return next(err);
-										next(null, user, body);
-									});
-								},
-
-								(user) => {
-									cache.pub('user.linkGitHub', user._id);
-									res.redirect(`${config.get('domain')}/settings`);
-								}
-							], next);
-						}
-						
-						if (!body.id) return next("Something went wrong, no id.");
-						db.models.user.findOne({ 'services.github.id': body.id }, (err, user) => {
-							next(err, user, body);
-						});
-					},
-
-					(user, body, next) => {
-						if (user) {
-							user.services.github.access_token = access_token;
-							return user.save(() => {
-								next(true, user._id);
-							});
-						}
-						db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i' )}, (err, user) => {
-							next(err, user);
-						});
-					},
-
-					(user, next) => {
-						if (user) return next('An account with that username already exists.');
-						request.get({
-							url: `https://api.github.com/user/emails?access_token=${access_token}`,
-							headers: {'User-Agent': 'request'}
-						}, next);
-					},
-
-					(httpResponse, body2, next) => {
-						body2 = JSON.parse(body2);
-						if (!Array.isArray(body2)) return next(body2.message);
-
-						body2.forEach(email => {
-							if (email.primary) address = email.email.toLowerCase();
-						});
-
-						db.models.user.findOne({ 'email.address': address }, next);
-					},
-
-					
-					(user, next) => {
-						this.utils.generateRandomString(12).then((_id) => {
-							next(null, user, _id);
-						});
-					},
-
-					(user, _id, next) => {
-						if (user) return next('An account with that email address already exists.');
-
-						next(null, {
-							_id, //TODO Check if exists
-							username: body.login,
-							name: body.name,
-							location: body.location,
-							bio: body.bio,
-							email: {
-								address,
-								verificationToken
-							},
-							services: {
-								github: { id: body.id, access_token }
-							}
-						});
-					},
-
-					// generate the url for gravatar avatar
-					(user, next) => {
-						this.utils.createGravatar(user.email.address).then(url => {
-							user.avatar = { type: "gravatar", url };
-							next(null, user);
-						});
-					},
-
-					// save the new user to the database
-					(user, next) => {
-						db.models.user.create(user, next);
-					},
-
-					// add the activity of account creation
-					(user, next) => {
-						activities.addActivity(user._id, "created_account");
-						next(null, user);
-					},
-
-					(user, next) => {
-						mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
-						next(null, user._id);
-					}
-				], async (err, userId) => {
-					if (err && err !== true) {
-						err = await this.utils.getError(err);
-						logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
-						return redirectOnErr(res, err);
-					}
-
-					const sessionId = await this.utils.guid();
-					cache.hset('sessions', sessionId, cache.schemas.session(sessionId, userId), err => {
-						if (err) return redirectOnErr(res, err.message);
-						let date = new Date();
-						date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-						res.cookie(SIDname, sessionId, {
-							expires: date,
-							secure: config.get("cookie.secure"),
-							path: "/",
-							domain: config.get("cookie.domain")
-						});
-						logger.success('AUTH_GITHUB_AUTHORIZE_CALLBACK', `User "${userId}" successfully authorized with GitHub.`);
-						res.redirect(`${config.get('domain')}/`);
-					});
-				});
-			});
-
-			app.get('/auth/verify_email', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-
-				let code = req.query.code;
-
-				async.waterfall([
-					(next) => {
-						if (!code) return next('Invalid code.');
-						next();
-					},
-
-					(next) => {
-						db.models.user.findOne({"email.verificationToken": code}, next);
-					},
-
-					(user, next) => {
-						if (!user) return next('User not found.');
-						if (user.email.verified) return next('This email is already verified.');
-						db.models.user.updateOne({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, {runValidators: true}, next);
-					}
-				], (err) => {
-					if (err) {
-						let error = 'An error occurred.';
-						if (typeof err === "string") error = err;
-						else if (err.message) error = err.message;
-						logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-						return res.json({ status: 'failure', message: error});
-					}
-					logger.success("VERIFY_EMAIL", `Successfully verified email.`);
-					res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
-				});
-			});
-
-			resolve();
-		});
-	}
+const CoreClass = require("../core.js");
+
+const express = require("express");
+const bodyParser = require("body-parser");
+const cookieParser = require("cookie-parser");
+const cors = require("cors");
+const config = require("config");
+const async = require("async");
+const request = require("request");
+const OAuth2 = require("oauth").OAuth2;
+
+class AppModule extends CoreClass {
+    constructor() {
+        super("app");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            const mail = this.moduleManager.modules["mail"],
+                cache = this.moduleManager.modules["cache"],
+                db = this.moduleManager.modules["db"],
+                activities = this.moduleManager.modules["activities"];
+
+            this.utils = this.moduleManager.modules["utils"];
+
+            let app = (this.app = express());
+            const SIDname = config.get("cookie.SIDname");
+            this.server = app.listen(config.get("serverPort"));
+
+            app.use(cookieParser());
+
+            app.use(bodyParser.json());
+            app.use(bodyParser.urlencoded({ extended: true }));
+
+            let corsOptions = Object.assign({}, config.get("cors"));
+
+            app.use(cors(corsOptions));
+            app.options("*", cors(corsOptions));
+
+            let oauth2 = new OAuth2(
+                config.get("apis.github.client"),
+                config.get("apis.github.secret"),
+                "https://github.com/",
+                "login/oauth/authorize",
+                "login/oauth/access_token",
+                null
+            );
+
+            let redirect_uri =
+                config.get("serverDomain") + "/auth/github/authorize/callback";
+
+            app.get("/auth/github/authorize", async (req, res) => {
+                try {
+                    await this._validateHook();
+                } catch {
+                    return;
+                }
+                let params = [
+                    `client_id=${config.get("apis.github.client")}`,
+                    `redirect_uri=${config.get(
+                        "serverDomain"
+                    )}/auth/github/authorize/callback`,
+                    `scope=user:email`,
+                ].join("&");
+                res.redirect(
+                    `https://github.com/login/oauth/authorize?${params}`
+                );
+            });
+
+            app.get("/auth/github/link", async (req, res) => {
+                try {
+                    await this._validateHook();
+                } catch {
+                    return;
+                }
+                let params = [
+                    `client_id=${config.get("apis.github.client")}`,
+                    `redirect_uri=${config.get(
+                        "serverDomain"
+                    )}/auth/github/authorize/callback`,
+                    `scope=user:email`,
+                    `state=${req.cookies[SIDname]}`,
+                ].join("&");
+                res.redirect(
+                    `https://github.com/login/oauth/authorize?${params}`
+                );
+            });
+
+            function redirectOnErr(res, err) {
+                return res.redirect(
+                    `${config.get("domain")}/?err=${encodeURIComponent(err)}`
+                );
+            }
+
+            app.get("/auth/github/authorize/callback", async (req, res) => {
+                try {
+                    await this._validateHook();
+                } catch {
+                    return;
+                }
+
+                let code = req.query.code;
+                let access_token;
+                let body;
+                let address;
+
+                const state = req.query.state;
+
+                const verificationToken = await this.utils.generateRandomString(
+                    64
+                );
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            if (req.query.error)
+                                return next(req.query.error_description);
+                            next();
+                        },
+
+                        (next) => {
+                            oauth2.getOAuthAccessToken(
+                                code,
+                                { redirect_uri },
+                                next
+                            );
+                        },
+
+                        (_access_token, refresh_token, results, next) => {
+                            if (results.error)
+                                return next(results.error_description);
+                            access_token = _access_token;
+                            request.get(
+                                {
+                                    url: `https://api.github.com/user?access_token=${access_token}`,
+                                    headers: { "User-Agent": "request" },
+                                },
+                                next
+                            );
+                        },
+
+                        (httpResponse, _body, next) => {
+                            body = _body = JSON.parse(_body);
+                            if (httpResponse.statusCode !== 200)
+                                return next(body.message);
+                            if (state) {
+                                return async.waterfall(
+                                    [
+                                        (next) => {
+                                            cache.hget("sessions", state, next);
+                                        },
+
+                                        (session, next) => {
+                                            if (!session)
+                                                return next("Invalid session.");
+                                            db.models.user.findOne(
+                                                { _id: session.userId },
+                                                next
+                                            );
+                                        },
+
+                                        (user, next) => {
+                                            if (!user)
+                                                return next("User not found.");
+                                            if (
+                                                user.services.github &&
+                                                user.services.github.id
+                                            )
+                                                return next(
+                                                    "Account already has GitHub linked."
+                                                );
+                                            db.models.user.updateOne(
+                                                { _id: user._id },
+                                                {
+                                                    $set: {
+                                                        "services.github": {
+                                                            id: body.id,
+                                                            access_token,
+                                                        },
+                                                    },
+                                                },
+                                                { runValidators: true },
+                                                (err) => {
+                                                    if (err) return next(err);
+                                                    next(null, user, body);
+                                                }
+                                            );
+                                        },
+
+                                        (user) => {
+                                            cache.pub(
+                                                "user.linkGitHub",
+                                                user._id
+                                            );
+                                            res.redirect(
+                                                `${config.get(
+                                                    "domain"
+                                                )}/settings`
+                                            );
+                                        },
+                                    ],
+                                    next
+                                );
+                            }
+
+                            if (!body.id)
+                                return next("Something went wrong, no id.");
+                            db.models.user.findOne(
+                                { "services.github.id": body.id },
+                                (err, user) => {
+                                    next(err, user, body);
+                                }
+                            );
+                        },
+
+                        (user, body, next) => {
+                            if (user) {
+                                user.services.github.access_token = access_token;
+                                return user.save(() => {
+                                    next(true, user._id);
+                                });
+                            }
+                            db.models.user.findOne(
+                                {
+                                    username: new RegExp(
+                                        `^${body.login}$`,
+                                        "i"
+                                    ),
+                                },
+                                (err, user) => {
+                                    next(err, user);
+                                }
+                            );
+                        },
+
+                        (user, next) => {
+                            if (user)
+                                return next(
+                                    "An account with that username already exists."
+                                );
+                            request.get(
+                                {
+                                    url: `https://api.github.com/user/emails?access_token=${access_token}`,
+                                    headers: { "User-Agent": "request" },
+                                },
+                                next
+                            );
+                        },
+
+                        (httpResponse, body2, next) => {
+                            body2 = JSON.parse(body2);
+                            if (!Array.isArray(body2))
+                                return next(body2.message);
+
+                            body2.forEach((email) => {
+                                if (email.primary)
+                                    address = email.email.toLowerCase();
+                            });
+
+                            db.models.user.findOne(
+                                { "email.address": address },
+                                next
+                            );
+                        },
+
+                        (user, next) => {
+                            this.utils.generateRandomString(12).then((_id) => {
+                                next(null, user, _id);
+                            });
+                        },
+
+                        (user, _id, next) => {
+                            if (user)
+                                return next(
+                                    "An account with that email address already exists."
+                                );
+
+                            next(null, {
+                                _id, //TODO Check if exists
+                                username: body.login,
+                                name: body.name,
+                                location: body.location,
+                                bio: body.bio,
+                                email: {
+                                    address,
+                                    verificationToken,
+                                },
+                                services: {
+                                    github: { id: body.id, access_token },
+                                },
+                            });
+                        },
+
+                        // generate the url for gravatar avatar
+                        (user, next) => {
+                            this.utils
+                                .createGravatar(user.email.address)
+                                .then((url) => {
+                                    user.avatar = { type: "gravatar", url };
+                                    next(null, user);
+                                });
+                        },
+
+                        // save the new user to the database
+                        (user, next) => {
+                            db.models.user.create(user, next);
+                        },
+
+                        // add the activity of account creation
+                        (user, next) => {
+                            activities.addActivity(user._id, "created_account");
+                            next(null, user);
+                        },
+
+                        (user, next) => {
+                            mail.schemas.verifyEmail(
+                                address,
+                                body.login,
+                                user.email.verificationToken
+                            );
+                            next(null, user._id);
+                        },
+                    ],
+                    async (err, userId) => {
+                        if (err && err !== true) {
+                            err = await this.utils.getError(err);
+                            logger.error(
+                                "AUTH_GITHUB_AUTHORIZE_CALLBACK",
+                                `Failed to authorize with GitHub. "${err}"`
+                            );
+                            return redirectOnErr(res, err);
+                        }
+
+                        const sessionId = await this.utils.guid();
+                        cache.hset(
+                            "sessions",
+                            sessionId,
+                            cache.schemas.session(sessionId, userId),
+                            (err) => {
+                                if (err) return redirectOnErr(res, err.message);
+                                let date = new Date();
+                                date.setTime(
+                                    new Date().getTime() +
+                                        2 * 365 * 24 * 60 * 60 * 1000
+                                );
+                                res.cookie(SIDname, sessionId, {
+                                    expires: date,
+                                    secure: config.get("cookie.secure"),
+                                    path: "/",
+                                    domain: config.get("cookie.domain"),
+                                });
+                                logger.success(
+                                    "AUTH_GITHUB_AUTHORIZE_CALLBACK",
+                                    `User "${userId}" successfully authorized with GitHub.`
+                                );
+                                res.redirect(`${config.get("domain")}/`);
+                            }
+                        );
+                    }
+                );
+            });
+
+            app.get("/auth/verify_email", async (req, res) => {
+                try {
+                    await this._validateHook();
+                } catch {
+                    return;
+                }
+
+                let code = req.query.code;
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            if (!code) return next("Invalid code.");
+                            next();
+                        },
+
+                        (next) => {
+                            db.models.user.findOne(
+                                { "email.verificationToken": code },
+                                next
+                            );
+                        },
+
+                        (user, next) => {
+                            if (!user) return next("User not found.");
+                            if (user.email.verified)
+                                return next("This email is already verified.");
+                            db.models.user.updateOne(
+                                { "email.verificationToken": code },
+                                {
+                                    $set: { "email.verified": true },
+                                    $unset: { "email.verificationToken": "" },
+                                },
+                                { runValidators: true },
+                                next
+                            );
+                        },
+                    ],
+                    (err) => {
+                        if (err) {
+                            let error = "An error occurred.";
+                            if (typeof err === "string") error = err;
+                            else if (err.message) error = err.message;
+                            logger.error(
+                                "VERIFY_EMAIL",
+                                `Verifying email failed. "${error}"`
+                            );
+                            return res.json({
+                                status: "failure",
+                                message: error,
+                            });
+                        }
+                        logger.success(
+                            "VERIFY_EMAIL",
+                            `Successfully verified email.`
+                        );
+                        res.redirect(
+                            `${config.get(
+                                "domain"
+                            )}?msg=Thank you for verifying your email`
+                        );
+                    }
+                );
+            });
+
+            resolve();
+        });
+    }
+
+    SERVER(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.server);
+        });
+    }
+
+    GET_APP(payload) {
+        return new Promise((resolve, reject) => {
+            resolve({ app: this.app });
+        });
+    }
+
+    EXAMPLE_JOB(payload) {
+        return new Promise((resolve, reject) => {
+            if (true) {
+                resolve({});
+            } else {
+                reject(new Error("Nothing changed."));
+            }
+        });
+    }
 }
+
+module.exports = new AppModule();

+ 261 - 206
backend/logic/cache/index.js

@@ -1,211 +1,266 @@
-'use strict';
+const CoreClass = require("../../core.js");
 
-const coreClass = require("../../core");
-
-const redis = require('redis');
-const config = require('config');
-const mongoose = require('mongoose');
+const redis = require("redis");
+const config = require("config");
+const mongoose = require("mongoose");
 
 // Lightweight / convenience wrapper around redis module for our needs
 
-const pubs = {}, subs = {};
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.schemas = {
-				session: require('./schemas/session'),
-				station: require('./schemas/station'),
-				playlist: require('./schemas/playlist'),
-				officialPlaylist: require('./schemas/officialPlaylist'),
-				song: require('./schemas/song'),
-				punishment: require('./schemas/punishment')
-			}
-
-			this.url = config.get("redis").url;
-			this.password = config.get("redis").password;
-
-			this.logger.info("REDIS", "Connecting...");
-
-			this.client = redis.createClient({
-				url: this.url,
-				password: this.password,
-				retry_strategy: (options) => {
-					if (this.state === "LOCKDOWN") return;
-					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
-
-					this.logger.info("CACHE_MODULE", `Attempting to reconnect.`);
-
-					if (options.attempt >= 10) {
-						this.logger.error("CACHE_MODULE", `Stopped trying to reconnect.`);
-
-						this.failed = true;
-						this._lockdown();
-
-						return undefined;
-					}
-
-					return 3000;
-				}
-			});
-
-			this.client.on('error', err => {
-				if (this.state === "INITIALIZING") reject(err);
-				if(this.state === "LOCKDOWN") return;
-
-				this.logger.error("CACHE_MODULE", `Error ${err.message}.`);
-			});
-
-			this.client.on("connect", () => {
-				this.logger.info("CACHE_MODULE", "Connected succesfully.");
-
-				if (this.state === "INITIALIZING") resolve();
-				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
-			});
-		});
-	}
-
-	/**
-	 * Gracefully closes all the Redis client connections
-	 */
-	async quit() {
-		try { await this._validateHook(); } catch { return; }
-
-		if (this.client.connected) {
-			this.client.quit();
-			Object.keys(pubs).forEach((channel) => pubs[channel].quit());
-			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
-		}
-	}
-
-	/**
-	 * Sets a single value in a table
-	 *
-	 * @param {String} table - name of the table we want to set a key of (table === redis hash)
-	 * @param {String} key -  name of the key to set
-	 * @param {*} value - the value we want to set
-	 * @param {Function} cb - gets called when the value has been set in Redis
-	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
-	 */
-	async hset(table, key, value, cb, stringifyJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-		// automatically stringify objects and arrays into JSON
-		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
-
-		this.client.hset(table, key, value, err => {
-			if (cb !== undefined) {
-				if (err) return cb(err);
-				cb(null, JSON.parse(value));
-			}
-		});
-	}
-
-	/**
-	 * Gets a single value from a table
-	 *
-	 * @param {String} table - name of the table to get the value from (table === redis hash)
-	 * @param {String} key - name of the key to fetch
-	 * @param {Function} cb - gets called when the value is returned from Redis
-	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
-	 */
-	async hget(table, key, cb, parseJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!key || !table) return typeof cb === 'function' ? cb(null, null) : null;
-		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-
-		this.client.hget(table, key, (err, value) => {
-			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (parseJson) try {
-				value = JSON.parse(value);
-			} catch (e) {
-			}
-			if (typeof cb === 'function') cb(null, value);
-		});
-	}
-
-	/**
-	 * Deletes a single value from a table
-	 *
-	 * @param {String} table - name of the table to delete the value from (table === redis hash)
-	 * @param {String} key - name of the key to delete
-	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
-	 */
-	async hdel(table, key, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!key || !table || typeof key !== "string") return cb(null, null);
-		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-
-		this.client.hdel(table, key, (err) => {
-			if (err) return cb(err);
-			else return cb(null);
-		});
-	}
-
-	/**
-	 * Returns all the keys for a table
-	 *
-	 * @param {String} table - name of the table to get the values from (table === redis hash)
-	 * @param {Function} cb - gets called when the values are returned from Redis
-	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
-	 */
-	async hgetall(table, cb, parseJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!table) return cb(null, null);
-
-		this.client.hgetall(table, (err, obj) => {
-			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (parseJson && obj) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });
-			if (parseJson && !obj) obj = [];
-			cb(null, obj);
-		});
-	}
-
-	/**
-	 * Publish a message to a channel, caches the redis client connection
-	 *
-	 * @param {String} channel - the name of the channel we want to publish a message to
-	 * @param {*} value - the value we want to send
-	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
-	 */
-	async pub(channel, value, stringifyJson = true) {
-		try { await this._validateHook(); } catch { return; }
-		/*if (pubs[channel] === undefined) {
-		 pubs[channel] = redis.createClient({ url: this.url });
-		 pubs[channel].on('error', (err) => console.error);
-		 }*/
-
-		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
-
-		//pubs[channel].publish(channel, value);
-		this.client.publish(channel, value);
-	}
-
-	/**
-	 * Subscribe to a channel, caches the redis client connection
-	 *
-	 * @param {String} channel - name of the channel to subscribe to
-	 * @param {Function} cb - gets called when a message is received
-	 * @param {Boolean} [parseJson=true] - parse the message as JSON
-	 */
-	async sub(channel, cb, parseJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (subs[channel] === undefined) {
-			subs[channel] = { client: redis.createClient({ url: this.url, password: this.password }), cbs: [] };
-			subs[channel].client.on('message', (channel, message) => {
-				if (parseJson) try { message = JSON.parse(message); } catch (e) {}
-				subs[channel].cbs.forEach((cb) => cb(message));
-			});
-			subs[channel].client.subscribe(channel);
-		}
-
-		subs[channel].cbs.push(cb);
-	}
+const pubs = {},
+    subs = {};
+
+class CacheModule extends CoreClass {
+    constructor() {
+        super("cache");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.schemas = {
+                session: require("./schemas/session"),
+                station: require("./schemas/station"),
+                playlist: require("./schemas/playlist"),
+                officialPlaylist: require("./schemas/officialPlaylist"),
+                song: require("./schemas/song"),
+                punishment: require("./schemas/punishment"),
+            };
+
+            this.url = config.get("redis").url;
+            this.password = config.get("redis").password;
+
+            this.log("INFO", "Connecting...");
+
+            this.client = redis.createClient({
+                url: this.url,
+                password: this.password,
+                retry_strategy: (options) => {
+                    if (this.getStatus() === "LOCKDOWN") return;
+                    if (this.getStatus() !== "RECONNECTING")
+                        this.setStatus("RECONNECTING");
+
+                    this.log("INFO", `Attempting to reconnect.`);
+
+                    if (options.attempt >= 10) {
+                        this.log("ERROR", `Stopped trying to reconnect.`);
+
+                        this.setStatus("FAILED");
+
+                        // this.failed = true;
+                        // this._lockdown();
+
+                        return undefined;
+                    }
+
+                    return 3000;
+                },
+            });
+
+            this.client.on("error", (err) => {
+                if (this.getStatus() === "INITIALIZING") reject(err);
+                if (this.getStatus() === "LOCKDOWN") return;
+
+                this.log("ERROR", `Error ${err.message}.`);
+            });
+
+            this.client.on("connect", () => {
+                this.log("INFO", "Connected succesfully.");
+
+                if (this.getStatus() === "INITIALIZING") resolve();
+                else if (
+                    this.getStatus() === "FAILED" ||
+                    this.getStatus() === "RECONNECTING"
+                )
+                    this.setStatus("READY");
+            });
+        });
+    }
+
+    /**
+     * Gracefully closes all the Redis client connections
+     */
+    QUIT(payload) {
+        return new Promise((resolve, reject) => {
+            if (this.client.connected) {
+                this.client.quit();
+                Object.keys(pubs).forEach((channel) => pubs[channel].quit());
+                Object.keys(subs).forEach((channel) =>
+                    subs[channel].client.quit()
+                );
+            }
+            resolve();
+        });
+    }
+
+    /**
+     * Sets a single value in a table
+     *
+     * @param {String} table - name of the table we want to set a key of (table === redis hash)
+     * @param {String} key -  name of the key to set
+     * @param {*} value - the value we want to set
+     * @param {Function} cb - gets called when the value has been set in Redis
+     * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
+     */
+    HSET(payload) {
+        //table, key, value, cb, stringifyJson = true
+        return new Promise((resolve, reject) => {
+            let key = payload.key;
+            let value = payload.value;
+
+            if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+            // automatically stringify objects and arrays into JSON
+            if (["object", "array"].includes(typeof value))
+                value = JSON.stringify(value);
+
+            this.client.hset(payload.table, key, value, (err) => {
+                if (err) return reject(new Error(err));
+                else resolve(JSON.parse(value));
+            });
+        });
+    }
+
+    /**
+     * Gets a single value from a table
+     *
+     * @param {String} table - name of the table to get the value from (table === redis hash)
+     * @param {String} key - name of the key to fetch
+     * @param {Function} cb - gets called when the value is returned from Redis
+     * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
+     */
+    HGET(payload) {
+        //table, key, cb, parseJson = true
+        return new Promise((resolve, reject) => {
+            // if (!key || !table)
+            // return typeof cb === "function" ? cb(null, null) : null;
+            let key = payload.key;
+
+            if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+            this.client.hget(payload.table, key, (err, value) => {
+                if (err) return reject(new Error(err));
+                try {
+                    value = JSON.parse(value);
+                } catch (e) {}
+                resolve(value);
+            });
+        });
+    }
+
+    /**
+     * Deletes a single value from a table
+     *
+     * @param {String} table - name of the table to delete the value from (table === redis hash)
+     * @param {String} key - name of the key to delete
+     * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
+     */
+    HDEL(payload) {
+        //table, key, cb
+        return new Promise((resolve, reject) => {
+            // if (!payload.key || !table || typeof key !== "string")
+            // return cb(null, null);
+
+            let key = payload.key;
+
+            if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+            this.client.hdel(payload.table, key, (err) => {
+                if (err) return reject(new Error(err));
+                else return resolve();
+            });
+        });
+    }
+
+    /**
+     * Returns all the keys for a table
+     *
+     * @param {String} table - name of the table to get the values from (table === redis hash)
+     * @param {Function} cb - gets called when the values are returned from Redis
+     * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
+     */
+    HGETALL(payload) {
+        //table, cb, parseJson = true
+        return new Promise((resolve, reject) => {
+            this.client.hgetall(payload.table, (err, obj) => {
+                if (err) return reject(new Error(err));
+                if (obj)
+                    Object.keys(obj).forEach((key) => {
+                        try {
+                            obj[key] = JSON.parse(obj[key]);
+                        } catch (e) {}
+                    });
+                else if (!obj) obj = [];
+                resolve(obj);
+            });
+        });
+    }
+
+    /**
+     * Publish a message to a channel, caches the redis client connection
+     *
+     * @param {String} channel - the name of the channel we want to publish a message to
+     * @param {*} value - the value we want to send
+     * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
+     */
+    PUB(payload) {
+        //channel, value, stringifyJson = true
+        return new Promise((resolve, reject) => {
+            /*if (pubs[channel] === undefined) {
+            pubs[channel] = redis.createClient({ url: this.url });
+            pubs[channel].on('error', (err) => console.error);
+            }*/
+
+            let value = payload.value;
+
+            if (["object", "array"].includes(typeof value))
+                value = JSON.stringify(value);
+
+            //pubs[channel].publish(channel, value);
+            this.client.publish(payload.channel, value);
+
+            resolve();
+        });
+    }
+
+    /**
+     * Subscribe to a channel, caches the redis client connection
+     *
+     * @param {String} channel - name of the channel to subscribe to
+     * @param {Function} cb - gets called when a message is received
+     * @param {Boolean} [parseJson=true] - parse the message as JSON
+     */
+    SUB(payload) {
+        //channel, cb, parseJson = true
+        return new Promise((resolve, reject) => {
+            if (subs[payload.channel] === undefined) {
+                subs[payload.channel] = {
+                    client: redis.createClient({
+                        url: this.url,
+                        password: this.password,
+                    }),
+                    cbs: [],
+                };
+                subs[payload.channel].client.on(
+                    "message",
+                    (channel, message) => {
+                        try {
+                            message = JSON.parse(message);
+                        } catch (e) {}
+                        subs[channel].cbs.forEach((cb) => payload.cb(message));
+                    }
+                );
+                subs[payload.channel].client.subscribe(payload.channel);
+            }
+
+            subs[payload.channel].cbs.push(payload.cb);
+
+            resolve();
+        });
+    }
+
+    GET_SCHEMA(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.schemas[payload.schemaName]);
+        });
+    }
 }
+
+module.exports = new CacheModule();

+ 324 - 192
backend/logic/db/index.js

@@ -1,129 +1,199 @@
-'use strict';
+const CoreClass = require("../../core.js");
 
-const coreClass = require("../../core");
-
-const mongoose = require('mongoose');
-const config = require('config');
+const mongoose = require("mongoose");
+const config = require("config");
 
 const regex = {
-	azAZ09_: /^[A-Za-z0-9_]+$/,
-	az09_: /^[a-z0-9_]+$/,
-	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
-	ascii: /^[\x00-\x7F]+$/,
-	custom: regex => new RegExp(`^[${regex}]+$`)
+    azAZ09_: /^[A-Za-z0-9_]+$/,
+    az09_: /^[a-z0-9_]+$/,
+    emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
+    ascii: /^[\x00-\x7F]+$/,
+    custom: (regex) => new RegExp(`^[${regex}]+$`),
 };
 
 const isLength = (string, min, max) => {
-	return !(typeof string !== 'string' || string.length < min || string.length > max);
-}
+    return !(
+        typeof string !== "string" ||
+        string.length < min ||
+        string.length > max
+    );
+};
 
-const bluebird = require('bluebird');
+const bluebird = require("bluebird");
 
 mongoose.Promise = bluebird;
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.schemas = {};
-			this.models = {};
-
-			const mongoUrl = config.get("mongo").url;
-
-			mongoose.connect(mongoUrl, {
-				useNewUrlParser: true,
-				useCreateIndex: true,
-				reconnectInterval: 3000,
-				reconnectTries: 10
-			})
-				.then(() => {
-					this.schemas = {
-						song: new mongoose.Schema(require(`./schemas/song`)),
-						queueSong: new mongoose.Schema(require(`./schemas/queueSong`)),
-						station: new mongoose.Schema(require(`./schemas/station`)),
-						user: new mongoose.Schema(require(`./schemas/user`)),
-						activity: new mongoose.Schema(require(`./schemas/activity`)),
-						playlist: new mongoose.Schema(require(`./schemas/playlist`)),
-						news: new mongoose.Schema(require(`./schemas/news`)),
-						report: new mongoose.Schema(require(`./schemas/report`)),
-						punishment: new mongoose.Schema(require(`./schemas/punishment`))
-					};
-		
-					this.models = {
-						song: mongoose.model('song', this.schemas.song),
-						queueSong: mongoose.model('queueSong', this.schemas.queueSong),
-						station: mongoose.model('station', this.schemas.station),
-						user: mongoose.model('user', this.schemas.user),
-						activity: mongoose.model('activity', this.schemas.activity),
-						playlist: mongoose.model('playlist', this.schemas.playlist),
-						news: mongoose.model('news', this.schemas.news),
-						report: mongoose.model('report', this.schemas.report),
-						punishment: mongoose.model('punishment', this.schemas.punishment)
-					};
-
-					mongoose.connection.on('error', err => {
-						this.logger.error("DB_MODULE", err);
-					});
-
-					mongoose.connection.on('disconnected', () => {
-						this.logger.error("DB_MODULE", "Disconnected, going to try to reconnect...");
-						this.setState("RECONNECTING");
-					});
-
-					mongoose.connection.on('reconnected', () => {
-						this.logger.success("DB_MODULE", "Reconnected.");
-						this.setState("INITIALIZED");
-					});
-
-					mongoose.connection.on('reconnectFailed', () => {
-						this.logger.error("DB_MODULE", "Reconnect failed, stopping reconnecting.");
-						this.failed = true;
-						this._lockdown();
-					});
-		
-					// User
-					this.schemas.user.path('username').validate((username) => {
-						return (isLength(username, 2, 32) && regex.custom("a-zA-Z0-9_-").test(username));
-					}, 'Invalid username.');
-		
-					this.schemas.user.path('email.address').validate((email) => {
-						if (!isLength(email, 3, 254)) return false;
-						if (email.indexOf('@') !== email.lastIndexOf('@')) return false;
-						return regex.emailSimple.test(email) && regex.ascii.test(email);
-					}, 'Invalid email.');
-
-					// Station
-					this.schemas.station.path('name').validate((id) => {
-						return (isLength(id, 2, 16) && regex.az09_.test(id));
-					}, 'Invalid station name.');
-		
-					this.schemas.station.path('displayName').validate((displayName) => {
-						return (isLength(displayName, 2, 32) && regex.ascii.test(displayName));
-					}, 'Invalid display name.');
-		
-					this.schemas.station.path('description').validate((description) => {
-						if (!isLength(description, 2, 200)) return false;
-						let characters = description.split("");
-						return characters.filter((character) => {
-							return character.charCodeAt(0) === 21328;
-						}).length === 0;
-					}, 'Invalid display name.');
-		
-					this.schemas.station.path('owner').validate({
-						validator: (owner) => {
-							return new Promise((resolve, reject) => {
-								this.models.station.countDocuments({ owner: owner }, (err, c) => {
-									if (err) reject(new Error("A mongo error happened."));
-									else if (c >= 3) reject(new Error("User already has 3 stations."));
-									else resolve();
-								});
-							});
-						},
-						message: 'User already has 3 stations.'
-					});
-		
-					/*
+class DBModule extends CoreClass {
+    constructor() {
+        super("db");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.schemas = {};
+            this.models = {};
+
+            const mongoUrl = config.get("mongo").url;
+
+            mongoose
+                .connect(mongoUrl, {
+                    useNewUrlParser: true,
+                    useCreateIndex: true,
+                    reconnectInterval: 3000,
+                    reconnectTries: 10,
+                })
+                .then(() => {
+                    this.schemas = {
+                        song: new mongoose.Schema(require(`./schemas/song`)),
+                        queueSong: new mongoose.Schema(
+                            require(`./schemas/queueSong`)
+                        ),
+                        station: new mongoose.Schema(
+                            require(`./schemas/station`)
+                        ),
+                        user: new mongoose.Schema(require(`./schemas/user`)),
+                        activity: new mongoose.Schema(
+                            require(`./schemas/activity`)
+                        ),
+                        playlist: new mongoose.Schema(
+                            require(`./schemas/playlist`)
+                        ),
+                        news: new mongoose.Schema(require(`./schemas/news`)),
+                        report: new mongoose.Schema(
+                            require(`./schemas/report`)
+                        ),
+                        punishment: new mongoose.Schema(
+                            require(`./schemas/punishment`)
+                        ),
+                    };
+
+                    this.models = {
+                        song: mongoose.model("song", this.schemas.song),
+                        queueSong: mongoose.model(
+                            "queueSong",
+                            this.schemas.queueSong
+                        ),
+                        station: mongoose.model(
+                            "station",
+                            this.schemas.station
+                        ),
+                        user: mongoose.model("user", this.schemas.user),
+                        activity: mongoose.model(
+                            "activity",
+                            this.schemas.activity
+                        ),
+                        playlist: mongoose.model(
+                            "playlist",
+                            this.schemas.playlist
+                        ),
+                        news: mongoose.model("news", this.schemas.news),
+                        report: mongoose.model("report", this.schemas.report),
+                        punishment: mongoose.model(
+                            "punishment",
+                            this.schemas.punishment
+                        ),
+                    };
+
+                    mongoose.connection.on("error", (err) => {
+                        this.log("ERROR", err);
+                    });
+
+                    mongoose.connection.on("disconnected", () => {
+                        this.log(
+                            "ERROR",
+                            "Disconnected, going to try to reconnect..."
+                        );
+                        this.setStatus("RECONNECTING");
+                    });
+
+                    mongoose.connection.on("reconnected", () => {
+                        this.log("INFO", "Reconnected.");
+                        this.setStatus("READY");
+                    });
+
+                    mongoose.connection.on("reconnectFailed", () => {
+                        this.log(
+                            "INFO",
+                            "Reconnect failed, stopping reconnecting."
+                        );
+                        // this.failed = true;
+                        // this._lockdown();
+                        this.setStatus("FAILED");
+                    });
+
+                    // User
+                    this.schemas.user.path("username").validate((username) => {
+                        return (
+                            isLength(username, 2, 32) &&
+                            regex.custom("a-zA-Z0-9_-").test(username)
+                        );
+                    }, "Invalid username.");
+
+                    this.schemas.user
+                        .path("email.address")
+                        .validate((email) => {
+                            if (!isLength(email, 3, 254)) return false;
+                            if (email.indexOf("@") !== email.lastIndexOf("@"))
+                                return false;
+                            return (
+                                regex.emailSimple.test(email) &&
+                                regex.ascii.test(email)
+                            );
+                        }, "Invalid email.");
+
+                    // Station
+                    this.schemas.station.path("name").validate((id) => {
+                        return isLength(id, 2, 16) && regex.az09_.test(id);
+                    }, "Invalid station name.");
+
+                    this.schemas.station
+                        .path("displayName")
+                        .validate((displayName) => {
+                            return (
+                                isLength(displayName, 2, 32) &&
+                                regex.ascii.test(displayName)
+                            );
+                        }, "Invalid display name.");
+
+                    this.schemas.station
+                        .path("description")
+                        .validate((description) => {
+                            if (!isLength(description, 2, 200)) return false;
+                            let characters = description.split("");
+                            return (
+                                characters.filter((character) => {
+                                    return character.charCodeAt(0) === 21328;
+                                }).length === 0
+                            );
+                        }, "Invalid display name.");
+
+                    this.schemas.station.path("owner").validate({
+                        validator: (owner) => {
+                            return new Promise((resolve, reject) => {
+                                this.models.station.countDocuments(
+                                    { owner: owner },
+                                    (err, c) => {
+                                        if (err)
+                                            reject(
+                                                new Error(
+                                                    "A mongo error happened."
+                                                )
+                                            );
+                                        else if (c >= 3)
+                                            reject(
+                                                new Error(
+                                                    "User already has 3 stations."
+                                                )
+                                            );
+                                        else resolve();
+                                    }
+                                );
+                            });
+                        },
+                        message: "User already has 3 stations.",
+                    });
+
+                    /*
 					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
 						let totalDuration = 0;
 						queue.forEach((song) => {
@@ -160,81 +230,143 @@ module.exports = class extends coreClass {
 					}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
 					*/
 
+                    // Song
+                    let songTitle = (title) => {
+                        return isLength(title, 1, 100);
+                    };
+                    this.schemas.song
+                        .path("title")
+                        .validate(songTitle, "Invalid title.");
+                    this.schemas.queueSong
+                        .path("title")
+                        .validate(songTitle, "Invalid title.");
 
-					// Song
-					let songTitle = (title) => {
-						return isLength(title, 1, 100);
-					};
-					this.schemas.song.path('title').validate(songTitle, 'Invalid title.');
-					this.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
-		
-					this.schemas.song.path('artists').validate((artists) => {
-						return !(artists.length < 1 || artists.length > 10);
-					}, 'Invalid artists.');
-					this.schemas.queueSong.path('artists').validate((artists) => {
-						return !(artists.length < 0 || artists.length > 10);
-					}, 'Invalid artists.');
-		
-					let songArtists = (artists) => {
-						return artists.filter((artist) => {
-								return (isLength(artist, 1, 64) && artist !== "NONE");
-							}).length === artists.length;
-					};
-					this.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
-					this.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
-		
-					let songGenres = (genres) => {
-						if (genres.length < 1 || genres.length > 16) return false;
-						return genres.filter((genre) => {
-								return (isLength(genre, 1, 32) && regex.ascii.test(genre));
-							}).length === genres.length;
-					};
-					this.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
-					this.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');
-		
-					let songThumbnail = (thumbnail) => {
-						if (!isLength(thumbnail, 1, 256)) return false;
-						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
-						else return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
-					};
-					this.schemas.song.path('thumbnail').validate(songThumbnail, 'Invalid thumbnail.');
-					this.schemas.queueSong.path('thumbnail').validate(songThumbnail, 'Invalid thumbnail.');
-
-					// Playlist
-					this.schemas.playlist.path('displayName').validate((displayName) => {
-						return (isLength(displayName, 1, 32) && regex.ascii.test(displayName));
-					}, 'Invalid display name.');
-		
-					this.schemas.playlist.path('createdBy').validate((createdBy) => {
-						this.models.playlist.countDocuments({ createdBy: createdBy }, (err, c) => {
-							return !(err || c >= 10);
-						});
-					}, 'Max 10 playlists per user.');
-		
-					this.schemas.playlist.path('songs').validate((songs) => {
-						return songs.length <= 5000;
-					}, 'Max 5000 songs per playlist.');
-		
-					this.schemas.playlist.path('songs').validate((songs) => {
-						if (songs.length === 0) return true;
-						return songs[0].duration <= 10800;
-					}, 'Max 3 hours per song.');
-		
-					// Report
-					this.schemas.report.path('description').validate((description) => {
-						return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
-					}, 'Invalid description.');
-
-					resolve();
-				})
-				.catch(err => {
-					this.logger.error("DB_MODULE", err);
-					reject(err);
-				});
-		})
-	}
-
-	passwordValid(password) {
-		return isLength(password, 6, 200);
-	}
+                    this.schemas.song.path("artists").validate((artists) => {
+                        return !(artists.length < 1 || artists.length > 10);
+                    }, "Invalid artists.");
+                    this.schemas.queueSong
+                        .path("artists")
+                        .validate((artists) => {
+                            return !(artists.length < 0 || artists.length > 10);
+                        }, "Invalid artists.");
+
+                    let songArtists = (artists) => {
+                        return (
+                            artists.filter((artist) => {
+                                return (
+                                    isLength(artist, 1, 64) && artist !== "NONE"
+                                );
+                            }).length === artists.length
+                        );
+                    };
+                    this.schemas.song
+                        .path("artists")
+                        .validate(songArtists, "Invalid artists.");
+                    this.schemas.queueSong
+                        .path("artists")
+                        .validate(songArtists, "Invalid artists.");
+
+                    let songGenres = (genres) => {
+                        if (genres.length < 1 || genres.length > 16)
+                            return false;
+                        return (
+                            genres.filter((genre) => {
+                                return (
+                                    isLength(genre, 1, 32) &&
+                                    regex.ascii.test(genre)
+                                );
+                            }).length === genres.length
+                        );
+                    };
+                    this.schemas.song
+                        .path("genres")
+                        .validate(songGenres, "Invalid genres.");
+                    this.schemas.queueSong
+                        .path("genres")
+                        .validate(songGenres, "Invalid genres.");
+
+                    let songThumbnail = (thumbnail) => {
+                        if (!isLength(thumbnail, 1, 256)) return false;
+                        if (config.get("cookie.secure") === true)
+                            return thumbnail.startsWith("https://");
+                        else
+                            return (
+                                thumbnail.startsWith("http://") ||
+                                thumbnail.startsWith("https://")
+                            );
+                    };
+                    this.schemas.song
+                        .path("thumbnail")
+                        .validate(songThumbnail, "Invalid thumbnail.");
+                    this.schemas.queueSong
+                        .path("thumbnail")
+                        .validate(songThumbnail, "Invalid thumbnail.");
+
+                    // Playlist
+                    this.schemas.playlist
+                        .path("displayName")
+                        .validate((displayName) => {
+                            return (
+                                isLength(displayName, 1, 32) &&
+                                regex.ascii.test(displayName)
+                            );
+                        }, "Invalid display name.");
+
+                    this.schemas.playlist
+                        .path("createdBy")
+                        .validate((createdBy) => {
+                            this.models.playlist.countDocuments(
+                                { createdBy: createdBy },
+                                (err, c) => {
+                                    return !(err || c >= 10);
+                                }
+                            );
+                        }, "Max 10 playlists per user.");
+
+                    this.schemas.playlist.path("songs").validate((songs) => {
+                        return songs.length <= 5000;
+                    }, "Max 5000 songs per playlist.");
+
+                    this.schemas.playlist.path("songs").validate((songs) => {
+                        if (songs.length === 0) return true;
+                        return songs[0].duration <= 10800;
+                    }, "Max 3 hours per song.");
+
+                    // Report
+                    this.schemas.report
+                        .path("description")
+                        .validate((description) => {
+                            return (
+                                !description ||
+                                (isLength(description, 0, 400) &&
+                                    regex.ascii.test(description))
+                            );
+                        }, "Invalid description.");
+
+                    resolve();
+                })
+                .catch((err) => {
+                    this.log("ERROR", err);
+                    reject(err);
+                });
+        });
+    }
+
+    GET_MODEL(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.models[payload.modelName]);
+        });
+    }
+
+    GET_SCHEMA(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.schemas[payload.schemaName]);
+        });
+    }
+
+    passwordValid(password) {
+        return isLength(password, 6, 200);
+    }
 }
+
+module.exports = new DBModule();

+ 99 - 77
backend/logic/discord.js

@@ -1,91 +1,113 @@
-const coreClass = require("../core");
+const CoreClass = require("../core.js");
 
-const EventEmitter = require('events');
 const Discord = require("discord.js");
 const config = require("config");
 
-const bus = new EventEmitter();
+class DiscordModule extends CoreClass {
+    constructor() {
+        super("discord");
+    }
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.log("INFO", "Discord initialize");
 
-			this.client = new Discord.Client();
-			this.adminAlertChannelId = config.get("apis.discord").loggingChannel;
-			
-			this.client.on("ready", () => {
-				this.logger.info("DISCORD_MODULE", `Logged in as ${this.client.user.tag}!`);
+            this.client = new Discord.Client();
+            this.adminAlertChannelId = config.get(
+                "apis.discord"
+            ).loggingChannel;
 
-				if (this.state === "INITIALIZING") resolve();
-				else {
-					this.logger.info("DISCORD_MODULE", `Discord client reconnected.`);
-					this.setState("INITIALIZED");
-				}
-			});
-		  
-			this.client.on("disconnect", () => {
-				this.logger.info("DISCORD_MODULE", `Discord client disconnected.`);
+            this.client.on("ready", () => {
+                this.log("INFO", `Logged in as ${this.client.user.tag}!`);
 
-				if (this.state === "INITIALIZING") reject();
-				else {
-					this.failed = true;
-					this._lockdown;
-				} 
-			});
+                if (this.getStatus() === "INITIALIZING") {
+                    resolve();
+                } else if (this.getStatus() === "RECONNECTING") {
+                    this.log("INFO", `Discord client reconnected.`);
+                    this.setStatus("READY");
+                }
+            });
 
-			this.client.on("reconnecting", () => {
-				this.logger.info("DISCORD_MODULE", `Discord client reconnecting.`);
-				this.setState("RECONNECTING");
-			});
-		
-			this.client.on("error", err => {
-				this.logger.info("DISCORD_MODULE", `Discord client encountered an error: ${err.message}.`);
-			});
+            this.client.on("disconnect", () => {
+                this.log("INFO", `Discord client disconnected.`);
 
-			this.client.login(config.get("apis.discord").token);
-		});
-	}
+                if (this.getStatus() === "INITIALIZING") reject();
+                else {
+                    this.setStatus("DISCONNECTED");
+                }
+            });
 
-	async sendAdminAlertMessage(message, color, type, critical, extraFields) {
-		try { await this._validateHook(); } catch { return; }
+            this.client.on("reconnecting", () => {
+                this.log("INFO", `Discord client reconnecting.`);
+                this.setStatus("RECONNECTING");
+            });
 
-		const channel = this.client.channels.find(channel => channel.id === this.adminAlertChannelId);
-		if (channel !== null) {
-			let richEmbed = new Discord.RichEmbed();
-			richEmbed.setAuthor(
-				"Musare Logger",
-				`${config.get("domain")}/favicon-194x194.png`,
-				config.get("domain")
-			);
-			richEmbed.setColor(color);
-			richEmbed.setDescription(message);
-			//richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
-			//richEmbed.setImage("https://musare.com/favicon-194x194.png");
-			//richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
-			richEmbed.setTimestamp(new Date());
-			richEmbed.setTitle("MUSARE ALERT");
-			richEmbed.setURL(config.get("domain"));
-			richEmbed.addField("Type:", type, true);
-			richEmbed.addField("Critical:", critical ? "True" : "False", true);
-			extraFields.forEach(extraField => {
-				richEmbed.addField(
-					extraField.name,
-					extraField.value,
-					extraField.inline
-				);
-			});
+            this.client.on("error", (err) => {
+                this.log(
+                    "INFO",
+                    `Discord client encountered an error: ${err.message}.`
+                );
+            });
 
-			channel
-			.send(message, { embed: richEmbed })
-			.then(message =>
-				this.logger.success("SEND_ADMIN_ALERT_MESSAGE", `Sent admin alert message: ${message}`)
-			)
-			.catch(() =>
-				this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message")
-			);
-		} else {
-			this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message, channel was not found.");
-		}
-	}
+            this.client.login(config.get("apis.discord").token);
+        });
+    }
+
+    SEND_ADMIN_ALERT_MESSAGE(payload) {
+        return new Promise((resolve, reject) => {
+            const channel = this.client.channels.find(
+                (channel) => channel.id === this.adminAlertChannelId
+            );
+            if (channel !== null) {
+                let richEmbed = new Discord.RichEmbed();
+                richEmbed.setAuthor(
+                    "Musare Logger",
+                    `${config.get("domain")}/favicon-194x194.png`,
+                    config.get("domain")
+                );
+                richEmbed.setColor(payload.color);
+                richEmbed.setDescription(payload.message);
+                //richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
+                //richEmbed.setImage("https://musare.com/favicon-194x194.png");
+                //richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
+                richEmbed.setTimestamp(new Date());
+                richEmbed.setTitle("MUSARE ALERT");
+                richEmbed.setURL(config.get("domain"));
+                richEmbed.addField("Type:", payload.type, true);
+                richEmbed.addField(
+                    "Critical:",
+                    payload.critical ? "True" : "False",
+                    true
+                );
+                payload.extraFields.forEach((extraField) => {
+                    richEmbed.addField(
+                        extraField.name,
+                        extraField.value,
+                        extraField.inline
+                    );
+                });
+
+                channel
+                    .send(payload.message, { embed: richEmbed })
+                    .then((message) =>
+                        resolve({
+                            status: "success",
+                            message: `Successfully sent admin alert message: ${message}`,
+                        })
+                    )
+                    .catch(() =>
+                        reject(new Error("Couldn't send admin alert message"))
+                    );
+            } else {
+                reject(new Error("Channel was not found"));
+            }
+            // if (true) {
+            //     resolve({});
+            // } else {
+            //     reject(new Error("Nothing changed."));
+            // }
+        });
+    }
 }
+
+module.exports = new DiscordModule();

+ 371 - 212
backend/logic/io.js

@@ -1,218 +1,377 @@
-'use strict';
-
-// This file contains all the logic for Socket.IO
-
-const coreClass = require("../core");
+const CoreClass = require("../core.js");
 
 const socketio = require("socket.io");
 const async = require("async");
 const config = require("config");
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["app", "db", "cache", "utils"];
-	}
-
-	initialize() {
-		return new Promise(resolve => {
-			this.setStage(1);
-
-			const 	logger		= this.logger,
-					app			= this.moduleManager.modules["app"],
-					cache		= this.moduleManager.modules["cache"],
-					utils		= this.moduleManager.modules["utils"],
-					db			= this.moduleManager.modules["db"],
-					punishments	= this.moduleManager.modules["punishments"];
-			
-			const actions = require('../logic/actions');
-
-			const SIDname = config.get("cookie.SIDname");
-
-			// TODO: Check every 30s/60s, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
-			this._io = socketio(app.server);
-
-			this._io.use(async (socket, next) => {
-				try { await this._validateHook(); } catch { return; }
-
-				let SID;
-
-				socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
-
-				async.waterfall([
-					(next) => {
-						utils.parseCookies(
-							socket.request.headers.cookie
-						).then(res => {
-							SID = res[SIDname];
-							next(null);
-						});
-					},
-
-					(next) => {
-						if (!SID) return next('No SID.');
-						next();
-					},
-
-					(next) => {
-						cache.hget('sessions', SID, next);
-					},
-
-					(session, next) => {
-						if (!session) return next('No session found.');
-
-						session.refreshDate = Date.now();
-						
-						socket.session = session;
-						cache.hset('sessions', SID, session, next);
-					},
-
-					(res, next) => {
-						// check if a session's user / IP is banned
-						punishments.getPunishments((err, punishments) => {
-							const isLoggedIn = !!(socket.session && socket.session.refreshDate);
-							const userId = (isLoggedIn) ? socket.session.userId : null;
-
-							let banishment = { banned: false, ban: 0 };
-
-							punishments.forEach(punishment => {
-								if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
-								if (punishment.type === 'banUserId' && isLoggedIn && punishment.value === userId) banishment.banned = true;
-								if (punishment.type === 'banUserIp' && punishment.value === socket.ip) banishment.banned = true;
-							});
-							
-							socket.banishment = banishment;
-
-							next();
-						});
-					}
-				], () => {
-					if (!socket.session) socket.session = { socketId: socket.id };
-					else socket.session.socketId = socket.id;
-
-					next();
-				});
-			});
-
-			this._io.on('connection', async socket => {
-				try { await this._validateHook(); } catch { return; }
-
-				let sessionInfo = '';
-				
-				if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-
-				// if session is banned
-				if (socket.banishment && socket.banishment.banned) {
-					logger.info('IO_BANNED_CONNECTION', `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`);
-					socket.emit('keep.event:banned', socket.banishment.ban);
-					socket.disconnect(true);
-				} else {
-					logger.info('IO_CONNECTION', `User connected. IP: ${socket.ip}.${sessionInfo}`);
-
-					// catch when the socket has been disconnected
-					socket.on('disconnect', () => {
-						if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-						logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
-					});
-
-					socket.use((data, next) => {
-						if (data.length === 0) return next(new Error("Not enough arguments specified."));
-						else if (typeof data[0] !== "string") return next(new Error("First argument must be a string."));
-						else {
-							const namespaceAction = data[0];
-							if (!namespaceAction || namespaceAction.indexOf(".") === -1 || namespaceAction.indexOf(".") !== namespaceAction.lastIndexOf(".")) return next(new Error("Invalid first argument"));
-							const namespace = data[0].split(".")[0];
-							const action = data[0].split(".")[1];
-							if (!namespace) return next(new Error("Invalid namespace."));
-							else if (!action) return next(new Error("Invalid action."));
-							else if (!actions[namespace]) return next(new Error("Namespace not found."));
-							else if (!actions[namespace][action]) return next(new Error("Action not found."));
-							else return next();
-						}
-					});
-
-					// catch errors on the socket (internal to socket.io)
-					socket.on('error', console.error);
-
-					// have the socket listen for each action
-					Object.keys(actions).forEach(namespace => {
-						Object.keys(actions[namespace]).forEach(action => {
-
-							// the full name of the action
-							let name = `${namespace}.${action}`;
-
-							// listen for this action to be called
-							socket.on(name, async (...args) => {
-								let cb = args[args.length - 1];
-								if (typeof cb !== "function")
-									cb = () => {
-										this.logger.info("IO_MODULE", `There was no callback provided for ${name}.`);
-									}
-								else args.pop();
-
-								try { await this._validateHook(); } catch { return cb({status: 'failure', message: 'Lockdown'}); } 
-
-								// load the session from the cache
-								cache.hget('sessions', socket.session.sessionId, (err, session) => {
-									if (err && err !== true) {
-										if (typeof cb === 'function') return cb({
-											status: 'error',
-											message: 'An error occurred while obtaining your session'
-										});
-									}
-
-									// make sure the sockets sessionId isn't set if there is no session
-									if (socket.session.sessionId && session === null) delete socket.session.sessionId;
-
-									try {
-										// call the action, passing it the session, and the arguments socket.io passed us
-										actions[namespace][action].apply(null, [socket.session].concat(args).concat([
-											(result) => {
-												// respond to the socket with our message
-												if (typeof cb === 'function') return cb(result);
-											}
-										]));
-									} catch(err) {
-										this.logger.error("IO_ACTION_ERROR", `Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`);
-										if (typeof cb === 'function') return cb({
-											status: "error",
-											message: "An error occurred while executing the specified action."
-										});
-									}
-								});
-							})
-						})
-					});
-
-					if (socket.session.sessionId) {
-						cache.hget('sessions', socket.session.sessionId, (err, session) => {
-							if (err && err !== true) socket.emit('ready', false);
-							else if (session && session.userId) {
-								db.models.user.findOne({ _id: session.userId }, (err, user) => {
-									if (err || !user) return socket.emit('ready', false);
-									let role = '';
-									let username = '';
-									let userId = '';
-									if (user) {
-										role = user.role;
-										username = user.username;
-										userId = session.userId;
-									}
-									socket.emit('ready', true, role, username, userId);
-								});
-							} else socket.emit('ready', false);
-						})
-					} else socket.emit('ready', false);
-				}
-			});
-
-			resolve();
-		});
-	}
-
-	async io () {
-		try { await this._validateHook(); } catch { return; }
-		return this._io;
-	}
+class IOModule extends CoreClass {
+    constructor() {
+        super("io");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            const app = this.moduleManager.modules["app"],
+                cache = this.moduleManager.modules["cache"],
+                utils = this.moduleManager.modules["utils"],
+                db = this.moduleManager.modules["db"],
+                punishments = this.moduleManager.modules["punishments"];
+
+            const actions = require("./actions");
+
+            this.setStage(2);
+
+            const SIDname = config.get("cookie.SIDname");
+
+            // TODO: Check every 30s/60s, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
+            this._io = socketio(await app.runJob("SERVER", {}));
+
+            this.setStage(3);
+
+            this._io.use(async (socket, next) => {
+                if (this.getStatus() !== "READY") {
+                    this.log(
+                        "INFO",
+                        "IO_REJECTED_CONNECTION",
+                        `A user tried to connect, but the IO module is currently not ready. IP: ${socket.ip}.${sessionInfo}`
+                    );
+                    return socket.disconnect(true);
+                }
+
+                let SID;
+
+                socket.ip =
+                    socket.request.headers["x-forwarded-for"] || "0.0.0.0";
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            utils
+                                .runJob("PARSE_COOKIES", {
+                                    cookieString: socket.request.headers.cookie,
+                                })
+                                .then((res) => {
+                                    SID = res[SIDname];
+                                    next(null);
+                                });
+                        },
+
+                        (next) => {
+                            if (!SID) return next("No SID.");
+                            next();
+                        },
+
+                        (next) => {
+                            cache
+                                .runJob("HGET", { table: "sessions", key: SID })
+                                .then((session) => {
+                                    next(null, session);
+                                });
+                        },
+
+                        (session, next) => {
+                            if (!session) return next("No session found.");
+
+                            session.refreshDate = Date.now();
+
+                            socket.session = session;
+                            cache
+                                .runJob("HSET", {
+                                    table: "sessions",
+                                    key: SID,
+                                    value: session,
+                                })
+                                .then((session) => {
+                                    next(null, session);
+                                });
+                        },
+
+                        (res, next) => {
+                            // check if a session's user / IP is banned
+                            punishments
+                                .runJob("GET_PUNISHMENTS", {})
+                                .then((punishments) => {
+                                    const isLoggedIn = !!(
+                                        socket.session &&
+                                        socket.session.refreshDate
+                                    );
+                                    const userId = isLoggedIn
+                                        ? socket.session.userId
+                                        : null;
+
+                                    let banishment = { banned: false, ban: 0 };
+
+                                    punishments.forEach((punishment) => {
+                                        if (
+                                            punishment.expiresAt >
+                                            banishment.ban
+                                        )
+                                            banishment.ban = punishment;
+                                        if (
+                                            punishment.type === "banUserId" &&
+                                            isLoggedIn &&
+                                            punishment.value === userId
+                                        )
+                                            banishment.banned = true;
+                                        if (
+                                            punishment.type === "banUserIp" &&
+                                            punishment.value === socket.ip
+                                        )
+                                            banishment.banned = true;
+                                    });
+
+                                    socket.banishment = banishment;
+
+                                    next();
+                                })
+                                .catch(() => {
+                                    next();
+                                });
+                        },
+                    ],
+                    () => {
+                        if (!socket.session)
+                            socket.session = { socketId: socket.id };
+                        else socket.session.socketId = socket.id;
+
+                        next();
+                    }
+                );
+            });
+
+            this.setStage(4);
+
+            this._io.on("connection", async (socket) => {
+                if (this.getStatus() !== "READY") {
+                    this.log(
+                        "INFO",
+                        "IO_REJECTED_CONNECTION",
+                        `A user tried to connect, but the IO module is currently not ready. IP: ${socket.ip}.${sessionInfo}`
+                    );
+                    return socket.disconnect(true);
+                }
+
+                let sessionInfo = "";
+
+                if (socket.session.sessionId)
+                    sessionInfo = ` UserID: ${socket.session.userId}.`;
+
+                // if session is banned
+                if (socket.banishment && socket.banishment.banned) {
+                    this.log(
+                        "INFO",
+                        "IO_BANNED_CONNECTION",
+                        `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`
+                    );
+                    socket.emit("keep.event:banned", socket.banishment.ban);
+                    socket.disconnect(true);
+                } else {
+                    this.log(
+                        "INFO",
+                        "IO_CONNECTION",
+                        `User connected. IP: ${socket.ip}.${sessionInfo}`
+                    );
+
+                    // catch when the socket has been disconnected
+                    socket.on("disconnect", () => {
+                        if (socket.session.sessionId)
+                            sessionInfo = ` UserID: ${socket.session.userId}.`;
+                        this.log(
+                            "INFO",
+                            "IO_DISCONNECTION",
+                            `User disconnected. IP: ${socket.ip}.${sessionInfo}`
+                        );
+                    });
+
+                    socket.use((data, next) => {
+                        if (data.length === 0)
+                            return next(
+                                new Error("Not enough arguments specified.")
+                            );
+                        else if (typeof data[0] !== "string")
+                            return next(
+                                new Error("First argument must be a string.")
+                            );
+                        else {
+                            const namespaceAction = data[0];
+                            if (
+                                !namespaceAction ||
+                                namespaceAction.indexOf(".") === -1 ||
+                                namespaceAction.indexOf(".") !==
+                                    namespaceAction.lastIndexOf(".")
+                            )
+                                return next(
+                                    new Error("Invalid first argument")
+                                );
+                            const namespace = data[0].split(".")[0];
+                            const action = data[0].split(".")[1];
+                            if (!namespace)
+                                return next(new Error("Invalid namespace."));
+                            else if (!action)
+                                return next(new Error("Invalid action."));
+                            else if (!actions[namespace])
+                                return next(new Error("Namespace not found."));
+                            else if (!actions[namespace][action])
+                                return next(new Error("Action not found."));
+                            else return next();
+                        }
+                    });
+
+                    // catch errors on the socket (internal to socket.io)
+                    socket.on("error", console.error);
+
+                    // have the socket listen for each action
+                    Object.keys(actions).forEach((namespace) => {
+                        Object.keys(actions[namespace]).forEach((action) => {
+                            // the full name of the action
+                            let name = `${namespace}.${action}`;
+
+                            // listen for this action to be called
+                            socket.on(name, async (...args) => {
+                                let cb = args[args.length - 1];
+                                if (typeof cb !== "function")
+                                    cb = () => {
+                                        this.this.log(
+                                            "INFO",
+                                            "IO_MODULE",
+                                            `There was no callback provided for ${name}.`
+                                        );
+                                    };
+                                else args.pop();
+
+                                if (this.getStatus() !== "READY") {
+                                    this.log(
+                                        "INFO",
+                                        "IO_REJECTED_ACTION",
+                                        `A user tried to execute an action, but the IO module is currently not ready. Action: ${namespace}.${action}.`
+                                    );
+                                    return;
+                                } else {
+                                    this.log(
+                                        "INFO",
+                                        "IO_ACTION",
+                                        `A user executed an action. Action: ${namespace}.${action}.`
+                                    );
+                                }
+
+                                // load the session from the cache
+                                cache
+                                    .runJob("HGET", {
+                                        table: "sessions",
+                                        key: socket.session.sessionId,
+                                    })
+                                    .then((session) => {
+                                        // make sure the sockets sessionId isn't set if there is no session
+                                        if (
+                                            socket.session.sessionId &&
+                                            session === null
+                                        )
+                                            delete socket.session.sessionId;
+
+                                        try {
+                                            // call the action, passing it the session, and the arguments socket.io passed us
+                                            actions[namespace][action].apply(
+                                                null,
+                                                [socket.session]
+                                                    .concat(args)
+                                                    .concat([
+                                                        (result) => {
+                                                            // respond to the socket with our message
+                                                            if (
+                                                                typeof cb ===
+                                                                "function"
+                                                            )
+                                                                return cb(
+                                                                    result
+                                                                );
+                                                        },
+                                                    ])
+                                            );
+                                        } catch (err) {
+                                            this.log(
+                                                "ERROR",
+                                                "IO_ACTION_ERROR",
+                                                `Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`
+                                            );
+                                            if (typeof cb === "function")
+                                                return cb({
+                                                    status: "error",
+                                                    message:
+                                                        "An error occurred while executing the specified action.",
+                                                });
+                                        }
+                                    })
+                                    .catch((err) => {
+                                        if (typeof cb === "function")
+                                            return cb({
+                                                status: "error",
+                                                message:
+                                                    "An error occurred while obtaining your session",
+                                            });
+                                    });
+                            });
+                        });
+                    });
+
+                    if (socket.session.sessionId) {
+                        cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: socket.session.sessionId,
+                            })
+                            .then((session) => {
+                                if (session && session.userId) {
+                                    db.runJob("GET_MODEL", {
+                                        modelName: "user",
+                                    }).then((userModel) => {
+                                        userModel.findOne(
+                                            { _id: session.userId },
+                                            (err, user) => {
+                                                if (err || !user)
+                                                    return socket.emit(
+                                                        "ready",
+                                                        false
+                                                    );
+                                                let role = "";
+                                                let username = "";
+                                                let userId = "";
+                                                if (user) {
+                                                    role = user.role;
+                                                    username = user.username;
+                                                    userId = session.userId;
+                                                }
+                                                socket.emit(
+                                                    "ready",
+                                                    true,
+                                                    role,
+                                                    username,
+                                                    userId
+                                                );
+                                            }
+                                        );
+                                    });
+                                } else socket.emit("ready", false);
+                            })
+                            .catch((err) => {
+                                socket.emit("ready", false);
+                            });
+                    } else socket.emit("ready", false);
+                }
+            });
+
+            this.setStage(5);
+
+            resolve();
+        });
+    }
+
+    IO() {
+        return new Promise((resolve, reject) => {
+            resolve(this._io);
+        });
+    }
 }
+
+module.exports = new IOModule();

+ 0 - 177
backend/logic/logger.js

@@ -1,177 +0,0 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const config = require('config');
-const fs = require('fs');
-
-const twoDigits = (num) => {
-	return (num < 10) ? '0' + num : num;
-};
-
-const getTime = () => {
-	let time = new Date();
-	return {
-		year: time.getFullYear(),
-		month: time.getMonth() + 1,
-		day: time.getDate(),
-		hour: time.getHours(),
-		minute: time.getMinutes(),
-		second: time.getSeconds()
-	}
-};
-
-const getTimeFormatted = () => {
-	let time = getTime();
-	return `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-}
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-		this.lockdownImmune = true;
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.configDirectory = `${__dirname}/../../log`;
-
-			if (!config.isDocker && !fs.existsSync(`${this.configDirectory}`))
-				fs.mkdirSync(this.configDirectory);
-
-			let time = getTimeFormatted();
-
-			this.logCbs = [];
-
-			this.colors = {
-				Reset: "\x1b[0m",
-				Bright: "\x1b[1m",
-				Dim: "\x1b[2m",
-				Underscore: "\x1b[4m",
-				Blink: "\x1b[5m",
-				Reverse: "\x1b[7m",
-				Hidden: "\x1b[8m",
-
-				FgBlack: "\x1b[30m",
-				FgRed: "\x1b[31m",
-				FgGreen: "\x1b[32m",
-				FgYellow: "\x1b[33m",
-				FgBlue: "\x1b[34m",
-				FgMagenta: "\x1b[35m",
-				FgCyan: "\x1b[36m",
-				FgWhite: "\x1b[37m",
-
-				BgBlack: "\x1b[40m",
-				BgRed: "\x1b[41m",
-				BgGreen: "\x1b[42m",
-				BgYellow: "\x1b[43m",
-				BgBlue: "\x1b[44m",
-				BgMagenta: "\x1b[45m",
-				BgCyan: "\x1b[46m",
-				BgWhite: "\x1b[47m"
-			};
-
-			fs.appendFile(this.configDirectory + '/all.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/success.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/error.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/info.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/debugStation.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-
-			if (this.moduleManager.fancyConsole) {
-				process.stdout.write(Array(this.reservedLines).fill(`\n`).join(""));
-			}
-
-			resolve();
-		});
-	}
-
-	async success(type, text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} SUCCESS - ${type} - ${text}`;
-
-		this.writeFile('all.log', message);
-		this.writeFile('success.log', message);
-
-		if (display) this.log(this.colors.FgGreen, message);
-	}
-
-	async error(type, text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} ERROR - ${type} - ${text}`;
-
-		this.writeFile('all.log', message);
-		this.writeFile('error.log', message);
-
-		if (display) this.log(this.colors.FgRed, message);
-	}
-
-	async info(type, text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} INFO - ${type} - ${text}`;
-
-		this.writeFile('all.log', message);
-		this.writeFile('info.log', message);
-		if (display) this.log(this.colors.FgCyan, message);
-	}
-
-	async debug(text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} DEBUG - ${text}`;
-
-		if (display) this.log(this.colors.FgMagenta, message);
-	}
-
-	async stationIssue(text, display = false) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} DEBUG_STATION - ${text}`;
-
-		this.writeFile('debugStation.log', message);
-
-		if (display) this.log(this.colors.FgMagenta, message);
-	}
-
-	log(color, message) {
-		if (this.moduleManager.fancyConsole) {
-			const rows = process.stdout.rows;
-			const columns = process.stdout.columns;
-			const lineNumber = rows - this.reservedLines;
-
-			
-			let lines = 0;
-			
-			message.split("\n").forEach((line) => {
-				lines += Math.floor(line.replace("\t", "    ").length / columns) + 1;
-			});
-
-			if (lines > this.logger.reservedLines)
-				lines = this.logger.reservedLines;
-
-			process.stdout.cursorTo(0, rows - this.logger.reservedLines);
-			process.stdout.clearScreenDown();
-
-			process.stdout.cursorTo(0, lineNumber);
-			process.stdout.write(`${color}${message}${this.colors.Reset}\n`);
-
-			process.stdout.cursorTo(0, process.stdout.rows);
-			process.stdout.write(Array(lines).fill(`\n!`).join(""));
-
-			this.moduleManager.printStatus();
-		} else console.log(`${color}${message}${this.colors.Reset}`);
-	}
-
-	writeFile(fileName, message) {
-		fs.appendFile(`${this.configDirectory}/${fileName}`, `${message}\n`, ()=>{});
-	}
-}

+ 45 - 35
backend/logic/mail/index.js

@@ -1,40 +1,50 @@
-'use strict';
+const CoreClass = require("../../core.js");
 
-const coreClass = require("../../core");
-
-const config = require('config');
+const config = require("config");
 
 let mailgun = null;
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.schemas = {
-				verifyEmail: require('./schemas/verifyEmail'),
-				resetPasswordRequest: require('./schemas/resetPasswordRequest'),
-				passwordRequest: require('./schemas/passwordRequest')
-			};
-
-			this.enabled = config.get('apis.mailgun.enabled');
-
-			if (this.enabled)
-				mailgun = require('mailgun-js')({
-					apiKey: config.get("apis.mailgun.key"),
-					domain: config.get("apis.mailgun.domain")
-				});
-			
-			resolve();
-		});
-	}
-
-	async sendMail(data, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!cb) cb = ()=>{};
-
-		if (this.enabled) mailgun.messages().send(data, cb);
-		else cb();
-	}
+class MailModule extends CoreClass {
+    constructor() {
+        super("mail");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.schemas = {
+                verifyEmail: require("./schemas/verifyEmail"),
+                resetPasswordRequest: require("./schemas/resetPasswordRequest"),
+                passwordRequest: require("./schemas/passwordRequest"),
+            };
+
+            this.enabled = config.get("apis.mailgun.enabled");
+
+            if (this.enabled)
+                mailgun = require("mailgun-js")({
+                    apiKey: config.get("apis.mailgun.key"),
+                    domain: config.get("apis.mailgun.domain"),
+                });
+
+            resolve();
+        });
+    }
+
+    SEND_MAIL(payload) {
+        //data, cb
+        return new Promise((resolve, reject) => {
+            if (this.enabled)
+                mailgun.messages().send(payload.data, () => {
+                    resolve();
+                });
+            else resolve();
+        });
+    }
+
+    GET_SCHEMA(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.schemas[payload.schemaName]);
+        });
+    }
 }
+
+module.exports = new MailModule();

+ 17 - 12
backend/logic/mail/schemas/passwordRequest.js

@@ -1,8 +1,8 @@
-const config = require('config');
+const config = require("config");
 
-const moduleManager = require('../../../index');
+// const moduleManager = require('../../../index');
 
-const mail = moduleManager.modules["mail"];
+const mail = require("../index");
 
 /**
  * Sends a request password email
@@ -13,12 +13,11 @@ const mail = moduleManager.modules["mail"];
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  */
 module.exports = function(to, username, code, cb) {
-	let data = {
-		from: 'Musare <noreply@musare.com>',
-		to: to,
-		subject: 'Password request',
-		html:
-			`
+    let data = {
+        from: "Musare <noreply@musare.com>",
+        to: to,
+        subject: "Password request",
+        html: `
 				Hello there ${username},
 				<br>
 				<br>
@@ -27,7 +26,13 @@ module.exports = function(to, username, code, cb) {
 				<br>
 				The code is <b>${code}</b>. You can enter this code on the page you requested the password on. This code will expire in 24 hours.
 			`
-	};
+    };
 
-	mail.sendMail(data, cb);
-};
+    mail.runJob("SEND_MAIL", { data })
+        .then(() => {
+            cb();
+        })
+        .catch(err => {
+            cb(err);
+        });
+};

+ 17 - 12
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,8 +1,8 @@
-const config = require('config');
+const config = require("config");
 
-const moduleManager = require('../../../index');
+// const moduleManager = require('../../../index');
 
-const mail = moduleManager.modules["mail"];
+const mail = require("../index");
 
 /**
  * Sends a request password reset email
@@ -13,12 +13,11 @@ const mail = moduleManager.modules["mail"];
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  */
 module.exports = function(to, username, code, cb) {
-	let data = {
-		from: 'Musare <noreply@musare.com>',
-		to: to,
-		subject: 'Password reset request',
-		html:
-			`
+    let data = {
+        from: "Musare <noreply@musare.com>",
+        to: to,
+        subject: "Password reset request",
+        html: `
 				Hello there ${username},
 				<br>
 				<br>
@@ -27,7 +26,13 @@ module.exports = function(to, username, code, cb) {
 				<br>
 				The reset code is <b>${code}</b>. You can enter this code on the page you requested the password reset. This code will expire in 24 hours.
 			`
-	};
+    };
 
-	mail.sendMail(data, cb);
-};
+    mail.runJob("SEND_MAIL", { data })
+        .then(() => {
+            cb();
+        })
+        .catch(err => {
+            cb(err);
+        });
+};

+ 22 - 13
backend/logic/mail/schemas/verifyEmail.js

@@ -1,8 +1,8 @@
-const config = require('config');
+const config = require("config");
 
-const moduleManager = require('../../../index');
+// const moduleManager = require('../../../index');
 
-const mail = moduleManager.modules["mail"];
+const mail = require("../index");
 
 /**
  * Sends a verify email email
@@ -13,18 +13,27 @@ const mail = moduleManager.modules["mail"];
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  */
 module.exports = function(to, username, code, cb) {
-	let data = {
-		from: 'Musare <noreply@musare.com>',
-		to: to,
-		subject: 'Please verify your email',
-		html:
-			`
+    let data = {
+        from: "Musare <noreply@musare.com>",
+        to: to,
+        subject: "Please verify your email",
+        html: `
 				Hello there ${username},
 				<br>
 				<br>
-				To verify your email, please visit <a href="${config.get('serverDomain')}/auth/verify_email?code=${code}">${config.get('serverDomain')}/auth/verify_email?code=${code}</a>.
+				To verify your email, please visit <a href="${config.get(
+                    "serverDomain"
+                )}/auth/verify_email?code=${code}">${config.get(
+            "serverDomain"
+        )}/auth/verify_email?code=${code}</a>.
 			`
-	};
+    };
 
-	mail.sendMail(data, cb);
-};
+    mail.runJob("SEND_MAIL", { data })
+        .then(() => {
+            cb();
+        })
+        .catch(err => {
+            cb(err);
+        });
+};

+ 238 - 154
backend/logic/notifications.js

@@ -1,159 +1,243 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
-
-const crypto = require('crypto');
-const redis = require('redis');
-const config = require('config');
+const crypto = require("crypto");
+const redis = require("redis");
+const config = require("config");
 
 const subscriptions = [];
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			const url = this.url = config.get("redis").url;
-			const password = this.password = config.get("redis").password;
-
-			this.pub = redis.createClient({
-				url,
-				password,
-				retry_strategy: (options) => {
-					if (this.state === "LOCKDOWN") return;
-					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
-
-					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect pub.`);
-
-					if (options.attempt >= 10) {
-						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect pub.`);
-
-						this.failed = true;
-						this._lockdown();
-
-						return undefined;
-					}
-
-					return 3000;
-				}
-			});
-			this.sub = redis.createClient({
-				url,
-				password,
-				retry_strategy: (options) => {
-					if (this.state === "LOCKDOWN") return;
-					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
-
-					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect sub.`);
-
-					if (options.attempt >= 10) {
-						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect sub.`);
-
-						this.failed = true;
-						this._lockdown();
-
-						return undefined;
-					}
-
-					return 3000;
-				}
-			});
-
-			this.sub.on('error', (err) => {
-				if (this.state === "INITIALIZING") reject(err);
-				if(this.state === "LOCKDOWN") return;
-
-				this.logger.error("NOTIFICATIONS_MODULE", `Sub error ${err.message}.`);
-			});
-
-			this.pub.on('error', (err) => {
-				if (this.state === "INITIALIZING") reject(err);
-				if(this.state === "LOCKDOWN") return; 
-
-				this.logger.error("NOTIFICATIONS_MODULE", `Pub error ${err.message}.`);
-			});
-
-			this.sub.on("connect", () => {
-				this.logger.info("NOTIFICATIONS_MODULE", "Sub connected succesfully.");
-
-				if (this.state === "INITIALIZING") resolve();
-				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
-				
-			});
-
-			this.pub.on("connect", () => {
-				this.logger.info("NOTIFICATIONS_MODULE", "Pub connected succesfully.");
-
-				if (this.state === "INITIALIZING") resolve();
-				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
-			});
-
-			this.sub.on('pmessage', (pattern, channel, expiredKey) => {
-				this.logger.stationIssue(`PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
-				subscriptions.forEach((sub) => {
-					this.logger.stationIssue(`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== expiredKey)}`);
-					if (sub.name !== expiredKey) return;
-					sub.cb();
-				});
-			});
-
-			this.sub.psubscribe('__keyevent@0__:expired');
-		});
-	}
-
-	/**
-	 * Schedules a notification to be dispatched in a specific amount of milliseconds,
-	 * notifications are unique by name, and the first one is always kept, as in
-	 * attempting to schedule a notification that already exists won't do anything
-	 *
-	 * @param {String} name - the name of the notification we want to schedule
-	 * @param {Integer} time - how long in milliseconds until the notification should be fired
-	 * @param {Function} cb - gets called when the notification has been scheduled
-	 */
-	async schedule(name, time, cb, station) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!cb) cb = ()=>{};
-
-		time = Math.round(time);
-		this.logger.stationIssue(`SCHEDULE - Time: ${time}; Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}; StationId: ${station._id}; StationName: ${station.name}`);
-		this.pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
-	}
-
-	/**
-	 * Subscribes a callback function to be called when a notification gets called
-	 *
-	 * @param {String} name - the name of the notification we want to subscribe to
-	 * @param {Function} cb - gets called when the subscribed notification gets called
-	 * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
-	 * @return {Object} - the subscription object
-	 */
-	async subscribe(name, cb, unique = false, station) {
-		try { await this._validateHook(); } catch { return; }
-
-		this.logger.stationIssue(`SUBSCRIBE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}, StationId: ${station._id}; StationName: ${station.name}; Unique: ${unique}; SubscriptionExists: ${!!subscriptions.find((subscription) => subscription.originalName == name)};`);
-		if (unique && !!subscriptions.find((subscription) => subscription.originalName == name)) return;
-		let subscription = { originalName: name, name: crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), cb };
-		subscriptions.push(subscription);
-		return subscription;
-	}
-
-	/**
-	 * Remove a notification subscription
-	 *
-	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
-	 */
-	async remove(subscription) {
-		try { await this._validateHook(); } catch { return; }
-
-		let index = subscriptions.indexOf(subscription);
-		if (index) subscriptions.splice(index, 1);
-	}
-
-	async unschedule(name) {
-		try { await this._validateHook(); } catch { return; }
-
-		this.logger.stationIssue(`UNSCHEDULE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}`);
-		this.pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
-	}
+class NotificationsModule extends CoreClass {
+    constructor() {
+        super("notifications");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            const url = (this.url = config.get("redis").url);
+            const password = (this.password = config.get("redis").password);
+
+            this.pub = redis.createClient({
+                url,
+                password,
+                retry_strategy: (options) => {
+                    if (this.getStatus() === "LOCKDOWN") return;
+                    if (this.getStatus() !== "RECONNECTING")
+                        this.setStatus("RECONNECTING");
+
+                    this.log("INFO", `Attempting to reconnect.`);
+
+                    if (options.attempt >= 10) {
+                        this.log("ERROR", `Stopped trying to reconnect.`);
+
+                        this.setStatus("FAILED");
+
+                        // this.failed = true;
+                        // this._lockdown();
+
+                        return undefined;
+                    }
+
+                    return 3000;
+                },
+            });
+            this.sub = redis.createClient({
+                url,
+                password,
+                retry_strategy: (options) => {
+                    if (this.getStatus() === "LOCKDOWN") return;
+                    if (this.getStatus() !== "RECONNECTING")
+                        this.setStatus("RECONNECTING");
+
+                    this.log("INFO", `Attempting to reconnect.`);
+
+                    if (options.attempt >= 10) {
+                        this.log("ERROR", `Stopped trying to reconnect.`);
+
+                        this.setStatus("FAILED");
+
+                        // this.failed = true;
+                        // this._lockdown();
+
+                        return undefined;
+                    }
+
+                    return 3000;
+                },
+            });
+
+            this.sub.on("error", (err) => {
+                if (this.getStatus() === "INITIALIZING") reject(err);
+                if (this.getStatus() === "LOCKDOWN") return;
+
+                this.log("ERROR", `Error ${err.message}.`);
+            });
+
+            this.pub.on("error", (err) => {
+                if (this.getStatus() === "INITIALIZING") reject(err);
+                if (this.getStatus() === "LOCKDOWN") return;
+
+                this.log("ERROR", `Error ${err.message}.`);
+            });
+
+            this.sub.on("connect", () => {
+                this.log("INFO", "Sub connected succesfully.");
+
+                if (this.getStatus() === "INITIALIZING") resolve();
+                else if (
+                    this.getStatus() === "LOCKDOWN" ||
+                    this.getStatus() === "RECONNECTING"
+                )
+                    this.setStatus("READY");
+            });
+
+            this.pub.on("connect", () => {
+                this.log("INFO", "Pub connected succesfully.");
+
+                if (this.getStatus() === "INITIALIZING") resolve();
+                else if (
+                    this.getStatus() === "LOCKDOWN" ||
+                    this.getStatus() === "RECONNECTING"
+                )
+                    this.setStatus("INITIALIZED");
+            });
+
+            this.sub.on("pmessage", (pattern, channel, expiredKey) => {
+                this.log(
+                    "STATION_ISSUE",
+                    `PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`
+                );
+                subscriptions.forEach((sub) => {
+                    this.log(
+                        "STATION_ISSUE",
+                        `PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(
+                            sub.name !== expiredKey
+                        )}`
+                    );
+                    if (sub.name !== expiredKey) return;
+                    sub.cb();
+                });
+            });
+
+            this.sub.psubscribe("__keyevent@0__:expired");
+        });
+    }
+
+    /**
+     * Schedules a notification to be dispatched in a specific amount of milliseconds,
+     * notifications are unique by name, and the first one is always kept, as in
+     * attempting to schedule a notification that already exists won't do anything
+     *
+     * @param {String} name - the name of the notification we want to schedule
+     * @param {Integer} time - how long in milliseconds until the notification should be fired
+     * @param {Function} cb - gets called when the notification has been scheduled
+     */
+    SCHEDULE(payload) {
+        //name, time, cb, station
+        return new Promise((resolve, reject) => {
+            const time = Math.round(payload.time);
+            this.log(
+                "STATION_ISSUE",
+                `SCHEDULE - Time: ${time}; Name: ${payload.name}; Key: ${crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex")}; StationId: ${
+                    payload.station._id
+                }; StationName: ${payload.station.name}`
+            );
+            this.pub.set(
+                crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex"),
+                "",
+                "PX",
+                time,
+                "NX",
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
+
+    /**
+     * Subscribes a callback function to be called when a notification gets called
+     *
+     * @param {String} name - the name of the notification we want to subscribe to
+     * @param {Function} cb - gets called when the subscribed notification gets called
+     * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
+     * @return {Object} - the subscription object
+     */
+    SUBSCRIBE(payload) {
+        //name, cb, unique = false, station
+        return new Promise((resolve, reject) => {
+            this.log(
+                "STATION_ISSUE",
+                `SUBSCRIBE - Name: ${payload.name}; Key: ${crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex")}, StationId: ${
+                    payload.station._id
+                }; StationName: ${payload.station.name}; Unique: ${
+                    payload.unique
+                }; SubscriptionExists: ${!!subscriptions.find(
+                    (subscription) => subscription.originalName === payload.name
+                )};`
+            );
+            if (
+                payload.unique &&
+                !!subscriptions.find(
+                    (subscription) => subscription.originalName === payload.name
+                )
+            )
+                return;
+            let subscription = {
+                originalName: payload.name,
+                name: crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex"),
+                cb: payload.cb,
+            };
+            subscriptions.push(subscription);
+            resolve({ subscription });
+        });
+    }
+
+    /**
+     * Remove a notification subscription
+     *
+     * @param {Object} subscription - the subscription object returned by {@link subscribe}
+     */
+    REMOVE(payload) {
+        //subscription
+        return new Promise((resolve, reject) => {
+            let index = subscriptions.indexOf(payload.subscription);
+            if (index) subscriptions.splice(index, 1);
+            resolve();
+        });
+    }
+
+    UNSCHEDULE(payload) {
+        //name
+        return new Promise((resolve, reject) => {
+            this.log(
+                "STATION_ISSUE",
+                `UNSCHEDULE - Name: ${payload.name}; Key: ${crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex")}`
+            );
+            this.pub.del(
+                crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex")
+            );
+
+            resolve();
+        });
+    }
 }
+
+module.exports = new NotificationsModule();

+ 277 - 166
backend/logic/playlists.js

@@ -1,167 +1,278 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const async = require('async');
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["cache", "db", "utils"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules["cache"];
-			this.db	= this.moduleManager.modules["db"];
-			this.utils	= this.moduleManager.modules["utils"];
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('playlists', next);
-				},
-	
-				(playlists, next) => {
-					this.setStage(3);
-					if (!playlists) return next();
-					let playlistIds = Object.keys(playlists);
-					async.each(playlistIds, (playlistId, next) => {
-						this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-							if (err) next(err);
-							else if (!playlist) {
-								this.cache.hdel('playlists', playlistId, next);
-							}
-							else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.playlist.find({}, next);
-				},
-	
-				(playlists, next) => {
-					this.setStage(5);
-					async.each(playlists, (playlist, next) => {
-						this.cache.hset('playlists', playlist._id, this.cache.schemas.playlist(playlist), next);
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	/**
-	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-	 *
-	 * @param {String} playlistId - the id of the playlist we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPlaylist(playlistId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('playlists', next);
-			},
-
-			(playlists, next) => {
-				if (!playlists) return next();
-				let playlistIds = Object.keys(playlists);
-				async.each(playlistIds, (playlistId, next) => {
-					this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-						if (err) next(err);
-						else if (!playlist) {
-							this.cache.hdel('playlists', playlistId, next);
-						}
-						else next();
-					});
-				}, next);
-			},
-
-			(next) => {
-				this.cache.hget('playlists', playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (playlist) return next(true, playlist);
-				this.db.models.playlist.findOne({ _id: playlistId }, next);
-			},
-
-			(playlist, next) => {
-				if (playlist) {
-					this.cache.hset('playlists', playlistId, playlist, next);
-				} else next('Playlist not found');
-			},
-
-		], (err, playlist) => {
-			if (err && err !== true) return cb(err);
-			else cb(null, playlist);
-		});
-	}
-
-	/**
-	 * Gets a playlist from id from Mongo and updates the cache with it
-	 *
-	 * @param {String} playlistId - the id of the playlist we are trying to update
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
-	 */
-	async updatePlaylist(playlistId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.db.models.playlist.findOne({ _id: playlistId }, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist) {
-					this.cache.hdel('playlists', playlistId);
-					return next('Playlist not found');
-				}
-				this.cache.hset('playlists', playlistId, playlist, next);
-			}
-
-		], (err, playlist) => {
-			if (err && err !== true) return cb(err);
-			cb(null, playlist);
-		});
-	}
-
-	/**
-	 * Deletes playlist from id from Mongo and cache
-	 *
-	 * @param {String} playlistId - the id of the playlist we are trying to delete
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
-	 */
-	async deletePlaylist(playlistId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.playlist.deleteOne({ _id: playlistId }, next);
-			},
-
-			(res, next) => {
-				this.cache.hdel('playlists', playlistId, next);
-			}
-
-		], (err) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null);
-		});
-	}
+const CoreClass = require("../core.js");
+
+const async = require("async");
+
+class ExampleModule extends CoreClass {
+    constructor() {
+        super("playlists");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            const playlistSchema = await this.cache.runJob("GET_SCHEMA", {
+                schemaName: "playlist",
+            });
+
+            this.setStage(2);
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(3);
+                        this.cache
+                            .runJob("HGETALL", { table: "playlists" })
+                            .then((playlists) => next(null, playlists))
+                            .catch(next);
+                    },
+
+                    (playlists, next) => {
+                        this.setStage(4);
+                        if (!playlists) return next();
+                        let playlistIds = Object.keys(playlists);
+                        async.each(
+                            playlistIds,
+                            (playlistId, next) => {
+                                playlistModel.findOne(
+                                    { _id: playlistId },
+                                    (err, playlist) => {
+                                        if (err) next(err);
+                                        else if (!playlist) {
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "playlists",
+                                                    key: playlistId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        } else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(5);
+                        playlistModel.find({}, next);
+                    },
+
+                    (playlists, next) => {
+                        this.setStage(6);
+                        async.each(
+                            playlists,
+                            (playlist, next) => {
+                                this.cache
+                                    .runJob("HSET", {
+                                        table: "playlists",
+                                        key: playlist._id,
+                                        value: playlistSchema(playlist),
+                                    })
+                                    .then(() => {
+                                        next();
+                                    })
+                                    .catch(next);
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+     *
+     * @param {String} playlistId - the id of the playlist we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PLAYLIST(payload) {
+        //playlistId, cb
+        return new Promise(async (resolve, reject) => {
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", { table: "playlists" })
+                            .then((playlists) => next(null, playlists))
+                            .catch(next);
+                    },
+
+                    (playlists, next) => {
+                        if (!playlists) return next();
+                        let playlistIds = Object.keys(playlists);
+                        async.each(
+                            playlistIds,
+                            (playlistId, next) => {
+                                playlistModel.findOne(
+                                    { _id: playlistId },
+                                    (err, playlist) => {
+                                        if (err) next(err);
+                                        else if (!playlist) {
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "playlists",
+                                                    key: playlistId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        } else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.cache
+                            .runJob("HGET", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                            })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (playlist) return next(true, playlist);
+                        playlistModel.findOne(
+                            { _id: payload.playlistId },
+                            next
+                        );
+                    },
+
+                    (playlist, next) => {
+                        if (playlist) {
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "playlists",
+                                    key: payload.playlistId,
+                                    value: playlist,
+                                })
+                                .then((playlist) => next(null, playlist))
+                                .catch(next);
+                        } else next("Playlist not found");
+                    },
+                ],
+                (err, playlist) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve(playlist);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a playlist from id from Mongo and updates the cache with it
+     *
+     * @param {String} playlistId - the id of the playlist we are trying to update
+     * @param {Function} cb - gets called when an error occurred or when the operation was successful
+     */
+    UPDATE_PLAYLIST(payload) {
+        //playlistId, cb
+        return new Promise(async (resolve, reject) => {
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        playlistModel.findOne(
+                            { _id: payload.playlistId },
+                            next
+                        );
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist) {
+                            this.cache.runJob("HDEL", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                            });
+                            return next("Playlist not found");
+                        }
+                        this.cache
+                            .runJob("HSET", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                                value: playlists,
+                            })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                (err, playlist) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve(playlist);
+                }
+            );
+        });
+    }
+
+    /**
+     * Deletes playlist from id from Mongo and cache
+     *
+     * @param {String} playlistId - the id of the playlist we are trying to delete
+     * @param {Function} cb - gets called when an error occurred or when the operation was successful
+     */
+    DELETE_PLAYLIST(payload) {
+        //playlistId, cb
+        return new Promise(async (resolve, reject) => {
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        playlistModel.deleteOne({ _id: playlistId }, next);
+                    },
+
+                    (res, next) => {
+                        this.cache
+                            .runJob("HDEL", {
+                                table: "playlists",
+                                key: playlistId,
+                            })
+                            .then(() => next())
+                            .catch(next);
+                    },
+                ],
+                (err) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new ExampleModule();

+ 313 - 241
backend/logic/punishments.js

@@ -1,243 +1,315 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const async = require('async');
-const mongoose = require('mongoose');
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["cache", "db", "utils"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules['cache'];
-			this.db = this.moduleManager.modules['db'];
-			this.io = this.moduleManager.modules['io'];
-			this.utils = this.moduleManager.modules['utils'];
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('punishments', next);
-				},
-	
-				(punishments, next) => {
-					this.setStage(3);
-					if (!punishments) return next();
-					let punishmentIds = Object.keys(punishments);
-					async.each(punishmentIds, (punishmentId, next) => {
-						this.db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
-							if (err) next(err);
-							else if (!punishment) this.cache.hdel('punishments', punishmentId, next);
-							else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.punishment.find({}, next);
-				},
-	
-				(punishments, next) => {
-					this.setStage(5);
-					async.each(punishments, (punishment, next) => {
-						if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
-						this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), next);
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	/**
-	 * Gets all punishments in the cache that are active, and removes those that have expired
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPunishments(cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let punishmentsToRemove = [];
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('punishments', next);
-			},
-
-			(punishmentsObj, next) => {
-				let punishments = [];
-				for (let id in punishmentsObj) {
-					let obj = punishmentsObj[id];
-					obj.punishmentId = id;
-					punishments.push(obj);
-				}
-				punishments = punishments.filter(punishment => {
-					if (punishment.expiresAt < Date.now()) punishmentsToRemove.push(punishment);
-					return punishment.expiresAt > Date.now();
-				});
-				next(null, punishments);
-			},
-
-			(punishments, next) => {
-				async.each(
-					punishmentsToRemove,
-					(punishment, next2) => {
-						this.cache.hdel('punishments', punishment.punishmentId, () => {
-							next2();
-						});
-					},
-					() => {
-						next(null, punishments);
-					}
-				);
-			}
-		], (err, punishments) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, punishments);
-		});
-	}
-
-	/**
-	 * Gets a punishment by id
-	 *
-	 * @param {String} id - the id of the punishment we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPunishment(id, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				this.cache.hget('punishments', id, next);
-			},
-
-			(punishment, next) => {
-				if (punishment) return next(true, punishment);
-				this.db.models.punishment.findOne({_id: id}, next);
-			},
-
-			(punishment, next) => {
-				if (punishment) {
-					this.cache.hset('punishments', id, punishment, next);
-				} else next('Punishment not found.');
-			},
-
-		], (err, punishment) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, punishment);
-		});
-	}
-
-	/**
-	 * Gets all punishments from a userId
-	 *
-	 * @param {String} userId - the userId of the punishment(s) we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPunishmentsFromUserId(userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.getPunishments(next);
-			},
-			(punishments, next) => {
-				punishments = punishments.filter((punishment) => {
-					return punishment.type === 'banUserId' && punishment.value === userId;
-				});
-				next(null, punishments);
-			}
-		], (err, punishments) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, punishments);
-		});
-	}
-
-	async addPunishment(type, value, reason, expiresAt, punishedBy, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				const punishment = new this.db.models.punishment({
-					type,
-					value,
-					reason,
-					active: true,
-					expiresAt,
-					punishedAt: Date.now(),
-					punishedBy
-				});
-				punishment.save((err, punishment) => {
-					if (err) return next(err);
-					next(null, punishment);
-				});
-			},
-
-			(punishment, next) => {
-				this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), (err) => {
-					next(err, punishment);
-				});
-			},
-
-			(punishment, next) => {
-				// DISCORD MESSAGE
-				next(null, punishment);
-			}
-		], (err, punishment) => {
-			cb(err, punishment);
-		});
-	}
-
-	async removePunishmentFromCache(punishmentId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				const punishment = new this.db.models.punishment({
-					type,
-					value,
-					reason,
-					active: true,
-					expiresAt,
-					punishedAt: Date.now(),
-					punishedBy
-				});
-				punishment.save((err, punishment) => {
-					console.log(err);
-					if (err) return next(err);
-					next(null, punishment);
-				});
-			},
-
-			(punishment, next) => {
-				this.cache.hset('punishments', punishment._id, punishment, next);
-			},
-
-			(punishment, next) => {
-				// DISCORD MESSAGE
-				next();
-			}
-		], (err) => {
-			cb(err);
-		});
-	}
+const CoreClass = require("../core.js");
+
+const async = require("async");
+const mongoose = require("mongoose");
+
+class PunishmentsModule extends CoreClass {
+    constructor() {
+        super("punishments");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.io = this.moduleManager.modules["io"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            const punishmentModel = await this.db.runJob("GET_MODEL", {
+                modelName: "punishment",
+            });
+
+            const punishmentSchema = await this.cache.runJob("GET_SCHEMA", {
+                schemaName: "punishment",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGETALL", { table: "punishments" })
+                            .then((punishments) => next(null, punishments))
+                            .catch(next);
+                    },
+
+                    (punishments, next) => {
+                        this.setStage(3);
+                        if (!punishments) return next();
+                        let punishmentIds = Object.keys(punishments);
+                        async.each(
+                            punishmentIds,
+                            (punishmentId, next) => {
+                                punishmentModel.findOne(
+                                    { _id: punishmentId },
+                                    (err, punishment) => {
+                                        if (err) next(err);
+                                        else if (!punishment)
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "punishments",
+                                                    key: punishmentId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(4);
+                        punishmentModel.find({}, next);
+                    },
+
+                    (punishments, next) => {
+                        this.setStage(5);
+                        async.each(
+                            punishments,
+                            (punishment, next) => {
+                                if (
+                                    punishment.active === false ||
+                                    punishment.expiresAt < Date.now()
+                                )
+                                    return next();
+                                this.cache
+                                    .runJob("HSET", {
+                                        table: "punishments",
+                                        key: punishment._id,
+                                        value: punishmentSchema(
+                                            punishment,
+                                            punishment._id
+                                        ),
+                                    })
+                                    .then(() => next())
+                                    .catch(next);
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets all punishments in the cache that are active, and removes those that have expired
+     *
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PUNISHMENTS() {
+        //cb
+        return new Promise((resolve, reject) => {
+            let punishmentsToRemove = [];
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", { table: "punishments" })
+                            .then((punishmentsObj) =>
+                                next(null, punishmentsObj)
+                            )
+                            .catch(next);
+                    },
+
+                    (punishmentsObj, next) => {
+                        let punishments = [];
+                        for (let id in punishmentsObj) {
+                            let obj = punishmentsObj[id];
+                            obj.punishmentId = id;
+                            punishments.push(obj);
+                        }
+                        punishments = punishments.filter((punishment) => {
+                            if (punishment.expiresAt < Date.now())
+                                punishmentsToRemove.push(punishment);
+                            return punishment.expiresAt > Date.now();
+                        });
+                        next(null, punishments);
+                    },
+
+                    (punishments, next) => {
+                        async.each(
+                            punishmentsToRemove,
+                            (punishment, next2) => {
+                                this.cache
+                                    .runJob("HDEL", {
+                                        table: "punishments",
+                                        key: punishment.punishmentId,
+                                    })
+                                    .finally(() => next2());
+                            },
+                            () => {
+                                next(null, punishments);
+                            }
+                        );
+                    },
+                ],
+                (err, punishments) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(punishments);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a punishment by id
+     *
+     * @param {String} id - the id of the punishment we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PUNISHMENT() {
+        //id, cb
+        return new Promise(async (resolve, reject) => {
+            const punishmentModel = await db.runJob("GET_MODEL", {
+                modelName: "punishment",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!mongoose.Types.ObjectId.isValid(payload.id))
+                            return next("Id is not a valid ObjectId.");
+                        this.cache
+                            .runJob("HGET", {
+                                table: "punishments",
+                                key: payload.id,
+                            })
+                            .then((punishment) => next(null, punishment))
+                            .catch(next);
+                    },
+
+                    (punishment, next) => {
+                        if (punishment) return next(true, punishment);
+                        punishmentModel.findOne({ _id: payload.id }, next);
+                    },
+
+                    (punishment, next) => {
+                        if (punishment) {
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "punishments",
+                                    key: payload.id,
+                                    value: punishment,
+                                })
+                                .then((punishment) => next(null, punishment))
+                                .catch(next);
+                        } else next("Punishment not found.");
+                    },
+                ],
+                (err, punishment) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(punishment);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets all punishments from a userId
+     *
+     * @param {String} userId - the userId of the punishment(s) we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PUNISHMENTS_FROM_USER_ID(payload) {
+        //userId, cb
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.runJob("GET_PUNISHMENTS", {})
+                            .then((punishments) => next(null, punishments))
+                            .catch(next);
+                    },
+                    (punishments, next) => {
+                        punishments = punishments.filter((punishment) => {
+                            return (
+                                punishment.type === "banUserId" &&
+                                punishment.value === payload.userId
+                            );
+                        });
+                        next(null, punishments);
+                    },
+                ],
+                (err, punishments) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(punishments);
+                }
+            );
+        });
+    }
+
+    ADD_PUNISHMENT(payload) {
+        //type, value, reason, expiresAt, punishedBy, cb
+        return new Promise(async (resolve, reject) => {
+            const punishmentModel = await db.runJob("GET_MODEL", {
+                modelName: "punishment",
+            });
+
+            const punishmentSchema = await cache.runJob("GET_SCHEMA", {
+                schemaName: "punishment",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        const punishment = new punishmentModel({
+                            type: payload.type,
+                            value: payload.value,
+                            reason: payload.reason,
+                            active: true,
+                            expiresAt: payload.expiresAt,
+                            punishedAt: Date.now(),
+                            punishedBy: payload.punishedBy,
+                        });
+                        punishment.save((err, punishment) => {
+                            if (err) return next(err);
+                            next(null, punishment);
+                        });
+                    },
+
+                    (punishment, next) => {
+                        this.cache
+                            .runJob("HSET", {
+                                table: "punishments",
+                                key: punishment._id,
+                                value: punishmentSchema(
+                                    punishment,
+                                    punishment._id
+                                ),
+                            })
+                            .then(() => next())
+                            .catch(next);
+                    },
+
+                    (punishment, next) => {
+                        // DISCORD MESSAGE
+                        next(null, punishment);
+                    },
+                ],
+                (err, punishment) => {
+                    if (err) return reject(new Error(err));
+                    resolve(punishment);
+                }
+            );
+        });
+    }
 }
 
+module.exports = new PunishmentsModule();

+ 256 - 172
backend/logic/songs.js

@@ -1,173 +1,257 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const async = require('async');
-const mongoose = require('mongoose');
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["utils", "cache", "db"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules["cache"];
-			this.db = this.moduleManager.modules["db"];
-			this.io = this.moduleManager.modules["io"];
-			this.utils = this.moduleManager.modules["utils"];
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('songs', next);
-				},
-	
-				(songs, next) => {
-					this.setStage(3);
-					if (!songs) return next();
-					let songIds = Object.keys(songs);
-					async.each(songIds, (songId, next) => {
-						this.db.models.song.findOne({songId}, (err, song) => {
-							if (err) next(err);
-							else if (!song) this.cache.hdel('songs', songId, next);
-							else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.song.find({}, next);
-				},
-	
-				(songs, next) => {
-					this.setStage(5);
-					async.each(songs, (song, next) => {
-						this.cache.hset('songs', song.songId, this.cache.schemas.song(song), next);
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	/**
-	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-	 *
-	 * @param {String} id - the id of the song we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getSong(id, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				this.cache.hget('songs', id, next);
-			},
-
-			(song, next) => {
-				if (song) return next(true, song);
-				this.db.models.song.findOne({_id: id}, next);
-			},
-
-			(song, next) => {
-				if (song) {
-					this.cache.hset('songs', id, song, next);
-				} else next('Song not found.');
-			},
-
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, song);
-		});
-	}
-
-	/**
-	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-	 *
-	 * @param {String} songId - the mongo id of the song we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getSongFromId(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.db.models.song.findOne({ songId }, next);
-			}
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
-			else return cb(null, song);
-		});
-	}
-
-	/**
-	 * Gets a song from id from Mongo and updates the cache with it
-	 *
-	 * @param {String} songId - the id of the song we are trying to update
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
-	 */
-	async updateSong(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.song.findOne({_id: songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) {
-					this.cache.hdel('songs', songId);
-					return next('Song not found.');
-				}
-
-				this.cache.hset('songs', songId, song, next);
-			}
-
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, song);
-		});
-	}
-
-	/**
-	 * Deletes song from id from Mongo and cache
-	 *
-	 * @param {String} songId - the id of the song we are trying to delete
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
-	 */
-	async deleteSong(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.song.deleteOne({ songId }, next);
-			},
-
-			(next) => {
-				this.cache.hdel('songs', songId, next);
-			}
-
-		], (err) => {
-			if (err && err !== true) cb(err);
-
-			cb(null);
-		});
-	}
+const CoreClass = require("../core.js");
+
+const async = require("async");
+
+class SongsModule extends CoreClass {
+    constructor() {
+        super("songs");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.io = this.moduleManager.modules["io"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+
+            const songSchema = await this.cache.runJob("GET_SCHEMA", {
+                schemaName: "song",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGETALL", { table: "songs" })
+                            .then((songs) => next(null, songs))
+                            .catch(next);
+                    },
+
+                    (songs, next) => {
+                        this.setStage(3);
+                        if (!songs) return next();
+                        let songIds = Object.keys(songs);
+                        async.each(
+                            songIds,
+                            (songId, next) => {
+                                songModel.findOne({ songId }, (err, song) => {
+                                    if (err) next(err);
+                                    else if (!song)
+                                        this.cache
+                                            .runJob("HDEL", {
+                                                table: "songs",
+                                                key: songId,
+                                            })
+                                            .then(() => next())
+                                            .catch(next);
+                                    else next();
+                                });
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(4);
+                        songModel.find({}, next);
+                    },
+
+                    (songs, next) => {
+                        this.setStage(5);
+                        async.each(
+                            songs,
+                            (song, next) => {
+                                this.cache
+                                    .runJob("HSET", {
+                                        table: "songs",
+                                        key: song.songId,
+                                        value: songSchema.song(song),
+                                    })
+                                    .then(() => next())
+                                    .catch(next);
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+     *
+     * @param {String} id - the id of the song we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_SONG(payload) {
+        //id, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!mongoose.Types.ObjectId.isValid(payload.id))
+                            return next("Id is not a valid ObjectId.");
+                        this.runJob("HGET", { table: "songs", key: payload.id })
+                            .then((song) => next(null, song))
+                            .catch(next);
+                    },
+
+                    (song, next) => {
+                        if (song) return next(true, song);
+                        songModel.findOne({ _id: payload.id }, next);
+                    },
+
+                    (song, next) => {
+                        if (song) {
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "songs",
+                                    key: payload.id,
+                                    value: song,
+                                })
+                                .then((song) => next(null, song));
+                        } else next("Song not found.");
+                    },
+                ],
+                (err, song) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(song);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+     *
+     * @param {String} songId - the mongo id of the song we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_SONG_FROM_ID(payload) {
+        //songId, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        songModel.findOne({ songId: payload.songId }, next);
+                    },
+                ],
+                (err, song) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve(song);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a song from id from Mongo and updates the cache with it
+     *
+     * @param {String} songId - the id of the song we are trying to update
+     * @param {Function} cb - gets called when an error occurred or when the operation was successful
+     */
+    UPDATE_SONG(payload) {
+        //songId, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        songModel.findOne({ _id: payload.songId }, next);
+                    },
+
+                    (song, next) => {
+                        if (!song) {
+                            this.cache.runJob("HDEL", {
+                                table: "songs",
+                                key: payload.songId,
+                            });
+                            return next("Song not found.");
+                        }
+
+                        this.cache
+                            .runJob("HSET", {
+                                table: "songs",
+                                key: payload.songId,
+                                value: song,
+                            })
+                            .then((song) => next(null, song))
+                            .catch(next);
+                    },
+                ],
+                (err, song) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(song);
+                }
+            );
+        });
+    }
+
+    /**
+     * Deletes song from id from Mongo and cache
+     *
+     * @param {String} songId - the id of the song we are trying to delete
+     * @param {Function} cb - gets called when an error occurred or when the operation was successful
+     */
+    DELETE_SONG(payload) {
+        //songId, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        songModel.deleteOne({ songId: payload.songId }, next);
+                    },
+
+                    (next) => {
+                        this.cache
+                            .runJob("HDEL", {
+                                table: "songs",
+                                key: payload.songId,
+                            })
+                            .then(() => next())
+                            .catch(next);
+                    },
+                ],
+                (err) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new SongsModule();

+ 103 - 82
backend/logic/spotify.js

@@ -1,95 +1,116 @@
-const coreClass = require("../core");
+const CoreClass = require("../core.js");
 
-const config = require('config'),
-	async  = require('async');
+const config = require("config"),
+    async = require("async");
 
 let apiResults = {
-	access_token: "",
-	token_type: "",
-	expires_in: 0,
-	expires_at: 0,
-	scope: "",
+    access_token: "",
+    token_type: "",
+    expires_in: 0,
+    expires_at: 0,
+    scope: "",
 };
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
+class SpotifyModule extends CoreClass {
+    constructor() {
+        super("spotify");
+    }
 
-		this.dependsOn = ["cache"];
-	}
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.cache = this.moduleManager.modules["cache"];
+            this.utils = this.moduleManager.modules["utils"];
 
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
+            const client = config.get("apis.spotify.client");
+            const secret = config.get("apis.spotify.secret");
 
-			this.cache = this.moduleManager.modules["cache"];
-			this.utils = this.moduleManager.modules["utils"];
+            const OAuth2 = require("oauth").OAuth2;
+            this.SpotifyOauth = new OAuth2(
+                client,
+                secret,
+                "https://accounts.spotify.com/",
+                null,
+                "api/token",
+                null
+            );
 
-			const client = config.get("apis.spotify.client");
-			const secret = config.get("apis.spotify.secret");
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGET", { table: "api", key: "spotify" })
+                            .then((data) => next(null, data))
+                            .catch(next);
+                    },
 
-			const OAuth2 = require('oauth').OAuth2;
-			this.SpotifyOauth = new OAuth2(
-				client,
-				secret, 
-				'https://accounts.spotify.com/', 
-				null,
-				'api/token',
-				null);
+                    (data, next) => {
+                        this.setStage(3);
+                        if (data) apiResults = data;
+                        next();
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
 
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hget("api", "spotify", next, true);
-				},
-	
-				(data, next) => {
-					this.setStage(3);
-					if (data) apiResults = data;
-					next();
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
+    GET_TOKEN(payload) {
+        return new Promise((resolve, reject) => {
+            if (Date.now() > apiResults.expires_at) {
+                this.runJob("REQUEST_TOKEN").then(() => {
+                    resolve(apiResults.access_token);
+                });
+            } else resolve(apiResults.access_token);
+        });
+    }
 
-	async getToken() {
-		try { await this._validateHook(); } catch { return; }
-
-		return new Promise((resolve, reject) => {
-			if (Date.now() > apiResults.expires_at) {
-				this.requestToken(() => {
-					resolve(apiResults.access_token);
-				});
-			} else resolve(apiResults.access_token);
-		});
-	}
-
-	async requestToken(cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.logger.info("SPOTIFY_REQUEST_TOKEN", "Requesting new Spotify token.");
-				this.SpotifyOauth.getOAuthAccessToken(
-					'',
-					{ 'grant_type': 'client_credentials' },
-					next
-				);
-			},
-			(access_token, refresh_token, results, next) => {
-				apiResults = results;
-				apiResults.expires_at = Date.now() + (results.expires_in * 1000);
-				this.cache.hset("api", "spotify", apiResults, next, true);
-			}
-		], () => {
-			cb();
-		});
-	}
+    REQUEST_TOKEN(payload) {
+        //cb
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.log(
+                            "INFO",
+                            "SPOTIFY_REQUEST_TOKEN",
+                            "Requesting new Spotify token."
+                        );
+                        this.SpotifyOauth.getOAuthAccessToken(
+                            "",
+                            { grant_type: "client_credentials" },
+                            next
+                        );
+                    },
+                    (access_token, refresh_token, results, next) => {
+                        apiResults = results;
+                        apiResults.expires_at =
+                            Date.now() + results.expires_in * 1000;
+                        this.cache
+                            .runJob("HSET", {
+                                table: "api",
+                                key: "spotify",
+                                value: apiResults,
+                                stringifyJson: true,
+                            })
+                            .finally(() => next());
+                    },
+                ],
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new SpotifyModule();

+ 1110 - 537
backend/logic/stations.js

@@ -1,541 +1,1114 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
-
-const async = require('async');
+const async = require("async");
 
 let subscription = null;
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["cache", "db", "utils"];
-	}
-
-	initialize() {
-		return new Promise(async (resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules["cache"];
-			this.db = this.moduleManager.modules["db"];
-			this.utils = this.moduleManager.modules["utils"];
-			this.songs = this.moduleManager.modules["songs"];
-			this.notifications = this.moduleManager.modules["notifications"];
-
-			this.defaultSong = {
-				songId: '60ItHLz5WEA',
-				title: 'Faded - Alan Walker',
-				duration: 212,
-				skipDuration: 0,
-				likes: -1,
-				dislikes: -1
-			};
-
-			//TEMP
-			this.cache.sub('station.pause', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.notifications.remove(`stations.nextSong?id=${stationId}`);
-			});
-
-			this.cache.sub('station.resume', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.initializeStation(stationId)
-			});
-
-			this.cache.sub('station.queueUpdate', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.getStation(stationId, (err, station) => {
-					if (!station.currentSong && station.queue.length > 0) {
-						this.initializeStation(stationId);
-					}
-				});
-			});
-
-			this.cache.sub('station.newOfficialPlaylist', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
-					if (!err && playlistObj) {
-						this.utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
-					}
-				})
-			});
-
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('stations', next);
-				},
-	
-				(stations, next) => {
-					this.setStage(3);
-					if (!stations) return next();
-					let stationIds = Object.keys(stations);
-					async.each(stationIds, (stationId, next) => {
-						this.db.models.station.findOne({_id: stationId}, (err, station) => {
-							if (err) next(err);
-							else if (!station) {
-								this.cache.hdel('stations', stationId, next);
-							} else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.station.find({}, next);
-				},
-	
-				(stations, next) => {
-					this.setStage(5);
-					async.each(stations, (station, next2) => {
-						async.waterfall([
-							(next) => {
-								this.cache.hset('stations', station._id, this.cache.schemas.station(station), next);
-							},
-	
-							(station, next) => {
-								this.initializeStation(station._id, () => {
-									next()
-								}, true);
-							}
-						], (err) => {
-							next2(err);
-						});
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	async initializeStation(stationId, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		if (typeof cb !== 'function') cb = ()=>{};
-
-		async.waterfall([
-			(next) => {
-				this.getStation(stationId, next, true);
-			},
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				this.notifications.unschedule(`stations.nextSong?id=${station._id}`);
-				subscription = this.notifications.subscribe(`stations.nextSong?id=${station._id}`, this.skipStation(station._id), true, station);
-				if (station.paused) return next(true, station);
-				next(null, station);
-			},
-			(station, next) => {
-				if (!station.currentSong) {
-					return this.skipStation(station._id)((err, station) => {
-						if (err) return next(err);
-						return next(true, station);
-					}, true);
-				}
-				let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
-				if (isNaN(timeLeft)) timeLeft = -1;
-				if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
-					this.skipStation(station._id)((err, station) => {
-						next(err, station);
-					}, true);
-				} else {
-					this.notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
-					next(null, station);
-				}
-			}
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	async calculateSongForStation(station, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		let songList = [];
-		async.waterfall([
-			(next) => {
-				if (station.genres.length === 0) return next();
-				let genresDone = [];
-				station.genres.forEach((genre) => {
-					this.db.models.song.find({genres: genre}, (err, songs) => {
-						if (!err) {
-							songs.forEach((song) => {
-								if (songList.indexOf(song._id) === -1) {
-									let found = false;
-									song.genres.forEach((songGenre) => {
-										if (station.blacklistedGenres.indexOf(songGenre) !== -1) found = true;
-									});
-									if (!found) {
-										songList.push(song._id);
-									}
-								}
-							});
-						}
-						genresDone.push(genre);
-						if (genresDone.length === station.genres.length) next();
-					});
-				});
-			},
-
-			(next) => {
-				let playlist = [];
-				songList.forEach(function(songId) {
-					if(station.playlist.indexOf(songId) === -1) playlist.push(songId);
-				});
-				station.playlist.filter((songId) => {
-					if (songList.indexOf(songId) !== -1) playlist.push(songId);
-				});
-
-				this.utils.shuffle(playlist).then((playlist) => {
-					next(null, playlist);
-				});
-			},
-
-			(playlist, next) => {
-				this.calculateOfficialPlaylistList(station._id, playlist, () => {
-					next(null, playlist);
-				}, true);
-			},
-
-			(playlist, next) => {
-				this.db.models.station.updateOne({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
-					this.updateStation(station._id, () => {
-						next(err, playlist);
-					}, true);
-				});
-			}
-
-		], (err, newPlaylist) => {
-			cb(err, newPlaylist);
-		});
-	}
-
-	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	async getStation(stationId, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.cache.hget('stations', stationId, next);
-			},
-
-			(station, next) => {
-				if (station) return next(true, station);
-				this.db.models.station.findOne({ _id: stationId }, next);
-			},
-
-			(station, next) => {
-				if (station) {
-					if (station.type === 'official') {
-						this.calculateOfficialPlaylistList(station._id, station.playlist, () => {});
-					}
-					station = this.cache.schemas.station(station);
-					this.cache.hset('stations', stationId, station);
-					next(true, station);
-				} else next('Station not found');
-			},
-
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	async getStationByName(stationName, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.station.findOne({ name: stationName }, next);
-			},
-
-			(station, next) => {
-				if (station) {
-					if (station.type === 'official') {
-						this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
-					}
-					station = this.cache.schemas.station(station);
-					this.cache.hset('stations', station._id, station);
-					next(true, station);
-				} else next('Station not found');
-			},
-
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	async updateStation(stationId, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.station.findOne({ _id: stationId }, next);
-			},
-
-			(station, next) => {
-				if (!station) {
-					this.cache.hdel('stations', stationId);
-					return next('Station not found');
-				}
-				this.cache.hset('stations', stationId, station, next);
-			}
-
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	async calculateOfficialPlaylistList(stationId, songList, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		let lessInfoPlaylist = [];
-		async.each(songList, (song, next) => {
-			this.songs.getSong(song, (err, song) => {
-				if (!err && song) {
-					let newSong = {
-						songId: song.songId,
-						title: song.title,
-						artists: song.artists,
-						duration: song.duration
-					};
-					lessInfoPlaylist.push(newSong);
-				}
-				next();
-			});
-		}, () => {
-			this.cache.hset("officialPlaylists", stationId, this.cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
-				this.cache.pub("station.newOfficialPlaylist", stationId);
-				cb();
-			});
-		});
-	}
-
-	skipStation(stationId) {
-		this.logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
-		return async (cb, bypassValidate = false) => {
-			if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-			this.logger.stationIssue(`SKIP_STATION_CB - Station ID: ${stationId}.`);
-
-			if (typeof cb !== 'function') cb = ()=>{};
-
-			async.waterfall([
-				(next) => {
-					this.getStation(stationId, next, true);
-				},
-				(station, next) => {
-					if (!station) return next('Station not found.');
-					if (station.type === 'community' && station.partyMode && station.queue.length === 0) return next(null, null, -11, station); // Community station with party mode enabled and no songs in the queue
-					if (station.type === 'community' && station.partyMode && station.queue.length > 0) { // Community station with party mode enabled and songs in the queue
-						if (station.paused) {
-							return next(null, null, -19, station);
-						} else {
-							return this.db.models.station.updateOne({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
-								if (err) return next(err);
-								next(null, station.queue[0], -12, station);
-							});
-						}
-					}
-					if (station.type === 'community' && !station.partyMode) {
-						return this.db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
-							if (err) return next(err);
-							if (!playlist) return next(null, null, -13, station);
-							playlist = playlist.songs;
-							if (playlist.length > 0) {
-								let currentSongIndex;
-								if (station.currentSongIndex < playlist.length - 1) currentSongIndex = station.currentSongIndex + 1;
-								else currentSongIndex = 0;
-								let callback = (err, song) => {
-									if (err) return next(err);
-									if (song) return next(null, song, currentSongIndex, station);
-									else {
-										let song = playlist[currentSongIndex];
-										let currentSong = {
-											songId: song.songId,
-											title: song.title,
-											duration: song.duration,
-											likes: -1,
-											dislikes: -1
-										};
-										return next(null, currentSong, currentSongIndex, station);
-									}
-								};
-								if (playlist[currentSongIndex]._id) this.songs.getSong(playlist[currentSongIndex]._id, callback);
-								else this.songs.getSongFromId(playlist[currentSongIndex].songId, callback);
-							} else return next(null, null, -14, station);
-						});
-					}
-					if (station.type === 'official' && station.playlist.length === 0) {
-						return this.calculateSongForStation(station, (err, playlist) => {
-							if (err) return next(err);
-							if (playlist.length === 0) return next(null, this.defaultSong, 0, station);
-							else {
-								this.songs.getSong(playlist[0], (err, song) => {
-									if (err || !song) return next(null, this.defaultSong, 0, station);
-									return next(null, song, 0, station);
-								});
-							}
-						}, true);
-					}
-					if (station.type === 'official' && station.playlist.length > 0) {
-						async.doUntil((next) => {
-							if (station.currentSongIndex < station.playlist.length - 1) {
-								this.songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
-									if (!err) return next(null, song, station.currentSongIndex + 1);
-									else {
-										station.currentSongIndex++;
-										next(null, null, null);
-									}
-								});
-							} else {
-								this.calculateSongForStation(station, (err, newPlaylist) => {
-									if (err) return next(null, this.defaultSong, 0);
-									this.songs.getSong(newPlaylist[0], (err, song) => {
-										if (err || !song) return next(null, this.defaultSong, 0);
-										station.playlist = newPlaylist;
-										next(null, song, 0);
-									});
-								}, true);
-							}
-						}, (song, currentSongIndex, next) => {
-							if (!!song) return next(null, true, currentSongIndex);
-							else return next(null, false);
-						}, (err, song, currentSongIndex) => {
-							return next(err, song, currentSongIndex, station);
-						});
-					}
-				},
-				(song, currentSongIndex, station, next) => {
-					let $set = {};
-					if (song === null) $set.currentSong = null;
-					else if (song.likes === -1 && song.dislikes === -1) {
-						$set.currentSong = {
-							songId: song.songId,
-							title: song.title,
-							duration: song.duration,
-							skipDuration: 0,
-							likes: -1,
-							dislikes: -1
-						};
-					} else {
-						$set.currentSong = {
-							songId: song.songId,
-							title: song.title,
-							artists: song.artists,
-							duration: song.duration,
-							likes: song.likes,
-							dislikes: song.dislikes,
-							skipDuration: song.skipDuration,
-							thumbnail: song.thumbnail
-						};
-					}
-					if (currentSongIndex >= 0) $set.currentSongIndex = currentSongIndex;
-					$set.startedAt = Date.now();
-					$set.timePaused = 0;
-					if (station.paused) $set.pausedAt = Date.now();
-					next(null, $set, station);
-				},
-
-				($set, station, next) => {
-					this.db.models.station.updateOne({_id: station._id}, {$set}, (err) => {
-						this.updateStation(station._id, (err, station) => {
-							if (station.type === 'community' && station.partyMode === true)
-								this.cache.pub('station.queueUpdate', stationId);
-							next(null, station);
-						}, true);
-					});
-				},
-			], async (err, station) => {
-				if (!err) {
-					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
-						station.currentSong.skipVotes = 0;
-					}
-					//TODO Pub/Sub this
-					this.utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
-						currentSong: station.currentSong,
-						startedAt: station.startedAt,
-						paused: station.paused,
-						timePaused: 0
-					});
-
-					if (station.privacy === 'public') this.utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
-					else {
-						let sockets = await this.utils.getRoomSockets('home');
-						for (let socketId in sockets) {
-							let socket = sockets[socketId];
-							let session = sockets[socketId].session;
-							if (session.sessionId) {
-								this.cache.hget('sessions', session.sessionId, (err, session) => {
-									if (!err && session) {
-										this.db.models.user.findOne({_id: session.userId}, (err, user) => {
-											if (!err && user) {
-												if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
-												else if (station.type === "community" && station.owner === session.userId) socket.emit("event:station.nextSong", station._id, station.currentSong);
-											}
-										});
-									}
-								});
-							}
-						}
-					}
-					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
-						this.utils.socketsJoinSongRoom(await this.utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
-						if (!station.paused) {
-							this.notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
-						}
-					} else {
-						this.utils.socketsLeaveSongRooms(await this.utils.getRoomSockets(`station.${station._id}`));
-					}
-					cb(null, station);
-				} else {
-					err = await this.utils.getError(err);
-					this.logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
-					cb(err);
-				}
-			});
-		}
-	}
-
-	async canUserViewStation(station, userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-		async.waterfall([
-			(next) => {
-				if (station.privacy !== 'private') return next(true);
-				if (!userId) return next("Not allowed");
-				next();
-			},
-			
-			(next) => {
-				this.db.models.user.findOne({_id: userId}, next);
-			},
-			
-			(user, next) => {
-				if (!user) return next("Not allowed");
-				if (user.role === 'admin') return next(true);
-				if (station.type === 'official') return next("Not allowed");
-				if (station.owner === userId) return next(true);
-				next("Not allowed");
-			}
-		], async (errOrResult) => {
-			if (errOrResult === true || errOrResult === "Not allowed") return cb(null, (errOrResult === true) ? true : false);
-			cb(await this.utils.getError(errOrResult));
-		});
-	}
-}
+class StationsModule extends CoreClass {
+    constructor() {
+        super("stations");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.utils = this.moduleManager.modules["utils"];
+            this.songs = this.moduleManager.modules["songs"];
+            this.notifications = this.moduleManager.modules["notifications"];
+
+            this.defaultSong = {
+                songId: "60ItHLz5WEA",
+                title: "Faded - Alan Walker",
+                duration: 212,
+                skipDuration: 0,
+                likes: -1,
+                dislikes: -1,
+            };
+
+            //TEMP
+            this.cache.runJob("SUB", {
+                channel: "station.pause",
+                cb: async (stationId) => {
+                    this.notifications
+                        .runJob("REMOVE", {
+                            subscription: `stations.nextSong?id=${stationId}`,
+                        })
+                        .then();
+                },
+            });
+
+            this.cache.runJob("SUB", {
+                channel: "station.resume",
+                cb: async (stationId) => {
+                    this.runJob("INITIALIZE_STATION", { stationId }).then();
+                },
+            });
+
+            this.cache.runJob("SUB", {
+                channel: "station.queueUpdate",
+                cb: async (stationId) => {
+                    this.runJob("GET_STATION", { stationId }).then(
+                        (station) => {
+                            if (
+                                !station.currentSong &&
+                                station.queue.length > 0
+                            ) {
+                                this.runJob("INITIALIZE_STATION", {
+                                    stationId,
+                                }).then();
+                            }
+                        }
+                    );
+                },
+            });
+
+            this.cache.runJob("SUB", {
+                channel: "station.newOfficialPlaylist",
+                cb: async (stationId) => {
+                    this.cache
+                        .runJob("HGET", {
+                            table: "officialPlaylists",
+                            key: stationId,
+                        })
+                        .then((playlistObj) => {
+                            if (playlistObj) {
+                                this.utils.emitToRoom(
+                                    `station.${stationId}`,
+                                    "event:newOfficialPlaylist",
+                                    playlistObj.songs
+                                );
+                            }
+                        });
+                },
+            });
+
+            const stationModel = (this.stationModel = await this.db.runJob(
+                "GET_MODEL",
+                {
+                    modelName: "station",
+                }
+            ));
+
+            const stationSchema = (this.stationSchema = await this.cache.runJob(
+                "GET_SCHEMA",
+                {
+                    schemaName: "station",
+                }
+            ));
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGETALL", { table: "stations" })
+                            .then((stations) => next(null, stations))
+                            .catch(next);
+                    },
+
+                    (stations, next) => {
+                        this.setStage(3);
+                        if (!stations) return next();
+                        let stationIds = Object.keys(stations);
+                        async.each(
+                            stationIds,
+                            (stationId, next) => {
+                                stationModel.findOne(
+                                    { _id: stationId },
+                                    (err, station) => {
+                                        if (err) next(err);
+                                        else if (!station) {
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "stations",
+                                                    key: stationId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        } else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(4);
+                        stationModel.find({}, next);
+                    },
+
+                    (stations, next) => {
+                        this.setStage(5);
+                        async.each(
+                            [stations[0]],
+                            (station, next2) => {
+                                async.waterfall(
+                                    [
+                                        (next) => {
+                                            this.cache
+                                                .runJob("HSET", {
+                                                    table: "stations",
+                                                    key: station._id,
+                                                    value: stationSchema(
+                                                        station
+                                                    ),
+                                                })
+                                                .then((station) =>
+                                                    next(null, station)
+                                                )
+                                                .catch(next);
+                                        },
+
+                                        (station, next) => {
+                                            this.runJob(
+                                                "INITIALIZE_STATION",
+                                                {
+                                                    stationId: station._id,
+                                                    bypassQueue: true,
+                                                },
+                                                { bypassQueue: true }
+                                            )
+                                                .then(() => next())
+                                                .catch(next); // bypassQueue is true because otherwise the module will never initialize
+                                        },
+                                    ],
+                                    (err) => {
+                                        next2(err);
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    INITIALIZE_STATION(payload) {
+        //stationId, cb, bypassValidate = false
+        return new Promise((resolve, reject) => {
+            // if (typeof cb !== 'function') cb = ()=>{};
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.runJob(
+                            "GET_STATION",
+                            { stationId: payload.stationId },
+                            { bypassQueue: payload.bypassQueue }
+                        )
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+                        this.notifications
+                            .runJob("UNSCHEDULE", {
+                                subscription: `stations.nextSong?id=${station._id}`,
+                            })
+                            .then()
+                            .catch();
+                        this.notifications
+                            .runJob("SUBSCRIBE", {
+                                subscription: `stations.nextSong?id=${station._id}`,
+                                cb: () =>
+                                    this.runJob("SKIP_STATION", {
+                                        stationId: station._id,
+                                    }),
+                                unique: true,
+                                station,
+                            })
+                            .then()
+                            .catch();
+                        if (station.paused) return next(true, station);
+                        next(null, station);
+                    },
+                    (station, next) => {
+                        if (!station.currentSong) {
+                            return this.runJob(
+                                "SKIP_STATION",
+                                {
+                                    stationId: station._id,
+                                    bypassQueue: payload.bypassQueue,
+                                },
+                                { bypassQueue: payload.bypassQueue }
+                            )
+                                .then((station) => next(true, station))
+                                .catch(next)
+                                .finally(() => {});
+                        }
+                        let timeLeft =
+                            station.currentSong.duration * 1000 -
+                            (Date.now() -
+                                station.startedAt -
+                                station.timePaused);
+                        if (isNaN(timeLeft)) timeLeft = -1;
+                        if (
+                            station.currentSong.duration * 1000 < timeLeft ||
+                            timeLeft < 0
+                        ) {
+                            this.runJob(
+                                "SKIP_STATION",
+                                { stationId: station._id },
+                                { bypassQueue: payload.bypassQueue }
+                            )
+                                .then((station) => next(null, station))
+                                .catch(next);
+                        } else {
+                            this.notifications.schedule(
+                                `stations.nextSong?id=${station._id}`,
+                                timeLeft,
+                                null,
+                                station
+                            );
+                            next(null, station);
+                        }
+                    },
+                ],
+                async (err, station) => {
+                    if (err && err !== true) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else resolve(station);
+                }
+            );
+        });
+    }
+
+    CALCULATE_SONG_FOR_STATION(payload) {
+        //station, cb, bypassValidate = false
+        return new Promise(async (resolve, reject) => {
+            const songModel = await db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+
+            let songList = [];
+            async.waterfall(
+                [
+                    (next) => {
+                        if (payload.station.genres.length === 0) return next();
+                        let genresDone = [];
+                        payload.station.genres.forEach((genre) => {
+                            songModel.find({ genres: genre }, (err, songs) => {
+                                if (!err) {
+                                    songs.forEach((song) => {
+                                        if (songList.indexOf(song._id) === -1) {
+                                            let found = false;
+                                            song.genres.forEach((songGenre) => {
+                                                if (
+                                                    payload.station.blacklistedGenres.indexOf(
+                                                        songGenre
+                                                    ) !== -1
+                                                )
+                                                    found = true;
+                                            });
+                                            if (!found) {
+                                                songList.push(song._id);
+                                            }
+                                        }
+                                    });
+                                }
+                                genresDone.push(genre);
+                                if (
+                                    genresDone.length ===
+                                    payload.station.genres.length
+                                )
+                                    next();
+                            });
+                        });
+                    },
+
+                    (next) => {
+                        let playlist = [];
+                        songList.forEach(function(songId) {
+                            if (payload.station.playlist.indexOf(songId) === -1)
+                                playlist.push(songId);
+                        });
+                        payload.station.playlist.filter((songId) => {
+                            if (songList.indexOf(songId) !== -1)
+                                playlist.push(songId);
+                        });
+
+                        this.utils
+                            .runJob("SHUFFLE", { array: playlist })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                        this.utils.shuffle(playlist).then((playlist) => {
+                            next(null, playlist);
+                        });
+                    },
+
+                    (playlist, next) => {
+                        this.runJob("CALCULATE_OFFICIAL_PLAYLIST_LIST", {
+                            stationId,
+                            songList: playlist,
+                        })
+                            .then(() => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        stationModel.updateOne(
+                            { _id: station._id },
+                            { $set: { playlist: playlist } },
+                            { runValidators: true },
+                            (err) => {
+                                this.runJob("UPDATE_STATION", {
+                                    stationId: station._id,
+                                })
+                                    .then(() => next(null, playlist))
+                                    .catch(next);
+                            }
+                        );
+                    },
+                ],
+                (err, newPlaylist) => {
+                    if (err) return reject(new Error(err));
+                    resolve(newPlaylist);
+                }
+            );
+        });
+    }
+
+    // Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+    GET_STATION(payload) {
+        //stationId, cb, bypassValidate = false
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGET", {
+                                table: "stations",
+                                key: payload.stationId,
+                            })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+
+                    (station, next) => {
+                        if (station) return next(true, station);
+                        this.stationModel.findOne({ _id: stationId }, next);
+                    },
+
+                    (station, next) => {
+                        if (station) {
+                            if (station.type === "official") {
+                                this.runJob(
+                                    "CALCULATE_OFFICIAL_PLAYLIST_LIST",
+                                    {
+                                        stationId: station._id,
+                                        songList: station.playlist,
+                                    }
+                                )
+                                    .then()
+                                    .catch();
+                            }
+                            station = this.stationSchema(station);
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "stations",
+                                    key: "stationId",
+                                    value: station,
+                                })
+                                .then()
+                                .catch();
+                            next(true, station);
+                        } else next("Station not found");
+                    },
+                ],
+                async (err, station) => {
+                    if (err && err !== true) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else resolve(station);
+                }
+            );
+        });
+    }
+
+    // Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+    GET_STATION_BY_NAME(payload) {
+        //stationName, cb
+        return new Promise(async (resolve, reject) => {
+            const stationModel = await this.db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.findOne(
+                            { name: payload.stationName },
+                            next
+                        );
+                    },
+
+                    (station, next) => {
+                        if (station) {
+                            if (station.type === "official") {
+                                this.runJob(
+                                    "CALCULATE_OFFICIAL_PLAYLIST_LIST",
+                                    { stationId, songList: playlist }
+                                );
+                            }
+                            this.cache
+                                .runJob("GET_SCHEMA", { schemaName: "station" })
+                                .then((stationSchema) => {
+                                    station = stationSchema(station);
+                                    this.cache.runJob("HSET", {
+                                        table: "stations",
+                                        key: station._id,
+                                        value: station,
+                                    });
+                                    next(true, station);
+                                });
+                        } else next("Station not found");
+                    },
+                ],
+                (err, station) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve(station);
+                }
+            );
+        });
+    }
+
+    UPDATE_STATION(payload) {
+        //stationId, cb, bypassValidate = false
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.stationModel.findOne(
+                            { _id: payload.stationId },
+                            next
+                        );
+                    },
+
+                    (station, next) => {
+                        if (!station) {
+                            this.cache
+                                .runJob("HDEL", {
+                                    table: "stations",
+                                    key: payload.stationId,
+                                })
+                                .then()
+                                .catch();
+                            return next("Station not found");
+                        }
+                        this.cache
+                            .runJob("HSET", {
+                                table: "stations",
+                                key: payload.stationId,
+                                value: station,
+                            })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err, station) => {
+                    if (err && err !== true) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else resolve(station);
+                }
+            );
+        });
+    }
+
+    CALCULATE_OFFICIAL_PLAYLIST_LIST(payload) {
+        //stationId, songList, cb, bypassValidate = false
+        return new Promise(async (resolve, reject) => {
+            const officialPlaylistSchema = await this.cache.runJob(
+                "GET_SCHEMA",
+                {
+                    schemaName: "officialPlaylist",
+                }
+            );
+
+            let lessInfoPlaylist = [];
+            async.each(
+                payload.songList,
+                (song, next) => {
+                    this.songs
+                        .runJob("GET_SONG", { song })
+                        .then((song) => {
+                            if (song) {
+                                let newSong = {
+                                    songId: song.songId,
+                                    title: song.title,
+                                    artists: song.artists,
+                                    duration: song.duration,
+                                };
+                                lessInfoPlaylist.push(newSong);
+                            }
+                        })
+                        .finally(() => {
+                            next();
+                        });
+                },
+                () => {
+                    this.cache
+                        .runJob("HSET", {
+                            table: "officialPlaylists",
+                            key: payload.stationId,
+                            value: officialPlaylistSchema(
+                                payload.stationId,
+                                lessInfoPlaylist
+                            ),
+                        })
+                        .finally(() => {
+                            this.cache.runJob("PUB", {
+                                channel: "station.newOfficialPlaylist",
+                                value: payload.stationId,
+                            });
+                            resolve();
+                        });
+                }
+            );
+        });
+    }
+
+    SKIP_STATION(payload) {
+        //stationId
+        return new Promise((resolve, reject) => {
+            this.log("INFO", `Skipping station ${payload.stationId}.`);
+
+            this.log(
+                "STATION_ISSUE",
+                `SKIP_STATION_CB - Station ID: ${payload.stationId}.`
+            );
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.runJob(
+                            "GET_STATION",
+                            {
+                                stationId: payload.stationId,
+                            },
+                            { bypassQueue: payload.bypassQueue }
+                        )
+                            .then((station) => {
+                                next(null, station);
+                            })
+                            .catch(() => {});
+                    },
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+                        if (
+                            station.type === "community" &&
+                            station.partyMode &&
+                            station.queue.length === 0
+                        )
+                            return next(null, null, -11, station); // Community station with party mode enabled and no songs in the queue
+                        if (
+                            station.type === "community" &&
+                            station.partyMode &&
+                            station.queue.length > 0
+                        ) {
+                            // Community station with party mode enabled and songs in the queue
+                            if (station.paused) {
+                                return next(null, null, -19, station);
+                            } else {
+                                return this.stationModel.updateOne(
+                                    { _id: stationId },
+                                    {
+                                        $pull: {
+                                            queue: {
+                                                _id: station.queue[0]._id,
+                                            },
+                                        },
+                                    },
+                                    (err) => {
+                                        if (err) return next(err);
+                                        next(
+                                            null,
+                                            station.queue[0],
+                                            -12,
+                                            station
+                                        );
+                                    }
+                                );
+                            }
+                        }
+                        if (
+                            station.type === "community" &&
+                            !station.partyMode
+                        ) {
+                            this.db
+                                .runJob("GET_MODEL", { modelName: "playlist" })
+                                .then((playlistModel) => {
+                                    return playlistModel.findOne(
+                                        { _id: station.privatePlaylist },
+                                        (err, playlist) => {
+                                            if (err) return next(err);
+                                            if (!playlist)
+                                                return next(
+                                                    null,
+                                                    null,
+                                                    -13,
+                                                    station
+                                                );
+                                            playlist = playlist.songs;
+                                            if (playlist.length > 0) {
+                                                let currentSongIndex;
+                                                if (
+                                                    station.currentSongIndex <
+                                                    playlist.length - 1
+                                                )
+                                                    currentSongIndex =
+                                                        station.currentSongIndex +
+                                                        1;
+                                                else currentSongIndex = 0;
+                                                let callback = (err, song) => {
+                                                    if (err) return next(err);
+                                                    if (song)
+                                                        return next(
+                                                            null,
+                                                            song,
+                                                            currentSongIndex,
+                                                            station
+                                                        );
+                                                    else {
+                                                        let song =
+                                                            playlist[
+                                                                currentSongIndex
+                                                            ];
+                                                        let currentSong = {
+                                                            songId: song.songId,
+                                                            title: song.title,
+                                                            duration:
+                                                                song.duration,
+                                                            likes: -1,
+                                                            dislikes: -1,
+                                                        };
+                                                        return next(
+                                                            null,
+                                                            currentSong,
+                                                            currentSongIndex,
+                                                            station
+                                                        );
+                                                    }
+                                                };
+                                                if (
+                                                    playlist[currentSongIndex]
+                                                        ._id
+                                                )
+                                                    this.songs
+                                                        .runJob("GET_SONG", {
+                                                            songId:
+                                                                playlist[
+                                                                    currentSongIndex
+                                                                ]._id,
+                                                        })
+                                                        .then((song) =>
+                                                            callback(null, song)
+                                                        )
+                                                        .catch(callback);
+                                                else
+                                                    this.songs
+                                                        .runJob(
+                                                            "GET_SONG_FROM_ID",
+                                                            {
+                                                                songId:
+                                                                    playlist[
+                                                                        currentSongIndex
+                                                                    ].songId,
+                                                            }
+                                                        )
+                                                        .then((song) =>
+                                                            callback(null, song)
+                                                        )
+                                                        .catch(callback);
+                                            } else
+                                                return next(
+                                                    null,
+                                                    null,
+                                                    -14,
+                                                    station
+                                                );
+                                        }
+                                    );
+                                });
+                        }
+                        if (
+                            station.type === "official" &&
+                            station.playlist.length === 0
+                        ) {
+                            return this.runJob(
+                                "CALCULATE_SONG_FOR_STATION",
+                                { station, bypassQueue: payload.bypassQueue },
+                                { bypassQueue: payload.bypassQueue }
+                            )
+                                .then((playlist) => {
+                                    if (playlist.length === 0)
+                                        return next(
+                                            null,
+                                            this.defaultSong,
+                                            0,
+                                            station
+                                        );
+                                    else {
+                                        this.songs
+                                            .runJob("GET_SONG", {
+                                                songId: playlist[0],
+                                            })
+                                            .then((song) => {
+                                                next(null, song, 0, station);
+                                            })
+                                            .catch((err) => {
+                                                return next(
+                                                    null,
+                                                    this.defaultSong,
+                                                    0,
+                                                    station
+                                                );
+                                            });
+                                    }
+                                })
+                                .catch(next);
+                        }
+                        if (
+                            station.type === "official" &&
+                            station.playlist.length > 0
+                        ) {
+                            async.doUntil(
+                                (next) => {
+                                    if (
+                                        station.currentSongIndex <
+                                        station.playlist.length - 1
+                                    ) {
+                                        this.songs
+                                            .runJob("GET_SONG", {
+                                                songId:
+                                                    station.playlist[
+                                                        station.currentSongIndex +
+                                                            1
+                                                    ],
+                                            })
+                                            .then((song) => {
+                                                return next(
+                                                    null,
+                                                    song,
+                                                    station.currentSongIndex + 1
+                                                );
+                                            })
+                                            .catch((err) => {
+                                                station.currentSongIndex++;
+                                                next(null, null, null);
+                                            });
+                                    } else {
+                                        this.runJob(
+                                            "CALCULATE_SONG_FOR_STATION",
+                                            {
+                                                station,
+                                                bypassQueue:
+                                                    payload.bypassQueue,
+                                            },
+                                            { bypassQueue: payload.bypassQueue }
+                                        )
+                                            .then((newPlaylist) => {
+                                                this.songs.getSong(
+                                                    newPlaylist[0],
+                                                    (err, song) => {
+                                                        if (err || !song)
+                                                            return next(
+                                                                null,
+                                                                this
+                                                                    .defaultSong,
+                                                                0
+                                                            );
+                                                        station.playlist = newPlaylist;
+                                                        next(null, song, 0);
+                                                    }
+                                                );
+                                            })
+                                            .catch((err) => {
+                                                next(null, this.defaultSong, 0);
+                                            });
+                                    }
+                                },
+                                (song, currentSongIndex, next) => {
+                                    if (!!song)
+                                        return next(
+                                            null,
+                                            true,
+                                            currentSongIndex
+                                        );
+                                    else return next(null, false);
+                                },
+                                (err, song, currentSongIndex) => {
+                                    return next(
+                                        err,
+                                        song,
+                                        currentSongIndex,
+                                        station
+                                    );
+                                }
+                            );
+                        }
+                    },
+                    (song, currentSongIndex, station, next) => {
+                        let $set = {};
+                        if (song === null) $set.currentSong = null;
+                        else if (song.likes === -1 && song.dislikes === -1) {
+                            $set.currentSong = {
+                                songId: song.songId,
+                                title: song.title,
+                                duration: song.duration,
+                                skipDuration: 0,
+                                likes: -1,
+                                dislikes: -1,
+                            };
+                        } else {
+                            $set.currentSong = {
+                                songId: song.songId,
+                                title: song.title,
+                                artists: song.artists,
+                                duration: song.duration,
+                                likes: song.likes,
+                                dislikes: song.dislikes,
+                                skipDuration: song.skipDuration,
+                                thumbnail: song.thumbnail,
+                            };
+                        }
+                        if (currentSongIndex >= 0)
+                            $set.currentSongIndex = currentSongIndex;
+                        $set.startedAt = Date.now();
+                        $set.timePaused = 0;
+                        if (station.paused) $set.pausedAt = Date.now();
+                        next(null, $set, station);
+                    },
+
+                    ($set, station, next) => {
+                        this.stationModel.updateOne(
+                            { _id: station._id },
+                            { $set },
+                            (err) => {
+                                this.runJob(
+                                    "UPDATE_STATION",
+                                    {
+                                        stationId: station._id,
+                                        bypassQueue: payload.bypassQueue,
+                                    },
+                                    { bypassQueue: payload.bypassQueue }
+                                )
+                                    .then((station) => {
+                                        if (
+                                            station.type === "community" &&
+                                            station.partyMode === true
+                                        )
+                                            this.cache
+                                                .runJob("PUB", {
+                                                    channel:
+                                                        "station.queueUpdate",
+                                                    value: payload.stationId,
+                                                })
+                                                .then()
+                                                .catch();
+                                        next(null, station);
+                                    })
+                                    .catch(next);
+                            }
+                        );
+                    },
+                ],
+                async (err, station) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        this.log(
+                            "ERROR",
+                            `Skipping station "${payload.stationId}" failed. "${err}"`
+                        );
+                        reject(new Error(err));
+                    } else {
+                        if (
+                            station.currentSong !== null &&
+                            station.currentSong.songId !== undefined
+                        ) {
+                            station.currentSong.skipVotes = 0;
+                        }
+                        //TODO Pub/Sub this
+
+                        this.utils
+                            .runJob("EMIT_TO_ROOM", {
+                                room: `station.${station._id}`,
+                                args: [
+                                    "event:songs.next",
+                                    {
+                                        currentSong: station.currentSong,
+                                        startedAt: station.startedAt,
+                                        paused: station.paused,
+                                        timePaused: 0,
+                                    },
+                                ],
+                            })
+                            .then()
+                            .catch();
+
+                        if (station.privacy === "public") {
+                            this.utils
+                                .runJob("EMIT_TO_ROOM", {
+                                    room: "home",
+                                    args: [
+                                        "event:station.nextSong",
+                                        station._id,
+                                        station.currentSong,
+                                    ],
+                                })
+                                .then()
+                                .catch();
+                        } else {
+                            let sockets = await this.utils.getRoomSockets(
+                                "home"
+                            );
+                            for (let socketId in sockets) {
+                                let socket = sockets[socketId];
+                                let session = sockets[socketId].session;
+                                if (session.sessionId) {
+                                    this.cache.hget(
+                                        "sessions",
+                                        session.sessionId,
+                                        (err, session) => {
+                                            if (!err && session) {
+                                                this.db.models.user.findOne(
+                                                    { _id: session.userId },
+                                                    (err, user) => {
+                                                        if (!err && user) {
+                                                            if (
+                                                                user.role ===
+                                                                "admin"
+                                                            )
+                                                                socket.emit(
+                                                                    "event:station.nextSong",
+                                                                    station._id,
+                                                                    station.currentSong
+                                                                );
+                                                            else if (
+                                                                station.type ===
+                                                                    "community" &&
+                                                                station.owner ===
+                                                                    session.userId
+                                                            )
+                                                                socket.emit(
+                                                                    "event:station.nextSong",
+                                                                    station._id,
+                                                                    station.currentSong
+                                                                );
+                                                        }
+                                                    }
+                                                );
+                                            }
+                                        }
+                                    );
+                                }
+                            }
+                        }
+
+                        if (
+                            station.currentSong !== null &&
+                            station.currentSong.songId !== undefined
+                        ) {
+                            this.utils.socketsJoinSongRoom(
+                                await this.utils.getRoomSockets(
+                                    `station.${station._id}`
+                                ),
+                                `song.${station.currentSong.songId}`
+                            );
+                            if (!station.paused) {
+                                this.notifications.schedule(
+                                    `stations.nextSong?id=${station._id}`,
+                                    station.currentSong.duration * 1000,
+                                    null,
+                                    station
+                                );
+                            }
+                        } else {
+                            this.utils
+                                .runJob("SOCKETS_LEAVE_SONG_ROOMS", {
+                                    sockets: await this.utils.runJob(
+                                        "GET_ROOM_SOCKETS",
+                                        { room: `station.${station._id}` }
+                                    ),
+                                })
+                                .then()
+                                .catch();
+                        }
+
+                        resolve({ station: station });
+                    }
+                }
+            );
+        });
+    }
+
+    CAN_USER_VIEW_STATION(payload) {
+        // station, userId, cb
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        if (payload.station.privacy !== "private")
+                            return next(true);
+                        if (!payload.userId) return next("Not allowed");
+                        next();
+                    },
+
+                    async (next) => {
+                        const userModel = await this.db.runJob("GET_MODEL", {
+                            modelName: "user",
+                        });
+                        userModel.findOne({ _id: userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("Not allowed");
+                        if (user.role === "admin") return next(true);
+                        if (payload.station.type === "official")
+                            return next("Not allowed");
+                        if (payload.station.owner === payload.userId)
+                            return next(true);
+                        next("Not allowed");
+                    },
+                ],
+                async (errOrResult) => {
+                    if (errOrResult !== true && err !== "Not allowed") {
+                        errOrResult = await this.utils.runJob("GET_ERROR", {
+                            error: errOrResult,
+                        });
+                        reject(new Error(errOrResult));
+                    } else {
+                        resolve(errOrResult === true ? true : false);
+                    }
+                }
+            );
+        });
+    }
+}
+
+module.exports = new StationsModule();

+ 318 - 167
backend/logic/tasks.js

@@ -1,173 +1,324 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
+const tasks = {};
 
 const async = require("async");
 const fs = require("fs");
 
-let tasks = {};
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-			
-			this.cache = this.moduleManager.modules["cache"];
-			this.stations = this.moduleManager.modules["stations"];
-			this.notifications = this.moduleManager.modules["notifications"];
-			this.utils = this.moduleManager.modules["utils"];
-
-			//this.createTask("testTask", testTask, 5000, true);
-			this.createTask("stationSkipTask", this.checkStationSkipTask, 1000 * 60 * 30);
-			this.createTask("sessionClearTask", this.sessionClearingTask, 1000 * 60 * 60 * 6);
-			this.createTask("logFileSizeCheckTask", this.logFileSizeCheckTask, 1000 * 60 * 60);
-
-			resolve();
-		});
-	}
-
-	async createTask(name, fn, timeout, paused = false) {
-		try { await this._validateHook(); } catch { return; }
-
-		tasks[name] = {
-			name,
-			fn,
-			timeout,
-			lastRan: 0,
-			timer: null
-		};
-		if (!paused) this.handleTask(tasks[name]);
-	}
-
-	async pauseTask(name) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (tasks[name].timer) tasks[name].timer.pause();
-	}
-
-	async resumeTask(name) {
-		try { await this._validateHook(); } catch { return; }
-
-		tasks[name].timer.resume();
-	}
-
-	async handleTask(task) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (task.timer) task.timer.pause();
-		
-		task.fn.apply(this, [
-			() => {
-				task.lastRan = Date.now();
-				task.timer = new this.utils.Timer(() => {
-					this.handleTask(task);
-				}, task.timeout, false);
-			}
-		]);
-	}
-
-	/*testTask(callback) {
-		//Stuff
-		console.log("Starting task");
-		setTimeout(() => {
-			console.log("Callback");
-			callback();
-		}, 10000);
-	}*/
-
-	async checkStationSkipTask(callback) {
-		this.logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('stations', next);
-			},
-			(stations, next) => {
-				async.each(stations, (station, next2) => {
-					if (station.paused || !station.currentSong || !station.currentSong.title) return next2();
-					const timeElapsed = Date.now() - station.startedAt - station.timePaused;
-					if (timeElapsed <= station.currentSong.duration) return next2();
-					else {
-						this.logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
-						this.stations.initializeStation(station._id);
-						next2();
-					}
-				}, () => {
-					next();
-				});
-			}
-		], () => {
-			callback();
-		});
-	}
-
-	async sessionClearingTask(callback) {
-		this.logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('sessions', next);
-			},
-			(sessions, next) => {
-				if (!sessions) return next();
-				let keys = Object.keys(sessions);
-				async.each(keys, (sessionId, next2) => {
-					let session = sessions[sessionId];
-					if (session && session.refreshDate && (Date.now() - session.refreshDate) < (60 * 60 * 24 * 30 * 1000)) return next2();
-					if (!session) {
-						this.logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
-						this.cache.hdel('sessions', sessionId, () => {
-							next2();
-						});
-					} else if (!session.refreshDate) {
-						session.refreshDate = Date.now();
-						this.cache.hset('sessions', sessionId, session, () => {
-							next2();
-						});
-					} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
-						this.utils.socketsFromSessionId(session.sessionId, (sockets) => {
-							if (sockets.length > 0) {
-								session.refreshDate = Date.now();
-								this.cache.hset('sessions', sessionId, session, () => {
-									next2()
-								});
-							} else {
-								this.logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
-								this.cache.hdel('sessions', session.sessionId, () => {
-									next2();
-								});
-							}
-						});
-					} else {
-						this.logger.error("TASK_SESSION_CLEAR", "This should never log.");
-						next2();
-					}
-				}, () => {
-					next();
-				});
-			}
-		], () => {
-			callback();
-		});
-	}
-
-	async logFileSizeCheckTask(callback) {
-		this.logger.info("TASK_LOG_FILE_SIZE_CHECK", `Checking the size for the log files.`);
-		async.each(
-			["all.log", "debugStation.log", "error.log", "info.log", "success.log"],
-			(fileName, next) => {
-				const stats = fs.statSync(`${__dirname}/../../log/${fileName}`);
-				const mb = stats.size / 1000000;
-				if (mb > 25) return next(true);
-				else next();
-			},
-			(err) => {
-				if (err === true) {
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "************************************WARNING*************************************");
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************");
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****");
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "********************************************************************************");
-				}
-				callback();
-			}
-		);
-	}
+const Timer = require("../classes/Timer.class");
+
+class TasksModule extends CoreClass {
+    constructor() {
+        super("tasks");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            // return reject(new Error("Not fully migrated yet."));
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.stations = this.moduleManager.modules["stations"];
+            this.notifications = this.moduleManager.modules["notifications"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            //this.createTask("testTask", testTask, 5000, true);
+
+            this.runJob("CREATE_TASK", {
+                name: "stationSkipTask",
+                fn: this.checkStationSkipTask,
+                timeout: 1000 * 60 * 30,
+            });
+
+            this.runJob("CREATE_TASK", {
+                name: "sessionClearTask",
+                fn: this.sessionClearingTask,
+                timeout: 1000 * 60 * 60 * 6,
+            });
+
+            this.runJob("CREATE_TASK", {
+                name: "logFileSizeCheckTask",
+                fn: this.logFileSizeCheckTask,
+                timeout: 1000 * 60 * 60,
+            });
+
+            resolve();
+        });
+    }
+
+    CREATE_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            tasks[payload.name] = {
+                name: payload.name,
+                fn: payload.fn,
+                timeout: payload.timeout,
+                lastRan: 0,
+                timer: null,
+            };
+
+            if (!payload.paused) {
+                this.runJob("RUN_TASK", { name: payload.name })
+                    .then(() => resolve())
+                    .catch((err) => reject(err));
+            } else resolve();
+        });
+    }
+
+    PAUSE_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            if (tasks[payload.name].timer) tasks[name].timer.pause();
+            resolve();
+        });
+    }
+
+    RESUME_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            tasks[payload.name].timer.resume();
+            resolve();
+        });
+    }
+
+    RUN_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            const task = tasks[payload.name];
+            if (task.timer) task.timer.pause();
+
+            task.fn.apply(this).then(() => {
+                task.lastRan = Date.now();
+                task.timer = new Timer(
+                    () => {
+                        this.runJob("RUN_TASK", { name: payload.name });
+                    },
+                    task.timeout,
+                    false
+                );
+
+                resolve();
+            });
+        });
+    }
+
+    checkStationSkipTask(callback) {
+        return new Promise((resolve, reject) => {
+            this.log(
+                "INFO",
+                "TASK_STATIONS_SKIP_CHECK",
+                `Checking for stations to be skipped.`,
+                false
+            );
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", {
+                                table: "stations",
+                            })
+                            .then((response) => next(null, response))
+                            .catch(next);
+                    },
+                    (stations, next) => {
+                        async.each(
+                            stations,
+                            (station, next2) => {
+                                if (
+                                    station.paused ||
+                                    !station.currentSong ||
+                                    !station.currentSong.title
+                                )
+                                    return next2();
+                                const timeElapsed =
+                                    Date.now() -
+                                    station.startedAt -
+                                    station.timePaused;
+                                if (timeElapsed <= station.currentSong.duration)
+                                    return next2();
+                                else {
+                                    this.log(
+                                        "ERROR",
+                                        "TASK_STATIONS_SKIP_CHECK",
+                                        `Skipping ${station._id} as it should have skipped already.`
+                                    );
+                                    this.stations
+                                        .runJob("INITIALIZE_STATION", {
+                                            stationId: station._id,
+                                        })
+                                        .then(() => {
+                                            next2();
+                                        });
+                                }
+                            },
+                            () => {
+                                next();
+                            }
+                        );
+                    },
+                ],
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
+
+    sessionClearingTask() {
+        return new Promise((resolve, reject) => {
+            this.log(
+                "INFO",
+                "TASK_SESSION_CLEAR",
+                `Checking for sessions to be cleared.`
+            );
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", {
+                                table: "sessions",
+                            })
+                            .then((sessions) => {
+                                next(null, sessions);
+                            })
+                            .catch(next);
+                    },
+                    (sessions, next) => {
+                        if (!sessions) return next();
+                        let keys = Object.keys(sessions);
+                        async.each(
+                            keys,
+                            (sessionId, next2) => {
+                                let session = sessions[sessionId];
+                                if (
+                                    session &&
+                                    session.refreshDate &&
+                                    Date.now() - session.refreshDate <
+                                        60 * 60 * 24 * 30 * 1000
+                                )
+                                    return next2();
+                                if (!session) {
+                                    this.log(
+                                        "INFO",
+                                        "TASK_SESSION_CLEAR",
+                                        "Removing an empty session."
+                                    );
+                                    this.cache
+                                        .runJob("HDEL", {
+                                            table: "sessions",
+                                            key: sessionId,
+                                        })
+                                        .finally(() => next2());
+                                } else if (!session.refreshDate) {
+                                    session.refreshDate = Date.now();
+                                    this.cache
+                                        .runJob("HSET", {
+                                            table: "sessions",
+                                            key: sessionId,
+                                            value: session,
+                                        })
+                                        .finally(() => next2());
+                                } else if (
+                                    Date.now() - session.refreshDate >
+                                    60 * 60 * 24 * 30 * 1000
+                                ) {
+                                    this.utils
+                                        .runJob("SOCKETS_FROM_SESSION_ID", {
+                                            sessionId: session.sessionId,
+                                        })
+                                        .then((response) => {
+                                            if (response.sockets.length > 0) {
+                                                session.refreshDate = Date.now();
+                                                this.cache
+                                                    .runJob("HSET", {
+                                                        table: "sessions",
+                                                        key: sessionId,
+                                                        value: session,
+                                                    })
+                                                    .finally(() => next2());
+                                            } else {
+                                                this.log(
+                                                    "INFO",
+                                                    "TASK_SESSION_CLEAR",
+                                                    `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`
+                                                );
+                                                this.cache
+                                                    .runJob("HDEL", {
+                                                        table: "sessions",
+                                                        key: session.sessionId,
+                                                    })
+                                                    .finally(() => next2());
+                                            }
+                                        });
+                                } else {
+                                    this.log(
+                                        "ERROR",
+                                        "TASK_SESSION_CLEAR",
+                                        "This should never log."
+                                    );
+                                    next2();
+                                }
+                            },
+                            () => {
+                                next();
+                            }
+                        );
+                    },
+                ],
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
+
+    logFileSizeCheckTask() {
+        return new Promise((resolve, reject) => {
+            this.log(
+                "INFO",
+                "TASK_LOG_FILE_SIZE_CHECK",
+                `Checking the size for the log files.`
+            );
+            async.each(
+                [
+                    "all.log",
+                    "debugStation.log",
+                    "error.log",
+                    "info.log",
+                    "success.log",
+                ],
+                (fileName, next) => {
+                    const stats = fs.statSync(
+                        `${__dirname}/../../log/${fileName}`
+                    );
+                    const mb = stats.size / 1000000;
+                    if (mb > 25) return next(true);
+                    else next();
+                },
+                (err) => {
+                    if (err === true) {
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "************************************WARNING*************************************"
+                        );
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************"
+                        );
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****"
+                        );
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "********************************************************************************"
+                        );
+                    }
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new TasksModule();

+ 752 - 624
backend/logic/utils.js

@@ -1,625 +1,753 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const config = require('config');
-const async = require('async');
-const request = require('request');
-const crypto = require('crypto');
-
-class Timer {
-	constructor(callback, delay, paused) {
-		this.callback = callback;
-		this.timerId = undefined;
-		this.start = undefined;
-		this.paused = paused;
-		this.remaining = delay;
-		this.timeWhenPaused = 0;
-		this.timePaused = Date.now();
-
-		if (!paused) {
-			this.resume();
-		}
-	}
-
-	pause() {
-		clearTimeout(this.timerId);
-		this.remaining -= Date.now() - this.start;
-		this.timePaused = Date.now();
-		this.paused = true;
-	}
-
-	ifNotPaused() {
-		if (!this.paused) {
-			this.resume();
-		}
-	}
-
-	resume() {
-		this.start = Date.now();
-		clearTimeout(this.timerId);
-		this.timerId = setTimeout(this.callback, this.remaining);
-		this.timeWhenPaused = Date.now() - this.timePaused;
-		this.paused = false;
-	}
-
-	resetTimeWhenPaused() {
-		this.timeWhenPaused = 0;
-	}
-
-	getTimePaused() {
-		if (!this.paused) {
-			return this.timeWhenPaused;
-		} else {
-			return Date.now() - this.timePaused;
-		}
-	}
-} 
-
-let youtubeRequestCallbacks = [];
-let youtubeRequestsPending = 0;
-let youtubeRequestsActive = false;
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-			
-			this.io = this.moduleManager.modules["io"];
-			this.db = this.moduleManager.modules["db"];
-			this.spotify = this.moduleManager.modules["spotify"];
-			this.cache = this.moduleManager.modules["cache"];
-
-			this.Timer = Timer;
-
-			resolve();
-		});
-	}
-
-	async parseCookies(cookieString) {
-		try { await this._validateHook(); } catch { return; }
-		let cookies = {};
-		if (cookieString) cookieString.split("; ").map((cookie) => {
-			(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
-		});
-		return cookies;
-	}
-
-	async cookiesToString(cookies) {
-		try { await this._validateHook(); } catch { return; }
-		let newCookie = [];
-		for (let prop in cookie) {
-			newCookie.push(prop + "=" + cookie[prop]);
-		}
-		return newCookie.join("; ");
-	}
-
-	async removeCookie(cookieString, cookieName) {
-		try { await this._validateHook(); } catch { return; }
-		var cookies = this.parseCookies(cookieString);
-		delete cookies[cookieName];
-		return this.toString(cookies);
-	}
-
-	async htmlEntities(str) {
-		try { await this._validateHook(); } catch { return; }
-		return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
-	}
-
-	async generateRandomString(len) {
-		try { await this._validateHook(); } catch { return; }
-		let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
-		let result = [];
-		for (let i = 0; i < len; i++) {
-			result.push(chars[await this.getRandomNumber(0, chars.length - 1)]);
-		}
-		return result.join("");
-	}
-
-	async getSocketFromId(socketId) {
-		try { await this._validateHook(); } catch { return; }
-		return globals.io.sockets.sockets[socketId];
-	}
-
-	async getRandomNumber(min, max) {
-		try { await this._validateHook(); } catch { return; }
-		return Math.floor(Math.random() * (max - min + 1)) + min
-	}
-
-	async convertTime(duration) {
-		try { await this._validateHook(); } catch { return; }
-		let a = duration.match(/\d+/g);
-	
-		if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
-			a = [0, a[0], 0];
-		}
-	
-		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
-			a = [a[0], 0, a[1]];
-		}
-		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
-			a = [a[0], 0, 0];
-		}
-	
-		duration = 0;
-	
-		if (a.length == 3) {
-			duration = duration + parseInt(a[0]) * 3600;
-			duration = duration + parseInt(a[1]) * 60;
-			duration = duration + parseInt(a[2]);
-		}
-	
-		if (a.length == 2) {
-			duration = duration + parseInt(a[0]) * 60;
-			duration = duration + parseInt(a[1]);
-		}
-	
-		if (a.length == 1) {
-			duration = duration + parseInt(a[0]);
-		}
-	
-		let hours = Math.floor(duration / 3600);
-		let minutes = Math.floor(duration % 3600 / 60);
-		let seconds = Math.floor(duration % 3600 % 60);
-	
-		return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
-	}
-
-	async guid () {
-		try { await this._validateHook(); } catch { return; }
-		return [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join('');
-	}
-
-	async socketFromSession(socketId) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		if (ns) {
-			return ns.connected[socketId];
-		}
-	}
-
-	async socketsFromSessionId(sessionId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				if (session.sessionId === sessionId) sockets.push(session.sessionId);
-				next();
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketsFromUser(userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				this.cache.hget('sessions', session.sessionId, (err, session) => {
-					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
-					next();
-				});
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketsFromIP(ip, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				this.cache.hget('sessions', session.sessionId, (err, session) => {
-					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
-					next();
-				});
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketsFromUserWithoutCache(userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				if (session.userId === userId) sockets.push(ns.connected[id]);
-				next();
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketLeaveRooms(socketid) {
-		try { await this._validateHook(); } catch { return; }
-
-		let socket = await this.socketFromSession(socketid);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			socket.leave(room);
-		}
-	}
-
-	async socketJoinRoom(socketId, room) {
-		try { await this._validateHook(); } catch { return; }
-
-		let socket = await this.socketFromSession(socketId);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			socket.leave(room);
-		}
-		socket.join(room);
-	}
-
-	async socketJoinSongRoom(socketId, room) {
-		try { await this._validateHook(); } catch { return; }
-
-		let socket = await this.socketFromSession(socketId);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			if (room.indexOf('song.') !== -1) socket.leave(rooms);
-		}
-		socket.join(room);
-	}
-
-	async socketsJoinSongRoom(sockets, room) {
-		try { await this._validateHook(); } catch { return; }
-
-		for (let id in sockets) {
-			let socket = sockets[id];
-			let rooms = socket.rooms;
-			for (let room in rooms) {
-				if (room.indexOf('song.') !== -1) socket.leave(room);
-			}
-			socket.join(room);
-		}
-	}
-
-	async socketsLeaveSongRooms(sockets) {
-		try { await this._validateHook(); } catch { return; }
-
-		for (let id in sockets) {
-			let socket = sockets[id];
-			let rooms = socket.rooms;
-			for (let room in rooms) {
-				if (room.indexOf('song.') !== -1) socket.leave(room);
-			}
-		}
-	}
-
-	async emitToRoom(room, ...args) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let sockets = io.sockets.sockets;
-		for (let id in sockets) {
-			let socket = sockets[id];
-			if (socket.rooms[room]) {
-				socket.emit.apply(socket, args);
-			}
-		}
-	}
-
-	async getRoomSockets(room) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let sockets = io.sockets.sockets;
-		let roomSockets = [];
-		for (let id in sockets) {
-			let socket = sockets[id];
-			if (socket.rooms[room]) roomSockets.push(socket);
-		}
-		return roomSockets;
-	}
-
-	async getSongFromYouTube(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		youtubeRequestCallbacks.push({cb: (test) => {
-			youtubeRequestsActive = true;
-			const youtubeParams = [
-				'part=snippet,contentDetails,statistics,status',
-				`id=${encodeURIComponent(songId)}`,
-				`key=${config.get('apis.youtube.key')}`
-			].join('&');
-
-			request(`https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`, (err, res, body) => {
-
-				youtubeRequestCallbacks.splice(0, 1);
-				if (youtubeRequestCallbacks.length > 0) {
-					youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
-				} else youtubeRequestsActive = false;
-
-				if (err) {
-					console.error(err);
-					return null;
-				}
-
-				body = JSON.parse(body);
-
-				//TODO Clean up duration converter
-  				let dur = body.items[0].contentDetails.duration;
-				dur = dur.replace('PT', '');
-				let duration = 0;
-				dur = dur.replace(/([\d]*)H/, (v, v2) => {
-					v2 = Number(v2);
-					duration = (v2 * 60 * 60);
-					return '';
-				});
-				dur = dur.replace(/([\d]*)M/, (v, v2) => {
-					v2 = Number(v2);
-					duration += (v2 * 60);
-					return '';
-				});
-				dur = dur.replace(/([\d]*)S/, (v, v2) => {
-					v2 = Number(v2);
-					duration += v2;
-					return '';
-				});
-
-				let song = {
-					songId: body.items[0].id,
-					title: body.items[0].snippet.title,
-					duration
-				};
-				cb(song);
-			});
-		}, songId});
-
-		if (!youtubeRequestsActive) {
-			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
-		}
-	}
-
-	async filterMusicVideosYouTube(videoIds, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		function getNextPage(cb2) {
-			let localVideoIds = videoIds.splice(0, 50);
-
-			const youtubeParams = [
-				'part=topicDetails',
-				`id=${encodeURIComponent(localVideoIds.join(","))}`,
-				`maxResults=50`,
-				`key=${config.get('apis.youtube.key')}`
-			].join('&');
-
-			request(`https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`, async (err, res, body) => {
-				if (err) {
-					console.error(err);
-					return next('Failed to find playlist from YouTube');
-				}
-
-				body = JSON.parse(body);
-
-				let songIds = [];
-				body.items.forEach(item => {
-					const songId = item.id;
-					if (!item.topicDetails) return;
-					else if (item.topicDetails.topicIds.indexOf("/m/04rlf") !== -1) {
-						songIds.push(songId);
-					}
-				});
-
-				if (videoIds.length > 0) {
-					getNextPage(newSongIds => {
-						cb2(songIds.concat(newSongIds));
-					});
-				} else cb2(songIds);
-			});
-		}
-
-		if (videoIds.length === 0) cb([]);
-		else getNextPage(songIds => {
-			cb(songIds);
-		});
-	}
-
-	async getPlaylistFromYouTube(url, musicOnly, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let local = this;
-
-		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
-		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
-		let playlistId = regex.exec(url)[1];
-
-		function getPage(pageToken, songs) {
-			let nextPageToken = (pageToken) ? `pageToken=${pageToken}` : '';
-			const youtubeParams = [
-				'part=contentDetails',
-				`playlistId=${encodeURIComponent(playlistId)}`,
-				`maxResults=50`,
-				`key=${config.get('apis.youtube.key')}`,
-				nextPageToken
-			].join('&');
-
-			request(`https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`, async (err, res, body) => {
-				if (err) {
-					console.error(err);
-					return next('Failed to find playlist from YouTube');
-				}
-
-				body = JSON.parse(body);
-				songs = songs.concat(body.items);
-				if (body.nextPageToken) getPage(body.nextPageToken, songs);
-				else {
-					songs = songs.map(song => song.contentDetails.videoId);
-					if (!musicOnly) cb(songs);
-					else {
-						local.filterMusicVideosYouTube(songs.slice(), (filteredSongs) => {
-							cb(filteredSongs, songs);
-						});
-					}
-				}
-			});
-		}
-		getPage(null, []);
-	}
-
-	async getSongFromSpotify(song, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!config.get("apis.spotify.enabled")) return cb("Spotify is not enabled", null);
-
-		const spotifyParams = [
-			`q=${encodeURIComponent(song.title)}`,
-			`type=track`
-		].join('&');
-
-		const token = await this.spotify.getToken();
-		const options = {
-			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
-			headers: {
-				Authorization: `Bearer ${token}`
-			}
-		};
-
-		request(options, (err, res, body) => {
-			if (err) console.error(err);
-			body = JSON.parse(body);
-			if (body.error) console.error(body.error);
-
-			durationArtistLoop:
-			for (let i in body) {
-				let items = body[i].items;
-				for (let j in items) {
-					let item = items[j];
-					let hasArtist = false;
-					for (let k = 0; k < item.artists.length; k++) {
-						let artist = item.artists[k];
-						if (song.title.indexOf(artist.name) !== -1) hasArtist = true;
-					}
-					if (hasArtist && song.title.indexOf(item.name) !== -1) {
-						song.duration = item.duration_ms / 1000;
-						song.artists = item.artists.map(artist => {
-							return artist.name;
-						});
-						song.title = item.name;
-						song.explicit = item.explicit;
-						song.thumbnail = item.album.images[1].url;
-						break durationArtistLoop;
-					}
-				}
-			}
-
-			cb(null, song);
-		});
-	}
-
-	async getSongsFromSpotify(title, artist, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!config.get("apis.spotify.enabled")) return cb([]);
-
-		const spotifyParams = [
-			`q=${encodeURIComponent(title)}`,
-			`type=track`
-		].join('&');
-		
-		const token = await this.spotify.getToken();
-		const options = {
-			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
-			headers: {
-				Authorization: `Bearer ${token}`
-			}
-		};
-
-		request(options, (err, res, body) => {
-			if (err) return console.error(err);
-			body = JSON.parse(body);
-			if (body.error) return console.error(body.error);
-
-			let songs = [];
-
-			for (let i in body) {
-				let items = body[i].items;
-				for (let j in items) {
-					let item = items[j];
-					let hasArtist = false;
-					for (let k = 0; k < item.artists.length; k++) {
-						let localArtist = item.artists[k];
-						if (artist.toLowerCase() === localArtist.name.toLowerCase()) hasArtist = true;
-					}
-					if (hasArtist && (title.indexOf(item.name) !== -1 || item.name.indexOf(title) !== -1)) {
-						let song = {};
-						song.duration = item.duration_ms / 1000;
-						song.artists = item.artists.map(artist => {
-							return artist.name;
-						});
-						song.title = item.name;
-						song.explicit = item.explicit;
-						song.thumbnail = item.album.images[1].url;
-						songs.push(song);
-					}
-				}
-			}
-
-			cb(songs);
-		});
-	}
-
-	async shuffle(array) {
-		try { await this._validateHook(); } catch { return; }
-
-		let currentIndex = array.length, temporaryValue, randomIndex;
-
-		// While there remain elements to shuffle...
-		while (0 !== currentIndex) {
-
-			// Pick a remaining element...
-			randomIndex = Math.floor(Math.random() * currentIndex);
-			currentIndex -= 1;
-
-			// And swap it with the current element.
-			temporaryValue = array[currentIndex];
-			array[currentIndex] = array[randomIndex];
-			array[randomIndex] = temporaryValue;
-		}
-
-		return array;
-	}
-
-	async getError(err) {
-		try { await this._validateHook(); } catch { return; }
-
-		let error = 'An error occurred.';
-		if (typeof err === "string") error = err;
-		else if (err.message) {
-			if (err.message !== 'Validation failed') error = err.message;
-			else error = err.errors[Object.keys(err.errors)].message;
-		}
-		return error;
-	}
-
-	async createGravatar(email) {
-		try { await this._validateHook(); } catch { return; }
-
-		const hash = crypto.createHash('md5').update(email).digest('hex');
-
-		return `https://www.gravatar.com/avatar/${hash}`;
-	}
+const CoreClass = require("../core.js");
+
+class UtilsModule extends CoreClass {
+    constructor() {
+        super("utils");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.io = this.moduleManager.modules["io"];
+            this.db = this.moduleManager.modules["db"];
+            this.spotify = this.moduleManager.modules["spotify"];
+            this.cache = this.moduleManager.modules["cache"];
+
+            resolve();
+        });
+    }
+
+    PARSE_COOKIES(payload) {
+        //cookieString
+        return new Promise((resolve, reject) => {
+            let cookies = {};
+            payload.cookieString.split("; ").map((cookie) => {
+                cookies[
+                    cookie.substring(0, cookie.indexOf("="))
+                ] = cookie.substring(cookie.indexOf("=") + 1, cookie.length);
+            });
+            resolve(cookies);
+        });
+    }
+
+    // COOKIES_TO_STRING() {//cookies
+    // 	return new Promise((resolve, reject) => {
+    //         let newCookie = [];
+    //         for (let prop in cookie) {
+    //             newCookie.push(prop + "=" + cookie[prop]);
+    //         }
+    //         return newCookie.join("; ");
+    //     });
+    // }
+
+    REMOVE_COOKIE(payload) {
+        //cookieString, cookieName
+        return new Promise(async (resolve, reject) => {
+            var cookies = await this.runJob("PARSE_COOKIES", {
+                cookieString: payload.cookieString,
+            });
+            delete cookies[payload.cookieName];
+            resolve(this.toString(cookies));
+        });
+    }
+
+    HTML_ENTITIES(payload) {
+        //str
+        return new Promise((resolve, reject) => {
+            resolve(
+                String(payload.str)
+                    .replace(/&/g, "&amp;")
+                    .replace(/</g, "&lt;")
+                    .replace(/>/g, "&gt;")
+                    .replace(/"/g, "&quot;")
+            );
+        });
+    }
+
+    GENERATE_RANDOM_STRING(payload) {
+        //length
+        return new Promise(async (resolve, reject) => {
+            let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(
+                ""
+            );
+            let result = [];
+            for (let i = 0; i < payload.length; i++) {
+                result.push(
+                    chars[
+                        await this.runJob("GET_RANDOM_NUMBER", {
+                            min: 0,
+                            max: chars.length - 1,
+                        })
+                    ]
+                );
+            }
+            resolve(result.join(""));
+        });
+    }
+
+    GET_SOCKET_FROM_ID(payload) {
+        //socketId
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            resolve(io.sockets.sockets[payload.socketId]);
+        });
+    }
+
+    GET_RANDOM_NUMBER(payload) {
+        //min, max
+        return new Promise((resolve, reject) => {
+            resolve(
+                Math.floor(Math.random() * (payload.max - payload.min + 1)) +
+                    payload.min
+            );
+        });
+    }
+
+    CONVERT_TIME(payload) {
+        //duration
+        return new Promise((resolve, reject) => {
+            let duration = payload.duration;
+            let a = duration.match(/\d+/g);
+
+            if (
+                duration.indexOf("M") >= 0 &&
+                duration.indexOf("H") == -1 &&
+                duration.indexOf("S") == -1
+            ) {
+                a = [0, a[0], 0];
+            }
+
+            if (duration.indexOf("H") >= 0 && duration.indexOf("M") == -1) {
+                a = [a[0], 0, a[1]];
+            }
+            if (
+                duration.indexOf("H") >= 0 &&
+                duration.indexOf("M") == -1 &&
+                duration.indexOf("S") == -1
+            ) {
+                a = [a[0], 0, 0];
+            }
+
+            duration = 0;
+
+            if (a.length == 3) {
+                duration = duration + parseInt(a[0]) * 3600;
+                duration = duration + parseInt(a[1]) * 60;
+                duration = duration + parseInt(a[2]);
+            }
+
+            if (a.length == 2) {
+                duration = duration + parseInt(a[0]) * 60;
+                duration = duration + parseInt(a[1]);
+            }
+
+            if (a.length == 1) {
+                duration = duration + parseInt(a[0]);
+            }
+
+            let hours = Math.floor(duration / 3600);
+            let minutes = Math.floor((duration % 3600) / 60);
+            let seconds = Math.floor((duration % 3600) % 60);
+
+            resolve(
+                (hours < 10 ? "0" + hours + ":" : hours + ":") +
+                    (minutes < 10 ? "0" + minutes + ":" : minutes + ":") +
+                    (seconds < 10 ? "0" + seconds : seconds)
+            );
+        });
+    }
+
+    GUID(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(
+                [1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1]
+                    .map((b) =>
+                        b
+                            ? Math.floor((1 + Math.random()) * 0x10000)
+                                  .toString(16)
+                                  .substring(1)
+                            : "-"
+                    )
+                    .join("")
+            );
+        });
+    }
+
+    SOCKET_FROM_SESSION(payload) {
+        //socketId
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            if (ns) {
+                resolve(ns.connected[payload.socketId]);
+            }
+        });
+    }
+
+    SOCKETS_FROM_SESSION_ID(payload) {
+        //sessionId, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        if (session.sessionId === payload.sessionId)
+                            sockets.push(session.sessionId);
+                        next();
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKETS_FROM_USER(payload) {
+        //userId, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        this.cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: session.sessionId,
+                            })
+                            .then((session) => {
+                                if (
+                                    session &&
+                                    session.userId === payload.userId
+                                )
+                                    sockets.push(ns.connected[id]);
+                                next();
+                            })
+                            .catch(() => {
+                                next();
+                            });
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKETS_FROM_IP(payload) {
+        //ip, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        this.cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: session.sessionId,
+                            })
+                            .then((session) => {
+                                if (
+                                    session &&
+                                    ns.connected[id].ip === payload.ip
+                                )
+                                    sockets.push(ns.connected[id]);
+                                next();
+                            })
+                            .catch((err) => {
+                                next();
+                            });
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKETS_FROM_USER_WITHOUT_CACHE(payload) {
+        //userId, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        if (session.userId === payload.userId)
+                            sockets.push(ns.connected[id]);
+                        next();
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKET_LEAVE_ROOMS(payload) {
+        //socketId
+        return new Promise(async (resolve, reject) => {
+            let socket = await this.runJob("SOCKET_FROM_SESSION", {
+                socketId: payload.socketId,
+            });
+            let rooms = socket.rooms;
+            for (let room in rooms) {
+                socket.leave(room);
+            }
+
+            resolve();
+        });
+    }
+
+    SOCKET_JOIN_ROOM(payload) {
+        //socketId, room
+        return new Promise(async (resolve, reject) => {
+            let socket = await this.runJob("SOCKET_FROM_SESSION", {
+                socketId: payload.socketId,
+            });
+            let rooms = socket.rooms;
+            for (let room in rooms) {
+                socket.leave(room);
+            }
+            socket.join(payload.room);
+            resolve();
+        });
+    }
+
+    SOCKET_JOIN_SONG_ROOM(payload) {
+        //socketId, room
+        return new Promise(async (resolve, reject) => {
+            let socket = await this.runJob("SOCKET_FROM_SESSION", {
+                socketId: payload.socketId,
+            });
+            let rooms = socket.rooms;
+            for (let room in rooms) {
+                if (room.indexOf("song.") !== -1) socket.leave(rooms);
+            }
+            socket.join(payload.room);
+            resolve();
+        });
+    }
+
+    SOCKETS_JOIN_SONG_ROOM(payload) {
+        //sockets, room
+        return new Promise((resolve, reject) => {
+            for (let id in payload.sockets) {
+                let socket = payload.sockets[id];
+                let rooms = socket.rooms;
+                for (let room in rooms) {
+                    if (room.indexOf("song.") !== -1) socket.leave(room);
+                }
+                socket.join(payload.room);
+            }
+            resolve();
+        });
+    }
+
+    SOCKETS_LEAVE_SONG_ROOMS(payload) {
+        //sockets
+        return new Promise((resolve, reject) => {
+            for (let id in payload.sockets) {
+                let socket = payload.sockets[id];
+                let rooms = payload.socket.rooms;
+                for (let room in rooms) {
+                    if (room.indexOf("song.") !== -1) socket.leave(room);
+                }
+            }
+            resolve();
+        });
+    }
+
+    EMIT_TO_ROOM(payload) {
+        //room, ...args
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let sockets = io.sockets.sockets;
+            for (let id in sockets) {
+                let socket = sockets[id];
+                if (socket.rooms[payload.room]) {
+                    socket.emit.apply(socket, payload.args);
+                }
+            }
+            resolve();
+        });
+    }
+
+    GET_ROOM_SOCKETS(payload) {
+        //room
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let sockets = io.sockets.sockets;
+            let roomSockets = [];
+            for (let id in sockets) {
+                let socket = sockets[id];
+                if (socket.rooms[payload.room]) roomSockets.push(socket);
+            }
+            resolve(roomSockets);
+        });
+    }
+
+    GET_SONG_FROM_YOUTUBE(payload) {
+        //songId, cb
+        return new Promise((resolve, reject) => {
+            youtubeRequestCallbacks.push({
+                cb: (test) => {
+                    youtubeRequestsActive = true;
+                    const youtubeParams = [
+                        "part=snippet,contentDetails,statistics,status",
+                        `id=${encodeURIComponent(payload.songId)}`,
+                        `key=${config.get("apis.youtube.key")}`,
+                    ].join("&");
+
+                    request(
+                        `https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`,
+                        (err, res, body) => {
+                            youtubeRequestCallbacks.splice(0, 1);
+                            if (youtubeRequestCallbacks.length > 0) {
+                                youtubeRequestCallbacks[0].cb(
+                                    youtubeRequestCallbacks[0].songId
+                                );
+                            } else youtubeRequestsActive = false;
+
+                            if (err) {
+                                console.error(err);
+                                return null;
+                            }
+
+                            body = JSON.parse(body);
+
+                            //TODO Clean up duration converter
+                            let dur = body.items[0].contentDetails.duration;
+                            dur = dur.replace("PT", "");
+                            let duration = 0;
+                            dur = dur.replace(/([\d]*)H/, (v, v2) => {
+                                v2 = Number(v2);
+                                duration = v2 * 60 * 60;
+                                return "";
+                            });
+                            dur = dur.replace(/([\d]*)M/, (v, v2) => {
+                                v2 = Number(v2);
+                                duration += v2 * 60;
+                                return "";
+                            });
+                            dur = dur.replace(/([\d]*)S/, (v, v2) => {
+                                v2 = Number(v2);
+                                duration += v2;
+                                return "";
+                            });
+
+                            let song = {
+                                songId: body.items[0].id,
+                                title: body.items[0].snippet.title,
+                                duration,
+                            };
+                            resolve({ song });
+                        }
+                    );
+                },
+                songId: payload.songId,
+            });
+
+            if (!youtubeRequestsActive) {
+                youtubeRequestCallbacks[0].cb(
+                    youtubeRequestCallbacks[0].songId
+                );
+            }
+        });
+    }
+
+    FILTER_MUSIC_VIDEOS_YOUTUBE(payload) {
+        //videoIds, cb
+        return new Promise((resolve, reject) => {
+            function getNextPage(cb2) {
+                let localVideoIds = payload.videoIds.splice(0, 50);
+
+                const youtubeParams = [
+                    "part=topicDetails",
+                    `id=${encodeURIComponent(localVideoIds.join(","))}`,
+                    `maxResults=50`,
+                    `key=${config.get("apis.youtube.key")}`,
+                ].join("&");
+
+                request(
+                    `https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`,
+                    async (err, res, body) => {
+                        if (err) {
+                            console.error(err);
+                            return next("Failed to find playlist from YouTube");
+                        }
+
+                        body = JSON.parse(body);
+
+                        let songIds = [];
+                        body.items.forEach((item) => {
+                            const songId = item.id;
+                            if (!item.topicDetails) return;
+                            else if (
+                                item.topicDetails.topicIds.indexOf(
+                                    "/m/04rlf"
+                                ) !== -1
+                            ) {
+                                songIds.push(songId);
+                            }
+                        });
+
+                        if (payload.videoIds.length > 0) {
+                            getNextPage((newSongIds) => {
+                                cb2(songIds.concat(newSongIds));
+                            });
+                        } else cb2(songIds);
+                    }
+                );
+            }
+
+            if (payload.videoIds.length === 0) resolve({ songIds: [] });
+            else
+                getNextPage((songIds) => {
+                    resolve({ songIds });
+                });
+        });
+    }
+
+    GET_PLAYLIST_FROM_YOUTUBE(payload) {
+        //url, musicOnly, cb
+        return new Promise((resolve, reject) => {
+            let local = this;
+
+            let name = "list".replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
+            var regex = new RegExp("[\\?&]" + name + "=([^&#]*)");
+            let playlistId = regex.exec(payload.url)[1];
+
+            function getPage(pageToken, songs) {
+                let nextPageToken = pageToken ? `pageToken=${pageToken}` : "";
+                const youtubeParams = [
+                    "part=contentDetails",
+                    `playlistId=${encodeURIComponent(playlistId)}`,
+                    `maxResults=50`,
+                    `key=${config.get("apis.youtube.key")}`,
+                    nextPageToken,
+                ].join("&");
+
+                request(
+                    `https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`,
+                    async (err, res, body) => {
+                        if (err) {
+                            console.error(err);
+                            return next("Failed to find playlist from YouTube");
+                        }
+
+                        body = JSON.parse(body);
+                        songs = songs.concat(body.items);
+                        if (body.nextPageToken)
+                            getPage(body.nextPageToken, songs);
+                        else {
+                            songs = songs.map(
+                                (song) => song.contentDetails.videoId
+                            );
+                            if (!payload.musicOnly) resolve({ songs });
+                            else {
+                                local
+                                    .runJob("FILTER_MUSIC_VIDEOS_YOUTUBE", {
+                                        videoIds: songs.slice(),
+                                    })
+                                    .resolve((filteredSongs) => {
+                                        resolve({ filteredSongs, songs });
+                                    });
+                            }
+                        }
+                    }
+                );
+            }
+            getPage(null, []);
+        });
+    }
+
+    GET_SONG_FROM_SPOTIFY(payload) {
+        //song, cb
+        return new Promise(async (resolve, reject) => {
+            if (!config.get("apis.spotify.enabled"))
+                return reject(new Error("Spotify is not enabled."));
+
+            const song = Object.assign({}, payload.song);
+
+            const spotifyParams = [
+                `q=${encodeURIComponent(payload.song.title)}`,
+                `type=track`,
+            ].join("&");
+
+            const token = await this.spotify.runJob("GET_TOKEN", {});
+            const options = {
+                url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+                headers: {
+                    Authorization: `Bearer ${token}`,
+                },
+            };
+
+            request(options, (err, res, body) => {
+                if (err) console.error(err);
+                body = JSON.parse(body);
+                if (body.error) console.error(body.error);
+
+                durationArtistLoop: for (let i in body) {
+                    let items = body[i].items;
+                    for (let j in items) {
+                        let item = items[j];
+                        let hasArtist = false;
+                        for (let k = 0; k < item.artists.length; k++) {
+                            let artist = item.artists[k];
+                            if (song.title.indexOf(artist.name) !== -1)
+                                hasArtist = true;
+                        }
+                        if (hasArtist && song.title.indexOf(item.name) !== -1) {
+                            song.duration = item.duration_ms / 1000;
+                            song.artists = item.artists.map((artist) => {
+                                return artist.name;
+                            });
+                            song.title = item.name;
+                            song.explicit = item.explicit;
+                            song.thumbnail = item.album.images[1].url;
+                            break durationArtistLoop;
+                        }
+                    }
+                }
+
+                resolve({ song });
+            });
+        });
+    }
+
+    GET_SONGS_FROM_SPOTIFY() {
+        //title, artist, cb
+        return new Promise(async (resolve, reject) => {
+            if (!config.get("apis.spotify.enabled"))
+                return reject(new Error("Spotify is not enabled."));
+
+            const spotifyParams = [
+                `q=${encodeURIComponent(payload.title)}`,
+                `type=track`,
+            ].join("&");
+
+            const token = await this.spotify.runJob("GET_TOKEN", {});
+            const options = {
+                url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+                headers: {
+                    Authorization: `Bearer ${token}`,
+                },
+            };
+
+            request(options, (err, res, body) => {
+                if (err) return console.error(err);
+                body = JSON.parse(body);
+                if (body.error) return console.error(body.error);
+
+                let songs = [];
+
+                for (let i in body) {
+                    let items = body[i].items;
+                    for (let j in items) {
+                        let item = items[j];
+                        let hasArtist = false;
+                        for (let k = 0; k < item.artists.length; k++) {
+                            let localArtist = item.artists[k];
+                            if (
+                                payload.artist.toLowerCase() ===
+                                localArtist.name.toLowerCase()
+                            )
+                                hasArtist = true;
+                        }
+                        if (
+                            hasArtist &&
+                            (payload.title.indexOf(item.name) !== -1 ||
+                                item.name.indexOf(payload.title) !== -1)
+                        ) {
+                            let song = {};
+                            song.duration = item.duration_ms / 1000;
+                            song.artists = item.artists.map((artist) => {
+                                return artist.name;
+                            });
+                            song.title = item.name;
+                            song.explicit = item.explicit;
+                            song.thumbnail = item.album.images[1].url;
+                            songs.push(song);
+                        }
+                    }
+                }
+
+                resolve({ songs });
+            });
+        });
+    }
+
+    SHUFFLE() {
+        //array
+        return new Promise((resolve, reject) => {
+            const array = payload.array.slice();
+
+            let currentIndex = payload.array.length,
+                temporaryValue,
+                randomIndex;
+
+            // While there remain elements to shuffle...
+            while (0 !== currentIndex) {
+                // Pick a remaining element...
+                randomIndex = Math.floor(Math.random() * currentIndex);
+                currentIndex -= 1;
+
+                // And swap it with the current element.
+                temporaryValue = array[currentIndex];
+                array[currentIndex] = array[randomIndex];
+                array[randomIndex] = temporaryValue;
+            }
+
+            return array;
+        });
+    }
+
+    GET_ERROR(payload) {
+        //err
+        return new Promise((resolve, reject) => {
+            let error = "An error occurred.";
+            if (typeof payload.error === "string") error = payload.error;
+            else if (payload.error.message) {
+                if (payload.error.message !== "Validation failed")
+                    error = payload.error.message;
+                else
+                    error =
+                        payload.error.errors[Object.keys(payload.error.errors)]
+                            .message;
+            }
+            resolve(error);
+        });
+    }
+
+    CREATE_GRAVATAR(payload) {
+        //email
+        return new Promise((resolve, reject) => {
+            const hash = crypto
+                .createHash("md5")
+                .update(payload.email)
+                .digest("hex");
+
+            resolve(`https://www.gravatar.com/avatar/${hash}`);
+        });
+    }
 }
+
+module.exports = new UtilsModule();

+ 2284 - 0
backend/package-lock.json

@@ -0,0 +1,2284 @@
+{
+  "name": "musare-backend",
+  "version": "2.1.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+    },
+    "accepts": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
+      "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
+      "requires": {
+        "mime-types": "~2.1.24",
+        "negotiator": "0.6.2"
+      }
+    },
+    "after": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
+      "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
+    },
+    "agent-base": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
+      "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==",
+      "requires": {
+        "es6-promisify": "^5.0.0"
+      }
+    },
+    "ajv": {
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.0.tgz",
+      "integrity": "sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==",
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+    },
+    "aproba": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+    },
+    "are-we-there-yet": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+      "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+      "requires": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
+      }
+    },
+    "array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
+    },
+    "arraybuffer.slice": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz",
+      "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog=="
+    },
+    "asn1": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+      "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+      "requires": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
+    "assert-plus": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+      "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
+    },
+    "ast-types": {
+      "version": "0.13.2",
+      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.2.tgz",
+      "integrity": "sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA=="
+    },
+    "async": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.1.0.tgz",
+      "integrity": "sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ=="
+    },
+    "async-limiter": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
+      "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
+    },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+    },
+    "aws-sign2": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+      "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
+    },
+    "aws4": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz",
+      "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug=="
+    },
+    "backo2": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
+      "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
+    },
+    "balanced-match": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+    },
+    "base64-arraybuffer": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
+      "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg="
+    },
+    "base64id": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+      "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
+    },
+    "bcrypt": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-3.0.8.tgz",
+      "integrity": "sha512-jKV6RvLhI36TQnPDvUFqBEnGX9c8dRRygKxCZu7E+MgLfKZbmmXL8a7/SFFOyHoPNX9nV81cKRC5tbQfvEQtpw==",
+      "requires": {
+        "nan": "2.14.0",
+        "node-pre-gyp": "0.14.0"
+      }
+    },
+    "bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+      "requires": {
+        "tweetnacl": "^0.14.3"
+      },
+      "dependencies": {
+        "tweetnacl": {
+          "version": "0.14.5",
+          "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+          "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+        }
+      }
+    },
+    "better-assert": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
+      "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=",
+      "requires": {
+        "callsite": "1.0.0"
+      }
+    },
+    "bl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz",
+      "integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==",
+      "requires": {
+        "readable-stream": "^2.3.5",
+        "safe-buffer": "^5.1.1"
+      }
+    },
+    "blob": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
+      "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig=="
+    },
+    "bluebird": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
+    },
+    "body-parser": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
+      "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
+      "requires": {
+        "bytes": "3.1.0",
+        "content-type": "~1.0.4",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "on-finished": "~2.3.0",
+        "qs": "6.7.0",
+        "raw-body": "2.4.0",
+        "type-is": "~1.6.17"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "bson": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.3.tgz",
+      "integrity": "sha512-TdiJxMVnodVS7r0BdL42y/pqC9cL2iKynVwA0Ho3qbsQYr428veL3l7BQyuqiw+Q5SqqoT0m4srSY/BlZ9AxXg=="
+    },
+    "bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
+    },
+    "callsite": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
+      "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA="
+    },
+    "caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
+    },
+    "chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+    },
+    "co": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+      "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
+    },
+    "code-point-at": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+      "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
+    },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "component-bind": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
+      "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E="
+    },
+    "component-emitter": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
+      "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY="
+    },
+    "component-inherit": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
+      "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM="
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "config": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/config/-/config-3.3.0.tgz",
+      "integrity": "sha512-Xw++JjmYOLLX2HaYpySAveO8a9o+Af0jpDdEt1st8xtLeZI0bDfNsI90DGFyE/7mNnEjHiI8ivp/PieM6ODtdw==",
+      "requires": {
+        "json5": "^1.0.1"
+      }
+    },
+    "console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+    },
+    "content-disposition": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
+      "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
+      "requires": {
+        "safe-buffer": "5.1.2"
+      }
+    },
+    "content-type": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+      "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
+    },
+    "convert-hex": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/convert-hex/-/convert-hex-0.1.0.tgz",
+      "integrity": "sha1-CMBFaJIsJ3drii6BqV05M2LqC2U="
+    },
+    "convert-string": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/convert-string/-/convert-string-0.1.0.tgz",
+      "integrity": "sha1-ec5BqbsNA7z3LNxqjzxW+7xkQQo="
+    },
+    "cookie": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
+      "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
+    },
+    "cookie-parser": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.4.tgz",
+      "integrity": "sha512-lo13tqF3JEtFO7FyA49CqbhaFkskRJ0u/UAiINgrIXeRCY41c88/zxtrECl8AKH3B0hj9q10+h3Kt8I7KlW4tw==",
+      "requires": {
+        "cookie": "0.3.1",
+        "cookie-signature": "1.0.6"
+      }
+    },
+    "cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+    },
+    "core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+    },
+    "cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "requires": {
+        "object-assign": "^4",
+        "vary": "^1"
+      }
+    },
+    "dashdash": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+      "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+      "requires": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "data-uri-to-buffer": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz",
+      "integrity": "sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ=="
+    },
+    "debug": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+      "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+      "requires": {
+        "ms": "^2.1.1"
+      }
+    },
+    "deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="
+    },
+    "deep-is": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+      "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
+    },
+    "degenerator": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz",
+      "integrity": "sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=",
+      "requires": {
+        "ast-types": "0.x.x",
+        "escodegen": "1.x.x",
+        "esprima": "3.x.x"
+      }
+    },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
+    },
+    "delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+    },
+    "denque": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz",
+      "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ=="
+    },
+    "depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
+    },
+    "destroy": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
+      "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
+    },
+    "detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
+    },
+    "discord.js": {
+      "version": "11.6.0",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-11.6.0.tgz",
+      "integrity": "sha512-W40KJp8FYmSFJVLgPGfS0WiDwivDctK1z0DyJFlV/Ocy2v5QfNuQ4Eey/K7718bcMcHbST7EyZWcAUvBirquLA==",
+      "requires": {
+        "long": "^4.0.0",
+        "prism-media": "^0.0.4",
+        "snekfetch": "^3.6.4",
+        "tweetnacl": "^1.0.0",
+        "ws": "^6.0.0"
+      }
+    },
+    "double-ended-queue": {
+      "version": "2.1.0-0",
+      "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
+      "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw="
+    },
+    "ecc-jsbn": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+      "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+      "requires": {
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.1.0"
+      }
+    },
+    "ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+    },
+    "encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
+    },
+    "engine.io": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.4.0.tgz",
+      "integrity": "sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==",
+      "requires": {
+        "accepts": "~1.3.4",
+        "base64id": "2.0.0",
+        "cookie": "0.3.1",
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
+        "ws": "^7.1.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "ws": {
+          "version": "7.2.1",
+          "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz",
+          "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A=="
+        }
+      }
+    },
+    "engine.io-client": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.0.tgz",
+      "integrity": "sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==",
+      "requires": {
+        "component-emitter": "1.2.1",
+        "component-inherit": "0.0.3",
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
+        "has-cors": "1.1.0",
+        "indexof": "0.0.1",
+        "parseqs": "0.0.5",
+        "parseuri": "0.0.5",
+        "ws": "~6.1.0",
+        "xmlhttprequest-ssl": "~1.5.4",
+        "yeast": "0.1.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "ws": {
+          "version": "6.1.4",
+          "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz",
+          "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==",
+          "requires": {
+            "async-limiter": "~1.0.0"
+          }
+        }
+      }
+    },
+    "engine.io-parser": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.0.tgz",
+      "integrity": "sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==",
+      "requires": {
+        "after": "0.8.2",
+        "arraybuffer.slice": "~0.0.7",
+        "base64-arraybuffer": "0.1.5",
+        "blob": "0.0.5",
+        "has-binary2": "~1.0.2"
+      }
+    },
+    "es6-promise": {
+      "version": "4.2.8",
+      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+      "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
+    },
+    "es6-promisify": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
+      "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
+      "requires": {
+        "es6-promise": "^4.0.3"
+      }
+    },
+    "escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+    },
+    "escodegen": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz",
+      "integrity": "sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==",
+      "requires": {
+        "esprima": "^4.0.1",
+        "estraverse": "^4.2.0",
+        "esutils": "^2.0.2",
+        "optionator": "^0.8.1",
+        "source-map": "~0.6.1"
+      },
+      "dependencies": {
+        "esprima": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+          "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
+        }
+      }
+    },
+    "esprima": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz",
+      "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM="
+    },
+    "estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
+    },
+    "etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
+    },
+    "express": {
+      "version": "4.17.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
+      "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
+      "requires": {
+        "accepts": "~1.3.7",
+        "array-flatten": "1.1.1",
+        "body-parser": "1.19.0",
+        "content-disposition": "0.5.3",
+        "content-type": "~1.0.4",
+        "cookie": "0.4.0",
+        "cookie-signature": "1.0.6",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.1.2",
+        "fresh": "0.5.2",
+        "merge-descriptors": "1.0.1",
+        "methods": "~1.1.2",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "0.1.7",
+        "proxy-addr": "~2.0.5",
+        "qs": "6.7.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.1.2",
+        "send": "0.17.1",
+        "serve-static": "1.14.1",
+        "setprototypeof": "1.1.1",
+        "statuses": "~1.5.0",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "dependencies": {
+        "cookie": {
+          "version": "0.4.0",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
+          "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
+        },
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
+    },
+    "extsprintf": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+      "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
+    },
+    "fast-deep-equal": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
+      "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA=="
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc="
+    },
+    "file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+    },
+    "finalhandler": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+      "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
+      "requires": {
+        "debug": "2.6.9",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "statuses": "~1.5.0",
+        "unpipe": "~1.0.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "forever-agent": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+      "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
+    },
+    "form-data": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
+      "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.6",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "forwarded": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
+      "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ="
+    },
+    "fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
+    },
+    "fs-minipass": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
+      "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
+      "requires": {
+        "minipass": "^2.6.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "ftp": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz",
+      "integrity": "sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=",
+      "requires": {
+        "readable-stream": "1.1.x",
+        "xregexp": "2.0.0"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "0.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+        },
+        "readable-stream": {
+          "version": "1.1.14",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+          "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.1",
+            "isarray": "0.0.1",
+            "string_decoder": "~0.10.x"
+          }
+        },
+        "string_decoder": {
+          "version": "0.10.31",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+          "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+        }
+      }
+    },
+    "gauge": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+      "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+      "requires": {
+        "aproba": "^1.0.3",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.0",
+        "object-assign": "^4.1.0",
+        "signal-exit": "^3.0.0",
+        "string-width": "^1.0.1",
+        "strip-ansi": "^3.0.1",
+        "wide-align": "^1.1.0"
+      }
+    },
+    "get-uri": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.4.tgz",
+      "integrity": "sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==",
+      "requires": {
+        "data-uri-to-buffer": "1",
+        "debug": "2",
+        "extend": "~3.0.2",
+        "file-uri-to-path": "1",
+        "ftp": "~0.3.10",
+        "readable-stream": "2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "getpass": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+      "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+      "requires": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "glob": {
+      "version": "7.1.6",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "har-schema": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+      "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
+    },
+    "har-validator": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
+      "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
+      "requires": {
+        "ajv": "^6.5.5",
+        "har-schema": "^2.0.0"
+      }
+    },
+    "has-binary2": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz",
+      "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==",
+      "requires": {
+        "isarray": "2.0.1"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
+        }
+      }
+    },
+    "has-cors": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
+      "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
+    },
+    "has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+    },
+    "http-errors": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
+      "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
+      "requires": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.3",
+        "setprototypeof": "1.1.1",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.0"
+      },
+      "dependencies": {
+        "inherits": {
+          "version": "2.0.3",
+          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+          "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+        }
+      }
+    },
+    "http-proxy-agent": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz",
+      "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==",
+      "requires": {
+        "agent-base": "4",
+        "debug": "3.1.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "http-signature": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+      "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+      "requires": {
+        "assert-plus": "^1.0.0",
+        "jsprim": "^1.2.2",
+        "sshpk": "^1.7.0"
+      }
+    },
+    "https-proxy-agent": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz",
+      "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==",
+      "requires": {
+        "agent-base": "^4.3.0",
+        "debug": "^3.1.0"
+      }
+    },
+    "iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "requires": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      }
+    },
+    "ignore-walk": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz",
+      "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==",
+      "requires": {
+        "minimatch": "^3.0.4"
+      }
+    },
+    "indexof": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
+      "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
+    },
+    "inflection": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz",
+      "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY="
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "ini": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
+      "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
+    },
+    "ip": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
+      "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo="
+    },
+    "ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
+    },
+    "is-fullwidth-code-point": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+      "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+      "requires": {
+        "number-is-nan": "^1.0.0"
+      }
+    },
+    "is-stream": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+      "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
+    },
+    "is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
+    },
+    "isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+    },
+    "isstream": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+      "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
+    },
+    "jsbn": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+      "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
+    },
+    "json-schema": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+      "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+    },
+    "json-stringify-safe": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
+    },
+    "json5": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+      "requires": {
+        "minimist": "^1.2.0"
+      },
+      "dependencies": {
+        "minimist": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+          "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+        }
+      }
+    },
+    "jsprim": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
+      "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
+      "requires": {
+        "assert-plus": "1.0.0",
+        "extsprintf": "1.3.0",
+        "json-schema": "0.2.3",
+        "verror": "1.10.0"
+      }
+    },
+    "kareem": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.1.tgz",
+      "integrity": "sha512-l3hLhffs9zqoDe8zjmb/mAN4B8VT3L56EUvKNqLFVs9YlFA+zx7ke1DO8STAdDyYNkeSo1nKmjuvQeI12So8Xw=="
+    },
+    "levn": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+      "requires": {
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2"
+      }
+    },
+    "lodash": {
+      "version": "4.17.15",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+      "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+    },
+    "long": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
+    },
+    "lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "requires": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "mailgun-js": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/mailgun-js/-/mailgun-js-0.22.0.tgz",
+      "integrity": "sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==",
+      "requires": {
+        "async": "^2.6.1",
+        "debug": "^4.1.0",
+        "form-data": "^2.3.3",
+        "inflection": "~1.12.0",
+        "is-stream": "^1.1.0",
+        "path-proxy": "~1.0.0",
+        "promisify-call": "^2.0.2",
+        "proxy-agent": "^3.0.3",
+        "tsscmp": "^1.0.6"
+      },
+      "dependencies": {
+        "async": {
+          "version": "2.6.3",
+          "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
+          "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+          "requires": {
+            "lodash": "^4.17.14"
+          }
+        },
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+    },
+    "memory-pager": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+      "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+      "optional": true
+    },
+    "merge-descriptors": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+    },
+    "methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
+    },
+    "mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
+    },
+    "mime-db": {
+      "version": "1.43.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
+      "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
+    },
+    "mime-types": {
+      "version": "2.1.26",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
+      "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
+      "requires": {
+        "mime-db": "1.43.0"
+      }
+    },
+    "minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "0.0.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+      "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
+    },
+    "minipass": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
+      "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
+      "requires": {
+        "safe-buffer": "^5.1.2",
+        "yallist": "^3.0.0"
+      }
+    },
+    "minizlib": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
+      "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
+      "requires": {
+        "minipass": "^2.9.0"
+      }
+    },
+    "mkdirp": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+      "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+      "requires": {
+        "minimist": "0.0.8"
+      }
+    },
+    "moment": {
+      "version": "2.24.0",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+      "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+    },
+    "mongodb": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.5.3.tgz",
+      "integrity": "sha512-II7P7A3XUdPiXRgcN96qIoRa1oesM6qLNZkzfPluNZjVkgQk3jnQwOT6/uDk4USRDTTLjNFw2vwfmbRGTA7msg==",
+      "requires": {
+        "bl": "^2.2.0",
+        "bson": "^1.1.1",
+        "denque": "^1.4.1",
+        "require_optional": "^1.0.1",
+        "safe-buffer": "^5.1.2",
+        "saslprep": "^1.0.0"
+      }
+    },
+    "mongoose": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.9.2.tgz",
+      "integrity": "sha512-Sa1qfqBvUfAgsrXpZjbBoIx8PEDUJSKF5Ous8gnBFI7TPiueSgJjg6GRA7A0teU8AB/vd0h8rl1rD5RQNfWhIw==",
+      "requires": {
+        "bson": "~1.1.1",
+        "kareem": "2.3.1",
+        "mongodb": "3.5.3",
+        "mongoose-legacy-pluralize": "1.0.2",
+        "mpath": "0.6.0",
+        "mquery": "3.2.2",
+        "ms": "2.1.2",
+        "regexp-clone": "1.0.0",
+        "safe-buffer": "5.1.2",
+        "sift": "7.0.1",
+        "sliced": "1.0.1"
+      }
+    },
+    "mongoose-legacy-pluralize": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz",
+      "integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ=="
+    },
+    "mpath": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.6.0.tgz",
+      "integrity": "sha512-i75qh79MJ5Xo/sbhxrDrPSEG0H/mr1kcZXJ8dH6URU5jD/knFxCVqVC/gVSW7GIXL/9hHWlT9haLbCXWOll3qw=="
+    },
+    "mquery": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.2.tgz",
+      "integrity": "sha512-XB52992COp0KP230I3qloVUbkLUxJIu328HBP2t2EsxSFtf4W1HPSOBWOXf1bqxK4Xbb66lfMJ+Bpfd9/yZE1Q==",
+      "requires": {
+        "bluebird": "3.5.1",
+        "debug": "3.1.0",
+        "regexp-clone": "^1.0.0",
+        "safe-buffer": "5.1.2",
+        "sliced": "1.0.1"
+      },
+      "dependencies": {
+        "bluebird": {
+          "version": "3.5.1",
+          "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
+          "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA=="
+        },
+        "debug": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "nan": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
+      "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
+    },
+    "needle": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.2.tgz",
+      "integrity": "sha512-DUzITvPVDUy6vczKKYTnWc/pBZ0EnjMJnQ3y+Jo5zfKFimJs7S3HFCxCRZYB9FUZcrzUQr3WsmvZgddMEIZv6w==",
+      "requires": {
+        "debug": "^3.2.6",
+        "iconv-lite": "^0.4.4",
+        "sax": "^1.2.4"
+      }
+    },
+    "negotiator": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+      "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
+    },
+    "netmask": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
+      "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU="
+    },
+    "node-pre-gyp": {
+      "version": "0.14.0",
+      "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz",
+      "integrity": "sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==",
+      "requires": {
+        "detect-libc": "^1.0.2",
+        "mkdirp": "^0.5.1",
+        "needle": "^2.2.1",
+        "nopt": "^4.0.1",
+        "npm-packlist": "^1.1.6",
+        "npmlog": "^4.0.2",
+        "rc": "^1.2.7",
+        "rimraf": "^2.6.1",
+        "semver": "^5.3.0",
+        "tar": "^4.4.2"
+      }
+    },
+    "nopt": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+      "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
+      "requires": {
+        "abbrev": "1",
+        "osenv": "^0.1.4"
+      }
+    },
+    "npm-bundled": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz",
+      "integrity": "sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==",
+      "requires": {
+        "npm-normalize-package-bin": "^1.0.1"
+      }
+    },
+    "npm-normalize-package-bin": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
+      "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA=="
+    },
+    "npm-packlist": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz",
+      "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==",
+      "requires": {
+        "ignore-walk": "^3.0.1",
+        "npm-bundled": "^1.0.1",
+        "npm-normalize-package-bin": "^1.0.1"
+      }
+    },
+    "npmlog": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+      "requires": {
+        "are-we-there-yet": "~1.1.2",
+        "console-control-strings": "~1.1.0",
+        "gauge": "~2.7.3",
+        "set-blocking": "~2.0.0"
+      }
+    },
+    "number-is-nan": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+      "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
+    },
+    "oauth": {
+      "version": "0.9.15",
+      "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
+      "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
+    },
+    "oauth-sign": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
+      "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    },
+    "object-component": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
+      "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE="
+    },
+    "on-finished": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+      "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+      "requires": {
+        "ee-first": "1.1.1"
+      }
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "optionator": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
+      "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
+      "requires": {
+        "deep-is": "~0.1.3",
+        "fast-levenshtein": "~2.0.6",
+        "levn": "~0.3.0",
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2",
+        "word-wrap": "~1.2.3"
+      }
+    },
+    "os-homedir": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+      "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
+    },
+    "os-tmpdir": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+      "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
+    },
+    "osenv": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+      "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+      "requires": {
+        "os-homedir": "^1.0.0",
+        "os-tmpdir": "^1.0.0"
+      }
+    },
+    "pac-proxy-agent": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz",
+      "integrity": "sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==",
+      "requires": {
+        "agent-base": "^4.2.0",
+        "debug": "^4.1.1",
+        "get-uri": "^2.0.0",
+        "http-proxy-agent": "^2.1.0",
+        "https-proxy-agent": "^3.0.0",
+        "pac-resolver": "^3.0.0",
+        "raw-body": "^2.2.0",
+        "socks-proxy-agent": "^4.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "pac-resolver": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-3.0.0.tgz",
+      "integrity": "sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==",
+      "requires": {
+        "co": "^4.6.0",
+        "degenerator": "^1.0.4",
+        "ip": "^1.1.5",
+        "netmask": "^1.0.6",
+        "thunkify": "^2.1.2"
+      }
+    },
+    "parseqs": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
+      "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
+    "parseuri": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz",
+      "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
+    "parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+    },
+    "path-proxy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/path-proxy/-/path-proxy-1.0.0.tgz",
+      "integrity": "sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=",
+      "requires": {
+        "inflection": "~1.3.0"
+      },
+      "dependencies": {
+        "inflection": {
+          "version": "1.3.8",
+          "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.3.8.tgz",
+          "integrity": "sha1-y9Fg2p91sUw8xjV41POWeEvzAU4="
+        }
+      }
+    },
+    "path-to-regexp": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+    },
+    "performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+    },
+    "prelude-ls": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
+    },
+    "prism-media": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-0.0.4.tgz",
+      "integrity": "sha512-dG2w7WtovUa4SiYTdWn9H8Bd4JNdei2djtkP/Bk9fXq81j5Q15ZPHYSwhUVvBRbp5zMkGtu0Yk62HuMcly0pRw=="
+    },
+    "process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "promisify-call": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz",
+      "integrity": "sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=",
+      "requires": {
+        "with-callback": "^1.0.2"
+      }
+    },
+    "proxy-addr": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
+      "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
+      "requires": {
+        "forwarded": "~0.1.2",
+        "ipaddr.js": "1.9.1"
+      }
+    },
+    "proxy-agent": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-3.1.1.tgz",
+      "integrity": "sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw==",
+      "requires": {
+        "agent-base": "^4.2.0",
+        "debug": "4",
+        "http-proxy-agent": "^2.1.0",
+        "https-proxy-agent": "^3.0.0",
+        "lru-cache": "^5.1.1",
+        "pac-proxy-agent": "^3.0.1",
+        "proxy-from-env": "^1.0.0",
+        "socks-proxy-agent": "^4.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "proxy-from-env": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
+      "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4="
+    },
+    "psl": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz",
+      "integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ=="
+    },
+    "punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+    },
+    "qs": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+      "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
+    },
+    "range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
+    },
+    "raw-body": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
+      "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
+      "requires": {
+        "bytes": "3.1.0",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      }
+    },
+    "rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "requires": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "dependencies": {
+        "minimist": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+          "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+        }
+      }
+    },
+    "readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "requires": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "redis": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-2.8.0.tgz",
+      "integrity": "sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==",
+      "requires": {
+        "double-ended-queue": "^2.1.0-0",
+        "redis-commands": "^1.2.0",
+        "redis-parser": "^2.6.0"
+      }
+    },
+    "redis-commands": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz",
+      "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg=="
+    },
+    "redis-parser": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz",
+      "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs="
+    },
+    "regexp-clone": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz",
+      "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw=="
+    },
+    "request": {
+      "version": "2.88.2",
+      "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
+      "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
+      "requires": {
+        "aws-sign2": "~0.7.0",
+        "aws4": "^1.8.0",
+        "caseless": "~0.12.0",
+        "combined-stream": "~1.0.6",
+        "extend": "~3.0.2",
+        "forever-agent": "~0.6.1",
+        "form-data": "~2.3.2",
+        "har-validator": "~5.1.3",
+        "http-signature": "~1.2.0",
+        "is-typedarray": "~1.0.0",
+        "isstream": "~0.1.2",
+        "json-stringify-safe": "~5.0.1",
+        "mime-types": "~2.1.19",
+        "oauth-sign": "~0.9.0",
+        "performance-now": "^2.1.0",
+        "qs": "~6.5.2",
+        "safe-buffer": "^5.1.2",
+        "tough-cookie": "~2.5.0",
+        "tunnel-agent": "^0.6.0",
+        "uuid": "^3.3.2"
+      },
+      "dependencies": {
+        "form-data": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+          "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+          "requires": {
+            "asynckit": "^0.4.0",
+            "combined-stream": "^1.0.6",
+            "mime-types": "^2.1.12"
+          }
+        },
+        "qs": {
+          "version": "6.5.2",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+          "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
+        }
+      }
+    },
+    "require_optional": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz",
+      "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==",
+      "requires": {
+        "resolve-from": "^2.0.0",
+        "semver": "^5.1.0"
+      }
+    },
+    "resolve-from": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
+      "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
+    },
+    "rimraf": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+      "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+    },
+    "saslprep": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
+      "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
+      "optional": true,
+      "requires": {
+        "sparse-bitfield": "^3.0.3"
+      }
+    },
+    "sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+    },
+    "semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
+    },
+    "send": {
+      "version": "0.17.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
+      "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
+      "requires": {
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "destroy": "~1.0.4",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "0.5.2",
+        "http-errors": "~1.7.2",
+        "mime": "1.6.0",
+        "ms": "2.1.1",
+        "on-finished": "~2.3.0",
+        "range-parser": "~1.2.1",
+        "statuses": "~1.5.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
+          }
+        },
+        "ms": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+          "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
+        }
+      }
+    },
+    "serve-static": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
+      "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
+      "requires": {
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "0.17.1"
+      }
+    },
+    "set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+    },
+    "setprototypeof": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
+      "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
+    },
+    "sha256": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/sha256/-/sha256-0.2.0.tgz",
+      "integrity": "sha1-c6C0GNqrcDW/+G6EkeNjQS/CqwU=",
+      "requires": {
+        "convert-hex": "~0.1.0",
+        "convert-string": "~0.1.0"
+      }
+    },
+    "sift": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/sift/-/sift-7.0.1.tgz",
+      "integrity": "sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g=="
+    },
+    "signal-exit": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+      "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
+    },
+    "sliced": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
+      "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
+    },
+    "smart-buffer": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.1.0.tgz",
+      "integrity": "sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw=="
+    },
+    "snekfetch": {
+      "version": "3.6.4",
+      "resolved": "https://registry.npmjs.org/snekfetch/-/snekfetch-3.6.4.tgz",
+      "integrity": "sha512-NjxjITIj04Ffqid5lqr7XdgwM7X61c/Dns073Ly170bPQHLm6jkmelye/eglS++1nfTWktpP6Y2bFXjdPlQqdw=="
+    },
+    "socket.io": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.3.0.tgz",
+      "integrity": "sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==",
+      "requires": {
+        "debug": "~4.1.0",
+        "engine.io": "~3.4.0",
+        "has-binary2": "~1.0.2",
+        "socket.io-adapter": "~1.1.0",
+        "socket.io-client": "2.3.0",
+        "socket.io-parser": "~3.4.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "socket.io-adapter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz",
+      "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g=="
+    },
+    "socket.io-client": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.0.tgz",
+      "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==",
+      "requires": {
+        "backo2": "1.0.2",
+        "base64-arraybuffer": "0.1.5",
+        "component-bind": "1.0.0",
+        "component-emitter": "1.2.1",
+        "debug": "~4.1.0",
+        "engine.io-client": "~3.4.0",
+        "has-binary2": "~1.0.2",
+        "has-cors": "1.1.0",
+        "indexof": "0.0.1",
+        "object-component": "0.0.3",
+        "parseqs": "0.0.5",
+        "parseuri": "0.0.5",
+        "socket.io-parser": "~3.3.0",
+        "to-array": "0.1.4"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
+        },
+        "socket.io-parser": {
+          "version": "3.3.0",
+          "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz",
+          "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==",
+          "requires": {
+            "component-emitter": "1.2.1",
+            "debug": "~3.1.0",
+            "isarray": "2.0.1"
+          },
+          "dependencies": {
+            "debug": {
+              "version": "3.1.0",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+              "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+              "requires": {
+                "ms": "2.0.0"
+              }
+            },
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
+          }
+        }
+      }
+    },
+    "socket.io-parser": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.4.0.tgz",
+      "integrity": "sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==",
+      "requires": {
+        "component-emitter": "1.2.1",
+        "debug": "~4.1.0",
+        "isarray": "2.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
+        }
+      }
+    },
+    "socks": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.3.tgz",
+      "integrity": "sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==",
+      "requires": {
+        "ip": "1.1.5",
+        "smart-buffer": "^4.1.0"
+      }
+    },
+    "socks-proxy-agent": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz",
+      "integrity": "sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==",
+      "requires": {
+        "agent-base": "~4.2.1",
+        "socks": "~2.3.2"
+      },
+      "dependencies": {
+        "agent-base": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz",
+          "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==",
+          "requires": {
+            "es6-promisify": "^5.0.0"
+          }
+        }
+      }
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "optional": true
+    },
+    "sparse-bitfield": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+      "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
+      "optional": true,
+      "requires": {
+        "memory-pager": "^1.0.2"
+      }
+    },
+    "sshpk": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
+      "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
+      "requires": {
+        "asn1": "~0.2.3",
+        "assert-plus": "^1.0.0",
+        "bcrypt-pbkdf": "^1.0.0",
+        "dashdash": "^1.12.0",
+        "ecc-jsbn": "~0.1.1",
+        "getpass": "^0.1.1",
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.0.2",
+        "tweetnacl": "~0.14.0"
+      },
+      "dependencies": {
+        "tweetnacl": {
+          "version": "0.14.5",
+          "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+          "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+        }
+      }
+    },
+    "statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
+    },
+    "string-width": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+      "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+      "requires": {
+        "code-point-at": "^1.0.0",
+        "is-fullwidth-code-point": "^1.0.0",
+        "strip-ansi": "^3.0.0"
+      }
+    },
+    "string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "requires": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "strip-ansi": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+      "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+      "requires": {
+        "ansi-regex": "^2.0.0"
+      }
+    },
+    "strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+    },
+    "tar": {
+      "version": "4.4.13",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz",
+      "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==",
+      "requires": {
+        "chownr": "^1.1.1",
+        "fs-minipass": "^1.2.5",
+        "minipass": "^2.8.6",
+        "minizlib": "^1.2.1",
+        "mkdirp": "^0.5.0",
+        "safe-buffer": "^5.1.2",
+        "yallist": "^3.0.3"
+      }
+    },
+    "thunkify": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz",
+      "integrity": "sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0="
+    },
+    "to-array": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz",
+      "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA="
+    },
+    "toidentifier": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+      "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
+    },
+    "tough-cookie": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+      "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+      "requires": {
+        "psl": "^1.1.28",
+        "punycode": "^2.1.1"
+      }
+    },
+    "tsscmp": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
+      "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="
+    },
+    "tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+      "requires": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "tweetnacl": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+      "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
+    },
+    "type-check": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+      "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+      "requires": {
+        "prelude-ls": "~1.1.2"
+      }
+    },
+    "type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "requires": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      }
+    },
+    "underscore": {
+      "version": "1.9.2",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.2.tgz",
+      "integrity": "sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ=="
+    },
+    "unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
+    },
+    "uri-js": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
+      "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+    },
+    "utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
+    },
+    "uuid": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+      "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
+    },
+    "vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
+    },
+    "verror": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+      "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+      "requires": {
+        "assert-plus": "^1.0.0",
+        "core-util-is": "1.0.2",
+        "extsprintf": "^1.2.0"
+      }
+    },
+    "wide-align": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+      "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+      "requires": {
+        "string-width": "^1.0.2 || 2"
+      }
+    },
+    "with-callback": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz",
+      "integrity": "sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE="
+    },
+    "word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ=="
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "ws": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
+      "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
+      "requires": {
+        "async-limiter": "~1.0.0"
+      }
+    },
+    "xmlhttprequest-ssl": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz",
+      "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4="
+    },
+    "xregexp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz",
+      "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM="
+    },
+    "yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
+    },
+    "yeast": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
+      "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
+    }
+  }
+}