Browse Source

Merge pull request #30 from Musare/staging

Release
Jonathan 8 years ago
parent
commit
a65b670e47
100 changed files with 7086 additions and 1953 deletions
  1. 7 1
      .gitignore
  2. 95 18
      README.md
  3. 9 0
      backend/config/template.json
  4. 37 3
      backend/index.js
  5. 69 9
      backend/logic/actions/apis.js
  6. 26 8
      backend/logic/actions/hooks/adminRequired.js
  7. 19 2
      backend/logic/actions/hooks/loginRequired.js
  8. 31 18
      backend/logic/actions/hooks/ownerRequired.js
  9. 141 17
      backend/logic/actions/news.js
  10. 385 117
      backend/logic/actions/playlists.js
  11. 140 56
      backend/logic/actions/queueSongs.js
  12. 203 65
      backend/logic/actions/reports.js
  13. 257 82
      backend/logic/actions/songs.js
  14. 869 354
      backend/logic/actions/stations.js
  15. 781 103
      backend/logic/actions/users.js
  16. 27 0
      backend/logic/api.js
  17. 169 66
      backend/logic/app.js
  18. 29 14
      backend/logic/cache/index.js
  19. 8 0
      backend/logic/cache/schemas/officialPlaylist.js
  20. 1 0
      backend/logic/cache/schemas/session.js
  21. 154 5
      backend/logic/db/index.js
  22. 0 1
      backend/logic/db/schemas/playlist.js
  23. 1 1
      backend/logic/db/schemas/queueSong.js
  24. 1 1
      backend/logic/db/schemas/report.js
  25. 1 1
      backend/logic/db/schemas/song.js
  26. 6 4
      backend/logic/db/schemas/station.js
  27. 11 1
      backend/logic/db/schemas/user.js
  28. 21 6
      backend/logic/io.js
  29. 173 0
      backend/logic/logger.js
  30. 26 0
      backend/logic/mail/index.js
  31. 30 0
      backend/logic/mail/schemas/passwordRequest.js
  32. 30 0
      backend/logic/mail/schemas/resetPasswordRequest.js
  33. 27 0
      backend/logic/mail/schemas/verifyEmail.js
  34. 5 2
      backend/logic/notifications.js
  35. 103 19
      backend/logic/playlists.js
  36. 106 23
      backend/logic/songs.js
  37. 322 290
      backend/logic/stations.js
  38. 128 0
      backend/logic/tasks.js
  39. 190 74
      backend/logic/utils.js
  40. 5 2
      backend/package.json
  41. 30 0
      docker-compose-production.yml
  42. 3 0
      docker-compose.yml
  43. 5 0
      frontend/.babelrc
  44. 148 27
      frontend/App.vue
  45. 1 1
      frontend/Dockerfile
  46. BIN
      frontend/build/android-chrome-144x144.png
  47. BIN
      frontend/build/android-chrome-192x192.png
  48. BIN
      frontend/build/android-chrome-36x36.png
  49. BIN
      frontend/build/android-chrome-48x48.png
  50. BIN
      frontend/build/android-chrome-72x72.png
  51. BIN
      frontend/build/android-chrome-96x96.png
  52. BIN
      frontend/build/apple-touch-icon-114x114.png
  53. BIN
      frontend/build/apple-touch-icon-120x120.png
  54. BIN
      frontend/build/apple-touch-icon-144x144.png
  55. BIN
      frontend/build/apple-touch-icon-152x152.png
  56. BIN
      frontend/build/apple-touch-icon-180x180.png
  57. BIN
      frontend/build/apple-touch-icon-57x57.png
  58. BIN
      frontend/build/apple-touch-icon-60x60.png
  59. BIN
      frontend/build/apple-touch-icon-72x72.png
  60. BIN
      frontend/build/apple-touch-icon-76x76.png
  61. BIN
      frontend/build/apple-touch-icon-precomposed.png
  62. BIN
      frontend/build/apple-touch-icon.png
  63. BIN
      frontend/build/assets/favicon.ico
  64. BIN
      frontend/build/assets/notes-transparent.png
  65. BIN
      frontend/build/assets/notes.png
  66. 55 0
      frontend/build/assets/social/discord.svg
  67. 10 0
      frontend/build/assets/social/facebook.svg
  68. 59 0
      frontend/build/assets/social/github.svg
  69. 18 0
      frontend/build/assets/social/twitter.svg
  70. BIN
      frontend/build/assets/wordmark.png
  71. 12 0
      frontend/build/browserconfig.xml
  72. 5 4
      frontend/build/config/template.json
  73. BIN
      frontend/build/favicon-16x16.png
  74. BIN
      frontend/build/favicon-194x194.png
  75. BIN
      frontend/build/favicon-32x32.png
  76. BIN
      frontend/build/favicon-96x96.png
  77. BIN
      frontend/build/favicon.ico
  78. 50 14
      frontend/build/index.html
  79. 41 0
      frontend/build/manifest.json
  80. BIN
      frontend/build/mstile-144x144.png
  81. BIN
      frontend/build/mstile-150x150.png
  82. BIN
      frontend/build/mstile-310x150.png
  83. BIN
      frontend/build/mstile-310x310.png
  84. BIN
      frontend/build/mstile-70x70.png
  85. 2 0
      frontend/build/robots.txt
  86. 116 0
      frontend/build/safari-pinned-tab.svg
  87. 227 0
      frontend/components/Admin/News.vue
  88. 95 130
      frontend/components/Admin/QueueSongs.vue
  89. 82 44
      frontend/components/Admin/Reports.vue
  90. 95 122
      frontend/components/Admin/Songs.vue
  91. 152 88
      frontend/components/Admin/Stations.vue
  92. 300 0
      frontend/components/Admin/Statistics.vue
  93. 100 0
      frontend/components/Admin/Users.vue
  94. 23 12
      frontend/components/MainFooter.vue
  95. 9 3
      frontend/components/MainHeader.vue
  96. 124 0
      frontend/components/Modals/AddSongToPlaylist.vue
  97. 96 51
      frontend/components/Modals/AddSongToQueue.vue
  98. 49 36
      frontend/components/Modals/CreateCommunityStation.vue
  99. 236 0
      frontend/components/Modals/EditNews.vue
  100. 300 58
      frontend/components/Modals/EditSong.vue

+ 7 - 1
.gitignore

@@ -8,6 +8,7 @@ startRedis.cmd
 startMongo.cmd
 startMongo.cmd
 .database
 .database
 dump.rdb
 dump.rdb
+npm-debug.log
 
 
 # Back End
 # Back End
 backend/node_modules/
 backend/node_modules/
@@ -16,4 +17,9 @@ backend/config/default.json
 # Front End
 # Front End
 frontend/node_modules/
 frontend/node_modules/
 frontend/build/bundle.js
 frontend/build/bundle.js
-frontend/build/config/default.json
+frontend/build/config/default.json
+
+npm
+
+# Logs
+log/

+ 95 - 18
README.md

@@ -1,19 +1,21 @@
 # MusareNode
 # MusareNode
-This is a rewrite of the original [Musare](https://github.com/Musare/Musare)
-in NodeJS, Express, SocketIO and VueJS. Everything is ran in it's own docker container.
+This is a rewrite of the original [Musare](https://github.com/Musare/MusareMeteor)
+in NodeJS, Express, SocketIO and VueJS. Everything is ran in it's own docker container, but you can also run it without Docker.
+
+The site is available at [https://musare.com](https://musare.com).
 
 
 ### Our Stack
 ### Our Stack
 
 
    * NodeJS
    * NodeJS
    * MongoDB
    * MongoDB
    * Redis
    * Redis
-   * Nginx
+   * Nginx (not required)
    * VueJS
    * VueJS
 
 
 ### Frontend
 ### Frontend
 The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
 The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
 [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
 [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
-served over Nginx. The Nginx server not only serves the frontend, but
+served over Nginx or express. The Nginx server not only serves the frontend, but
 also serves as a load balancer for requests going to the backend.
 also serves as a load balancer for requests going to the backend.
 
 
 ### Backend
 ### Backend
@@ -23,43 +25,112 @@ in a central Redis server. All data is stored in a central MongoDB server.
 The Redis and MongoDB servers are replicated to several secondary nodes,
 The Redis and MongoDB servers are replicated to several secondary nodes,
 which can become the primary node if the current primary node goes down.
 which can become the primary node if the current primary node goes down.
 
 
+We currently only have 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+
 ## Requirements
 ## Requirements
+Option 1: (not recommended for Windows users)
  * [Docker](https://www.docker.com/)
  * [Docker](https://www.docker.com/)
 
 
+Option 2:
+ * [NodeJS](https://nodejs.org/en/download/)
+ 	* nodemon: `npm install -g nodemon`
+ 	* [node-gyp](https://github.com/nodejs/node-gyp#installation)
+ * [MongoDB](https://www.mongodb.com/download-center)
+ * [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
+
 ## Getting Started
 ## Getting Started
 Once you've installed the required tools:
 Once you've installed the required tools:
 
 
-1. `git clone https://github.com/MusareNode/MusareNode.git`
+1. `git clone https://github.com/Musare/MusareNode.git`
 
 
 2. `cd MusareNode`
 2. `cd MusareNode`
 
 
 3. `cp backend/config/template.json backend/config/default.json`
 3. `cp backend/config/template.json backend/config/default.json`
 
 
-   > The `secret` key can be whatever. It's used by express's session module.
-   The `apis.youtube.key` value can be obtained by setting up a
-   [YouTube API Key](https://developers.google.com/youtube/v3/getting-started).
-  
-4. Build the backend and frontend Docker images
+	Values:  
+   	The `secret` key can be whatever. It's used by express's session module.  
+   	The `domain` should be the url where the site will be accessible from, usually `http://localhost` for non-Docker.  
+   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.  
+   	The `serverPort` should be the port where the backend will listen on, usually `8080` for non-Docker.  
+   	`isDocker` if you are using Docker or not.  
+   	The `apis.youtube.key` value can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started).  
+	To set up a GitHub OAuth Application, you need to fill in some value's. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.
+   	The `apis.recaptcha.secret` value can be obtained by setting up a [ReCaptcha Site](https://www.google.com/recaptcha/admin).  
+   	The `apis.github` values can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers).  
+   	`apis.discord` is currently not needed.  
+   	The `apis.mailgun` values can be obtained by setting up a [Mailgun account](http://www.mailgun.com/).  
+   	The `redis.url` url should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.
+   	The `mongo.url` url should be left alone for Docker, and changed to `mongodb://localhost:27017/musare` for non-Docker.  
+   	The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
+   	The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
+
+4. `cp frontend/build/config/template.json frontend/build/config/default.json`
+
+	Values:  
+   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.   
+   	The `recaptcha.key` value can be obtained by setting up a [ReCaptcha Site](https://www.google.com/recaptcha/admin).  
+   	The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
+   	The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
+
+Now you have different paths here.
+
+####Docker
+
+1. Build the backend and frontend Docker images (from the main folder)
 
 
    `docker-compose build`
    `docker-compose build`
 
 
-5. Start the databases and tools in the background, as we usually don't need to monitor these for errors
+2. Start the databases and tools in the background, as we usually don't need to monitor these for errors
 
 
    `docker-compose up -d mongo mongoclient redis`
    `docker-compose up -d mongo mongoclient redis`
 
 
-6. Start the backend and frontend in the foreground, so we can watch for errors during development
+3. Start the backend and frontend in the foreground, so we can watch for errors during development
 
 
    `docker-compose up backend frontend`
    `docker-compose up backend frontend`
 
 
-7. You should now be able to begin development! The backend is auto reloaded when
+4. You should now be able to begin development! The backend is auto reloaded when
    you make changes and the frontend is auto compiled and live reloaded by webpack
    you make changes and the frontend is auto compiled and live reloaded by webpack
    when you make changes. You should be able to access Musare in your local browser
    when you make changes. You should be able to access Musare in your local browser
    at `http://<docker-machine-ip>:8080/` where `<docker-machine-ip>` can be found below:
    at `http://<docker-machine-ip>:8080/` where `<docker-machine-ip>` can be found below:
 
 
    * Docker for Windows / Mac: This is just `localhost`
    * Docker for Windows / Mac: This is just `localhost`
-   
+
    * Docker ToolBox: The output of `docker-machine ip default`
    * Docker ToolBox: The output of `docker-machine ip default`
-   
+
+####Non-docker
+
+Steps 1-4 are things you only have to do once. The steps to start servers follow.
+
+1. In the main folder, create a folder called `.database`
+
+2. Create a file called `startMongo.cmd` in the main folder with the contents:
+
+		"C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
+
+	Make sure to adjust your paths accordingly.
+
+3. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
+
+4. Create a file called `startRedis.cmd` in the main folder with the contents:
+
+		"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf"
+
+	And again, make sure that the paths lead to the proper config and executable.
+
+####Non-docker start servers
+
+**Automatic**
+
+1.  If you are on Windows you can run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
+
+**Manual**
+
+1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
+
+2. In a command prompt with the pwd of frontend, run `npm run development-watch`
+
+3. In a command prompt with the pwd of backend, run `nodemon`
+
 ## Extra
 ## Extra
 
 
 Below is a list of helpful tips / solutions we've collected while developing MusareNode.
 Below is a list of helpful tips / solutions we've collected while developing MusareNode.
@@ -82,7 +153,7 @@ of the following commands to give Docker Toolbox access to those files.
 2. Now start the machine back up and ssh into it
 2. Now start the machine back up and ssh into it
 
 
    `docker-machine start default && docker-machine ssh default`
    `docker-machine start default && docker-machine ssh default`
-   
+
 3. Tell boot2docker to mount our volume at startup, by appending to its startup script
 3. Tell boot2docker to mount our volume at startup, by appending to its startup script
 	```bash
 	```bash
 	sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
 	sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
@@ -95,7 +166,7 @@ of the following commands to give Docker Toolbox access to those files.
 4. Restart the docker machine so that it uses the new shared folder
 4. Restart the docker machine so that it uses the new shared folder
 
 
    `docker-machine restart default`
    `docker-machine restart default`
-   
+
 5. You now should be good to go!
 5. You now should be good to go!
 
 
 ### Fixing the "couldn't connect to docker daemon" error
 ### Fixing the "couldn't connect to docker daemon" error
@@ -133,4 +204,10 @@ You can call Toasts using our custom package, [`vue-roaster`](https://github.com
 ```js
 ```js
 import { Toast } from 'vue-roaster';
 import { Toast } from 'vue-roaster';
 Toast.methods.addToast('', 0);
 Toast.methods.addToast('', 0);
-```
+```
+
+## Contact
+
+There are multiple ways to contact us. You can send an email to [musaremusic@gmail.com](musaremusic@gmail.com) or [krisvos130@gmail.com](krisvos130@gmail.com).
+
+You can also message us on [Facebook](https://www.facebook.com/MusareMusic), [Twitter](https://twitter.com/MusareApp) or on our [Discord](https://discord.gg/Y5NxYGP).

+ 9 - 0
backend/config/template.json

@@ -2,6 +2,7 @@
 	"secret": "",
 	"secret": "",
 	"domain": "",
 	"domain": "",
 	"serverDomain": "",
 	"serverDomain": "",
+  	"serverPort": 8080,
   	"isDocker": true,
   	"isDocker": true,
 	"apis": {
 	"apis": {
 		"youtube": {
 		"youtube": {
@@ -18,6 +19,10 @@
 		"discord": {
 		"discord": {
 			"client": "",
 			"client": "",
 			"secret": ""
 			"secret": ""
+		},
+		"mailgun": {
+			"key": "",
+			"domain": ""
 		}
 		}
 	},
 	},
 	"cors": {
 	"cors": {
@@ -32,5 +37,9 @@
 	},
 	},
   	"mongo": {
   	"mongo": {
 	  	"url": "mongodb://mongo:27017/musare"
 	  	"url": "mongodb://mongo:27017/musare"
+	},
+  	"cookie": {
+	  	"domain": "",
+	  	"secure": false
 	}
 	}
 }
 }

+ 37 - 3
backend/index.js

@@ -3,17 +3,27 @@
 process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 
 
 const async = require('async');
 const async = require('async');
+const fs = require('fs');
 
 
 const db = require('./logic/db');
 const db = require('./logic/db');
 const app = require('./logic/app');
 const app = require('./logic/app');
+const mail = require('./logic/mail');
+const api = require('./logic/api');
 const io = require('./logic/io');
 const io = require('./logic/io');
 const stations = require('./logic/stations');
 const stations = require('./logic/stations');
 const songs = require('./logic/songs');
 const songs = require('./logic/songs');
 const playlists = require('./logic/playlists');
 const playlists = require('./logic/playlists');
 const cache = require('./logic/cache');
 const cache = require('./logic/cache');
 const notifications = require('./logic/notifications');
 const notifications = require('./logic/notifications');
+const logger = require('./logic/logger');
+const tasks = require('./logic/tasks');
 const config = require('config');
 const config = require('config');
 
 
+process.on('uncaughtException', err => {
+	//console.log(`ERROR: ${err.message}`);
+	console.log(`ERROR: ${err.stack}`);
+});
+
 async.waterfall([
 async.waterfall([
 
 
 	// setup our Redis cache
 	// setup our Redis cache
@@ -26,9 +36,12 @@ async.waterfall([
 	// setup our MongoDB database
 	// setup our MongoDB database
 	(next) => db.init(config.get("mongo").url, next),
 	(next) => db.init(config.get("mongo").url, next),
 
 
-	// setup the express server (not used right now, but may be used for OAuth stuff later, or for an API)
+	// setup the express server
 	(next) => app.init(next),
 	(next) => app.init(next),
 
 
+	// setup the mail
+	(next) => mail.init(next),
+
 	// setup the socket.io server (all client / server communication is done over this)
 	// setup the socket.io server (all client / server communication is done over this)
 	(next) => io.init(next),
 	(next) => io.init(next),
 
 
@@ -44,13 +57,33 @@ async.waterfall([
 	// setup the playlists
 	// setup the playlists
 	(next) => playlists.init(next),
 	(next) => playlists.init(next),
 
 
+	// setup the API
+	(next) => api.init(next),
+
+	// setup the logger
+	(next) => logger.init(next),
+
+	// setup the tasks system
+	(next) => tasks.init(next),
+
 	// setup the frontend for local setups
 	// setup the frontend for local setups
 	(next) => {
 	(next) => {
 		if (!config.get("isDocker")) {
 		if (!config.get("isDocker")) {
 			const express = require('express');
 			const express = require('express');
 			const app = express();
 			const app = express();
-			const server = app.listen(80);
-			app.use(express.static(__dirname + "/../frontend/build/"));
+			app.listen(80);
+			const rootDir = __dirname.substr(0, __dirname.lastIndexOf("backend")) + "frontend\\build\\";
+
+			app.get("/*", (req, res) => {
+				const path = req.path;
+				fs.access(rootDir + path, function(err) {
+					if (!err) {
+						res.sendFile(rootDir + path);
+					} else {
+						res.sendFile(rootDir + "index.html");
+					}
+				});
+			});
 		}
 		}
 		next();
 		next();
 	}
 	}
@@ -58,6 +91,7 @@ async.waterfall([
 	if (err && err !== true) {
 	if (err && err !== true) {
 		console.error('An error occurred while initializing the backend server');
 		console.error('An error occurred while initializing the backend server');
 		console.error(err);
 		console.error(err);
+		process.exit();
 	} else {
 	} else {
 		console.info('Backend server has been successfully started');
 		console.info('Backend server has been successfully started');
 	}
 	}

+ 69 - 9
backend/logic/actions/apis.js

@@ -1,8 +1,11 @@
 'use strict';
 'use strict';
 
 
-const request = require('request'),
-	  config  = require('config'),
-		utils = require('../utils');
+const 	request = require('request'),
+		config  = require('config'),
+		async 	= require('async'),
+		utils 	= require('../utils'),
+		logger 	= require('../logger'),
+		hooks 	= require('./hooks');
 
 
 module.exports = {
 module.exports = {
 
 
@@ -15,7 +18,6 @@ module.exports = {
 	 * @return {{ status: String, data: Object }}
 	 * @return {{ status: String, data: Object }}
 	 */
 	 */
 	searchYoutube: (session, query, cb) => {
 	searchYoutube: (session, query, cb) => {
-
 		const params = [
 		const params = [
 			'part=snippet',
 			'part=snippet',
 			`q=${encodeURIComponent(query)}`,
 			`q=${encodeURIComponent(query)}`,
@@ -24,22 +26,80 @@ module.exports = {
 			'maxResults=15'
 			'maxResults=15'
 		].join('&');
 		].join('&');
 
 
-		request(`https://www.googleapis.com/youtube/v3/search?${params}`, (err, res, body) => {
+		async.waterfall([
+			(next) => {
+				request(`https://www.googleapis.com/youtube/v3/search?${params}`, next);
+			},
 
 
+			(res, body, next) => {
+				next(null, JSON.parse(body));
+			}
+		], (err, data) => {
 			if (err) {
 			if (err) {
-				console.error(err);
-				return cb({ status: 'error', message: 'Failed to search youtube with the requested query' });
+				err = utils.getError(err);
+				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
+				return cb({status: 'failure', message: err});
 			}
 			}
-
-			cb({ status: 'success', data: JSON.parse(body) });
+			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, userId) => {
+		async.waterfall([
+			(next) => {
+				utils.getSongsFromSpotify(title, artist, next);
+			}
+		], (songs) => {
+			logger.success('APIS_GET_SPOTIFY_SONGS', `User "${userId}" got Spotify songs for title "${title}" successfully.`);
+			cb({status: 'success', songs: songs});
+		});
+	}),
+
+	/**
+	 * Joins a room
+	 *
+	 * @param session
+	 * @param page - the room to join
+	 * @param cb
+	 */
 	joinRoom: (session, page, cb) => {
 	joinRoom: (session, page, cb) => {
 		if (page === 'home') {
 		if (page === 'home') {
 			utils.socketJoinRoom(session.socketId, page);
 			utils.socketJoinRoom(session.socketId, page);
 		}
 		}
 		cb({});
 		cb({});
+	},
+
+	/**
+	 * 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') {
+			utils.socketJoinRoom(session.socketId, `admin.${page}`);
+		}
+		cb({});
+	}),
+
+	/**
+	 * Returns current date
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	ping: (session, cb) => {
+		cb({date: Date.now()});
 	}
 	}
 
 
 };
 };

+ 26 - 8
backend/logic/actions/hooks/adminRequired.js

@@ -1,19 +1,37 @@
 const cache = require('../../cache');
 const cache = require('../../cache');
 const db = require('../../db');
 const db = require('../../db');
+const utils = require('../../utils');
+const logger = require('../../logger');
+const async = require('async');
 
 
 module.exports = function(next) {
 module.exports = function(next) {
 	return function(session) {
 	return function(session) {
 		let args = [];
 		let args = [];
 		for (let prop in arguments) args.push(arguments[prop]);
 		for (let prop in arguments) args.push(arguments[prop]);
 		let cb = args[args.length - 1];
 		let cb = args[args.length - 1];
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session || !session.userId) return cb({ status: 'failure', message: 'Login required.' });
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (err || !user) return cb({ status: 'failure', message: 'Login required.' });
-				if (user.role !== 'admin') return cb({ status: 'failure', message: 'Admin required.' });
-				args.push(session.userId);
-				next.apply(null, args);
-			});
+		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();
+			}
+		], (err) => {
+			if (err) {
+				err = 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.`);
+			args.push(session.userId);
+			next.apply(null, args);
 		});
 		});
 	}
 	}
 };
 };

+ 19 - 2
backend/logic/actions/hooks/loginRequired.js

@@ -1,12 +1,29 @@
 const cache = require('../../cache');
 const cache = require('../../cache');
+const utils = require('../../utils');
+const logger = require('../../logger');
+const async = require('async');
 
 
 module.exports = function(next) {
 module.exports = function(next) {
 	return function(session) {
 	return function(session) {
 		let args = [];
 		let args = [];
 		for (let prop in arguments) args.push(arguments[prop]);
 		for (let prop in arguments) args.push(arguments[prop]);
 		let cb = args[args.length - 1];
 		let cb = args[args.length - 1];
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session || !session.userId) return cb({ status: 'failure', message: 'Login required.' });
+		async.waterfall([
+			(next) => {
+				cache.hget('sessions', session.sessionId, next);
+			},
+			(session, next) => {
+				if (!session || !session.userId) return next('Login required.');
+				this.session = session;
+				next();
+			}
+		], (err) => {
+			if (err) {
+				err = 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.`);
 			args.push(session.userId);
 			args.push(session.userId);
 			next.apply(null, args);
 			next.apply(null, args);
 		});
 		});

+ 31 - 18
backend/logic/actions/hooks/ownerRequired.js

@@ -1,5 +1,8 @@
 const cache = require('../../cache');
 const cache = require('../../cache');
 const db = require('../../db');
 const db = require('../../db');
+const utils = require('../../utils');
+const logger = require('../../logger');
+const async = require('async');
 const stations = require('../../stations');
 const stations = require('../../stations');
 
 
 module.exports = function(next) {
 module.exports = function(next) {
@@ -7,24 +10,34 @@ module.exports = function(next) {
 		let args = [];
 		let args = [];
 		for (let prop in arguments) args.push(arguments[prop]);
 		for (let prop in arguments) args.push(arguments[prop]);
 		let cb = args[args.length - 1];
 		let cb = args[args.length - 1];
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session || !session.userId) return cb({ status: 'failure', message: 'Login required.' });
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (err || !user) return cb({ status: 'failure', message: 'Login required.' });
-				if (user.role === 'admin') func();
-				else {
-					stations.getStation(stationId, (err, station) => {
-						if (err || !station) return cb({ status: 'failure', message: 'Something went wrong when getting the station.' });
-						else if (station.type === 'community' && station.owner === session.userId) func();
-						else return cb({ status: 'failure', message: 'Invalid permissions.' });
-					});
-				}
-
-				function func() {
-					args.push(session.userId);
-					next.apply(null, args);
-				}
-			});
+		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.');
+			}
+		], (err) => {
+			if (err !== true) {
+				err = 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}"`);
+			args.push(session.userId);
+			next.apply(null, args);
 		});
 		});
 	}
 	}
 };
 };

+ 141 - 17
backend/logic/actions/news.js

@@ -1,30 +1,154 @@
 'use strict';
 'use strict';
 
 
+const async = require('async');
+
 const db = require('../db');
 const db = require('../db');
+const cache = require('../cache');
+const utils = require('../utils');
+const logger = require('../logger');
+const hooks = require('./hooks');
+
+cache.sub('news.create', news => {
+	utils.socketsFromUser(news.createdBy, 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.sub('news.update', news => {
+	utils.socketsFromUser(news.createdBy, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:admin.news.updated', news);
+		});
+	});
+});
 
 
 module.exports = {
 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: (session, cb) => {
 	index: (session, cb) => {
-		db.models.news.find({}).sort({ createdAt: 'desc' }).exec((err, news) => {
-			if (err) throw err;
-			else cb({ status: 'success', data: news });
+		async.waterfall([
+			(next) => {
+				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
+			}
+		], (err, news) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
+				return cb({status: 'failure', message: err});
+			}
+			logger.success("NEWS_INDEX", `Indexing news successful.`);
+			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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	create: hooks.adminRequired((session, data, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				data.createdBy = userId;
+				data.createdAt = Date.now();
+				db.models.news.create(data, next);
+			}
+		], (err, news) => {
+			if (err) {
+				err = 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: (session, cb) => {
 	newest: (session, cb) => {
-		// db.models.news.create({
-		// 	title: 'Beta',
-		// 	description: 'Remember to let us know in Discord if you notice anything odd!',
-		// 	upcoming: ['Private Playlists', 'Christmas Magic', 'Reports'],
-		// 	bugs: ['Mobile Responsiveness',	'Station Name Overflow'],
-		// 	improvements: ['No more Meteor Glitches!'],
-		// 	createdAt: Date.now(),
-		// 	createdBy: 'Jonathan (Musare Lead Developer)'
-		// });
-		db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec((err, news) => {
-			if (err) throw err;
-			else cb({ status: 'success', data: news });
-		});
-	}
+		async.waterfall([
+			(next) => {
+				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
+			}
+		], (err, news) => {
+			if (err) {
+				err = 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.`);
+			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((session, news, cb, userId) => {
+		db.models.news.remove({ _id: news._id }, err => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${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 "${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, userId) => {
+		db.models.news.update({ _id }, news, { upsert: true }, err => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${userId}". "${err}"`);
+				return cb({ 'status': 'failure', 'message': err });
+			} else {
+				cache.pub('news.update', news);
+				logger.success("NEWS_UPDATE", `Updating news "${_id}" successful for user "${userId}".`);
+				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
+			}
+		});
+	}),
 
 
 };
 };

+ 385 - 117
backend/logic/actions/playlists.js

@@ -4,6 +4,7 @@ const db = require('../db');
 const io = require('../io');
 const io = require('../io');
 const cache = require('../cache');
 const cache = require('../cache');
 const utils = require('../utils');
 const utils = require('../utils');
+const logger = require('../logger');
 const hooks = require('./hooks');
 const hooks = require('./hooks');
 const async = require('async');
 const async = require('async');
 const playlists = require('../playlists');
 const playlists = require('../playlists');
@@ -29,10 +30,26 @@ cache.sub('playlist.delete', res => {
 	});
 	});
 });
 });
 
 
+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.sub('playlist.moveSongToBottom', res => {
+	utils.socketsFromUser(res.userId, (sockets) => {
+		sockets.forEach((socket) => {
+			socket.emit('event:playlist.moveSongToBottom', {playlistId: res.playlistId, songId: res.songId});
+		});
+	});
+});
+
 cache.sub('playlist.addSong', res => {
 cache.sub('playlist.addSong', res => {
 	utils.socketsFromUser(res.userId, (sockets) => {
 	utils.socketsFromUser(res.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
-			socket.emit('event:playlist.addSong', {playlistId: res.playlistId, song: res.song});
+			socket.emit('event:playlist.addSong', { playlistId: res.playlistId, song: res.song });
 		});
 		});
 	});
 	});
 });
 });
@@ -40,7 +57,7 @@ cache.sub('playlist.addSong', res => {
 cache.sub('playlist.removeSong', res => {
 cache.sub('playlist.removeSong', res => {
 	utils.socketsFromUser(res.userId, (sockets) => {
 	utils.socketsFromUser(res.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
-			socket.emit('event:playlist.removeSong', {playlistId: res.playlistId, songId: res.songId});
+			socket.emit('event:playlist.removeSong', { playlistId: res.playlistId, songId: res.songId });
 		});
 		});
 	});
 	});
 });
 });
@@ -48,16 +65,64 @@ cache.sub('playlist.removeSong', res => {
 cache.sub('playlist.updateDisplayName', res => {
 cache.sub('playlist.updateDisplayName', res => {
 	utils.socketsFromUser(res.userId, (sockets) => {
 	utils.socketsFromUser(res.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
-			socket.emit('event:playlist.updateDisplayName', {playlistId: res.playlistId, displayName: res.displayName});
+			socket.emit('event:playlist.updateDisplayName', { playlistId: res.playlistId, displayName: res.displayName });
 		});
 		});
 	});
 	});
 });
 });
 
 
 let lib = {
 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	getFirstSong: hooks.loginRequired((session, playlistId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
+
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
+				next(null, playlist.songs[0]);
+			}
+		], (err, song) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_GET_FIRST_SONG", `Successfully got the first song of playlist "${playlistId}" for user "${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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	indexForUser: hooks.loginRequired((session, cb, userId) => {
 	indexForUser: hooks.loginRequired((session, cb, userId) => {
-		db.models.playlist.find({ createdBy: userId }, (err, playlists) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when getting the playlists.'});;
+		async.waterfall([
+			(next) => {
+				db.models.playlist.find({ createdBy: userId }, next);
+			}
+		], (err, playlists) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${userId}" failed. "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${userId}".`);
 			cb({
 			cb({
 				status: 'success',
 				status: 'success',
 				data: playlists
 				data: playlists
@@ -65,6 +130,14 @@ let lib = {
 		});
 		});
 	}),
 	}),
 
 
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	create: hooks.loginRequired((session, data, cb, userId) => {
 	create: hooks.loginRequired((session, data, cb, userId) => {
 		async.waterfall([
 		async.waterfall([
 
 
@@ -73,7 +146,7 @@ let lib = {
 			},
 			},
 
 
 			(next) => {
 			(next) => {
-				const { name, displayName, songs } = data;
+				const { displayName, songs } = data;
 				db.models.playlist.create({
 				db.models.playlist.create({
 					displayName,
 					displayName,
 					songs,
 					songs,
@@ -83,16 +156,43 @@ let lib = {
 			}
 			}
 
 
 		], (err, playlist) => {
 		], (err, playlist) => {
-			if (err) return cb({ 'status': 'failure', 'message': 'Something went wrong'});
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
 			cache.pub('playlist.create', playlist._id);
 			cache.pub('playlist.create', playlist._id);
-			return cb({ 'status': 'success', 'message': 'Successfully created playlist' });
+			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${userId}".`);
+			cb({ 'status': 'success', 'message': 'Successfully created playlist' });
 		});
 		});
 	}),
 	}),
 
 
-	getPlaylist: hooks.loginRequired((session, id, cb, userId) => {
-		playlists.getPlaylist(id, (err, playlist) => {
-			if (err || playlist.createdBy !== userId) return cb({status: 'success', message: 'Playlist not found.'});
-			if (err == null) return cb({
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	getPlaylist: hooks.loginRequired((session, playlistId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
+
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				next(null, playlist);
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_GET", `Successfully got private playlist "${playlistId}" for user "${userId}".`);
+			cb({
 				status: 'success',
 				status: 'success',
 				data: playlist
 				data: playlist
 			});
 			});
@@ -100,30 +200,57 @@ let lib = {
 	}),
 	}),
 
 
 	//TODO Remove this
 	//TODO Remove this
-	update: hooks.loginRequired((session, _id, playlist, cb, userId) => {
-		db.models.playlist.update({ _id, createdBy: userId }, playlist, (err, data) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong.' });
-			playlists.updatePlaylist(_id, (err) => {
-				if (err) return cb({ status: 'failure', message: 'Something went wrong.' });
-				return cb({ status: 'success', message: 'Playlist has been successfully updated', data });
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	update: hooks.loginRequired((session, playlistId, playlist, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.playlist.update({ _id: playlistId, createdBy: userId }, playlist, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next)
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_UPDATE", `Successfully updated private playlist "${playlistId}" for user "${userId}".`);
+			cb({
+				status: 'success',
+				data: playlist
 			});
 			});
 		});
 		});
 	}),
 	}),
 
 
+	/**
+	 * Adds a song to a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	addSongToPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
 	addSongToPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.getPlaylist(playlistId, (err, playlist) => {
 				playlists.getPlaylist(playlistId, (err, playlist) => {
-					if (err || !playlist || playlist.createdBy !== userId) return next('Something went wrong when trying to get the playlist.');
+					if (err || !playlist || playlist.createdBy !== userId) return next('Something went wrong when trying to get the playlist');
 
 
-					let found = false;
-					playlist.songs.forEach((song) => {
-						if (songId === song._id) {
-							found = true;
-						}
-					});
-					if (found) return next('That song is already in the playlist.');
-					return next(null);
+					async.each(playlist.songs, (song, next) => {
+						if (song.songId === songId) return next('That song is already in the playlist');
+						next();
+					}, next);
 				});
 				});
 			},
 			},
 			(next) => {
 			(next) => {
@@ -134,7 +261,8 @@ let lib = {
 						});
 						});
 					} else {
 					} else {
 						next(null, {
 						next(null, {
-							_id: songId,
+							_id: song._id,
+							songId: songId,
 							title: song.title,
 							title: song.title,
 							duration: song.duration
 							duration: song.duration
 						});
 						});
@@ -142,12 +270,8 @@ let lib = {
 				});
 				});
 			},
 			},
 			(newSong, next) => {
 			(newSong, next) => {
-				db.models.playlist.update({_id: playlistId}, {$push: {songs: newSong}}, (err) => {
-					if (err) {
-						console.error(err);
-						return next('Failed to add song to playlist');
-					}
-
+				db.models.playlist.update({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
+					if (err) return next(err);
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 						next(err, playlist, newSong);
 						next(err, playlist, newSong);
 					});
 					});
@@ -155,15 +279,27 @@ let lib = {
 			}
 			}
 		],
 		],
 		(err, playlist, newSong) => {
 		(err, playlist, newSong) => {
-			if (err) return cb({ status: 'error', message: err });
-			else if (playlist.songs) {
-				cache.pub('playlist.addSong', {playlistId: playlist._id, song: newSong, userId: userId});
-				return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
 			}
 			}
+			logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: userId });
+			return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
 		});
 		});
 	}),
 	}),
-	
-	/*addSetToPlaylist: hooks.loginRequired((session, url, playlistId, cb, userId) => {
+
+	/**
+	 * 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 {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	addSetToPlaylist: hooks.loginRequired((session, url, playlistId, cb, userId) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				utils.getPlaylistFromYouTube(url, songs => {
 				utils.getPlaylistFromYouTube(url, songs => {
@@ -171,111 +307,243 @@ let lib = {
 				});
 				});
 			},
 			},
 			(songs, next) => {
 			(songs, next) => {
+				let processed = 0;
+				function checkDone() {
+					if (processed === songs.length) next();
+				}
 				for (let s = 0; s < songs.length; s++) {
 				for (let s = 0; s < songs.length; s++) {
-					lib.addSongToPlaylist(session, songs[s].contentDetails.videoId, playlistId, (res) => {})();
+					lib.addSongToPlaylist(session, songs[s].contentDetails.videoId, playlistId, () => {
+						processed++;
+						checkDone();
+					});
 				}
 				}
-				next(null);
 			},
 			},
 			(next) => {
 			(next) => {
-				playlists.getPlaylist(playlistId, (err, playlist) => {
-					if (err || !playlist || playlist.createdBy !== userId) return next('Something went wrong while trying to get the playlist.');
+				playlists.getPlaylist(playlistId, next);
+			},
 
 
-					next(null, playlist);
-				});
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
+				next(null, playlist);
 			}
 			}
-		],
-		(err, playlist) => {
-			if (err) return cb({ status: 'failure', message: err });
-			else if (playlist.songs) return cb({ status: 'success', message: 'Playlist has been successfully added', data: playlist.songs });
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${userId}".`);
+			cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
 		});
 		});
-	}),*/
-
+	}),
 
 
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
 	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
-		playlists.getPlaylist(playlistId, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the 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();
+			},
 
 
-			for (let z = 0; z < playlist.songs.length; z++) {
-				if (playlist.songs[z]._id == songId) playlist.songs.shift(playlist.songs[z]);
-			}
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
 
 
-			db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
-				if (err) {
-					console.error(err);
-					return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-				}
-				playlists.updatePlaylist(playlistId, (err, playlist) => {
-					cache.pub('playlist.removeSong', {playlistId: playlist._id, songId: songId, userId: userId});
-					return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
-				});
-			});
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.removeSong', {playlistId: playlist._id, songId: songId, userId: userId});
+			return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
 		});
 		});
 	}),
 	}),
 
 
-	updateDisplayName: hooks.loginRequired((session, _id, displayName, cb, userId) => {
-		db.models.playlist.update({ _id, createdBy: userId }, { displayName }, (err, res) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-			playlists.updatePlaylist(_id, (err) => {
-				if (err) return cb({ status: 'failure', message: err});
-				cache.pub('playlist.updateDisplayName', {playlistId: _id, displayName: displayName, userId: userId});
-				return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-			})
-		});
-	}),/*
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.playlist.update({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, {runValidators: true}, next);
+			},
 
 
-	promoteSong: hooks.loginRequired((session, playlistId, fromIndex, cb, userId) => {
-		db.models.playlist.findOne({ _id: playlistId }, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist.'});
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${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 "${userId}".`);
+			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: userId});
+			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
+		});
+	}),
 
 
-			let song = playlist.songs[fromIndex];
-			playlist.songs.splice(fromIndex, 1);
-			playlist.songs.splice((fromIndex + 1), 0, song);
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	moveSongToTop: hooks.loginRequired((session, playlistId, songId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
 
 
-			playlist.save(err => {
-				if (err) {
-					console.error(err);
-					return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-				}
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== 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');
+				});
+			},
 
 
-				playlists.updatePlaylist(playlistId, (err) => {
-					if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-					return cb({ status: 'success', data: playlist.songs });
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+					if (err) return next(err);
+					return next(null, song);
 				});
 				});
+			},
 
 
-			});
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {
+					$push: {
+						songs: {
+							$each: [song],
+							$position: 0
+						}
+					}
+				}, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${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 "${userId}".`);
+			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: userId});
+			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 		});
 	}),
 	}),
 
 
-	demoteSong: hooks.loginRequired((session, playlistId, fromIndex, cb, userId) => {
-		db.models.playlist.findOne({ _id: playlistId }, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist.'});
-
-			let song = playlist.songs[fromIndex];
-			playlist.songs.splice(fromIndex, 1);
-			playlist.songs.splice((fromIndex - 1), 0, song);
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	moveSongToBottom: hooks.loginRequired((session, playlistId, songId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
 
 
-			playlist.save(err => {
-				if (err) {
-					console.error(err);
-					return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-				}
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== 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');
+				});
+			},
 
 
-				playlists.updatePlaylist(playlistId, (err) => {
-					if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-					return cb({ status: 'success', data: playlist.songs });
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+					if (err) return next(err);
+					return next(null, song);
 				});
 				});
+			},
 
 
-			});
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {
+					$push: {
+						songs: song
+					}
+				}, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${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 "${userId}".`);
+			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: userId});
+			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 		});
-	}),*/
-
-	remove: hooks.loginRequired((session, _id, cb, userId) => {
-		db.models.playlist.remove({ _id, createdBy: userId }).exec(err => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when removing the playlist.'});
-			cache.hdel('playlists', _id, () => {
-				cache.pub('playlist.delete', {userId: userId, playlistId: _id});
-				return cb({ status: 'success', message: 'Playlist successfully removed' });
-			});
+	}),
+
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	remove: hooks.loginRequired((session, playlistId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.deletePlaylist(playlistId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			}
+			logger.success("PLAYLIST_REMOVE", `Successfully removed private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.delete', {userId: userId, playlistId});
+			return cb({ status: 'success', message: 'Playlist successfully removed' });
 		});
 		});
 	})
 	})
 
 

+ 140 - 56
backend/logic/actions/queueSongs.js

@@ -2,6 +2,7 @@
 
 
 const db = require('../db');
 const db = require('../db');
 const utils = require('../utils');
 const utils = require('../utils');
+const logger = require('../logger');
 const notifications = require('../notifications');
 const notifications = require('../notifications');
 const cache = require('../cache');
 const cache = require('../cache');
 const async = require('async');
 const async = require('async');
@@ -9,77 +10,152 @@ const config = require('config');
 const request = require('request');
 const request = require('request');
 const hooks = require('./hooks');
 const hooks = require('./hooks');
 
 
-notifications.subscribe('queue.newSong', songId => {
-	utils.emitToRoom('admin.queue', 'event:song.new', { songId });
+cache.sub('queue.newSong', songId => {
+	db.models.queueSong.findOne({songId}, (err, song) => {
+		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
+	});
 });
 });
 
 
-notifications.subscribe('queue.removedSong', songId => {
-	utils.emitToRoom('admin.queue', 'event:song.removed', { songId });
+cache.sub('queue.removedSong', songId => {
+	utils.emitToRoom('admin.queue', 'event:admin.queueSong.removed', songId);
 });
 });
 
 
-notifications.subscribe('queue.updatedSong', songId => {
-	//TODO Retrieve new Song object
-	utils.emitToRoom('admin.queue', 'event:song.updated', { songId });
+cache.sub('queue.update', songId => {
+	db.models.queueSong.findOne({songId}, (err, song) => {
+		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
+	});
 });
 });
 
 
 module.exports = {
 module.exports = {
 
 
+	/**
+	 * Gets all queuesongs
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	index: hooks.adminRequired((session, cb) => {
 	index: hooks.adminRequired((session, cb) => {
-		db.models.queueSong.find({}, (err, songs) => {
+		async.waterfall([
+			(next) => {
+				db.models.queueSong.find({}, next);
+			}
+		], (err, songs) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("QUEUE_INDEX", `Indexing queuesongs failed. "${err}"`);
+				return cb({status: 'failure', message: err});
+			} else {
+				module.exports.getSet(session, 1, result => {
+					logger.success("QUEUE_INDEX", `Indexing queuesongs successful.`);
+					return cb({
+						songs: result,
+						maxLength: songs.length
+					});
+				});
+			}
+		});
+	}),
+
+	/**
+	 * Gets a set of queue songs
+	 *
+	 * @param session
+	 * @param set - the set number to return
+	 * @param cb
+	 */
+	getSet: hooks.adminRequired((session, set, cb) => {
+		db.models.queueSong.find({}).limit(50 * set).exec((err, songs) => {
 			if (err) throw err;
 			if (err) throw err;
-			cb(songs);
+			cb(songs.splice(Math.max(songs.length - 50, 0)));
 		});
 		});
 	}),
 	}),
 
 
-	update: hooks.adminRequired((session, _id, updatedSong, cb) => {
-		//TODO Check if id and updatedSong is valid
-		db.models.queueSong.findOne({ _id }, (err, currentSong) => {
-			if (err) console.error(err);
-			// TODO Check if new id, if any, is already in use in queue or on rotation
-			let updated = false;
-			for (let prop in updatedSong) if (updatedSong[prop] !== currentSong[prop]) currentSong[prop] = updatedSong[prop]; updated = true;
-			if (!updated) return cb({ status: 'error', message: 'No properties changed' });
-			else {
-				currentSong.save(err => {
-					if (err) console.error(err);
-					return cb({ status: 'success', message: 'Successfully updated the queued song' });
-				});
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	update: hooks.adminRequired((session, songId, updatedSong, cb, userId) => {
+		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.update({_id: songId}, {$set}, {runValidators: true}, next);
 			}
 			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				return cb({status: 'failure', message: err});
+			}
+			cache.pub('queue.update', songId);
+			logger.success("QUEUE_UPDATE", `User "${userId}" successfully update queuesong "${songId}".`);
+			return cb({status: 'success', message: 'Successfully updated song.'});
 		});
 		});
 	}),
 	}),
 
 
-	remove: hooks.adminRequired((session, songId, cb) => {
-		db.models.queueSong.remove({ _id: songId }, (err, res) => {
-			if (err) return cb({ status: 'failure', message: err.message });
-			//TODO Pub/sub for (queue)songs on admin pages.
-			cb({ status: 'success', message: 'Song was removed successfully' });
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	remove: hooks.adminRequired((session, songId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.queueSong.remove({_id: songId}, next);
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				return cb({status: 'failure', message: err});
+			}
+			cache.pub('queue.removedSong', songId);
+			logger.success("QUEUE_REMOVE", `User "${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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	add: hooks.loginRequired((session, songId, cb, userId) => {
 	add: hooks.loginRequired((session, songId, cb, userId) => {
-		//TODO Check if id is valid
-
 		let requestedAt = Date.now();
 		let requestedAt = Date.now();
 
 
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.queueSong.findOne({_id: songId}, (err, song) => {
-					if (err) return next('Something went wrong while getting the song from the Database.');
-					if (song) return next('This song is already in the queue.');
-					next();
-				});
+				db.models.queueSong.findOne({songId}, next);
 			},
 			},
 
 
-			(next) => {
-				db.models.song.findOne({_id: songId}, (err, song) => {
-					if (err) return next('Something went wrong while getting the song from the Database.');
-					if (song) return next('This song has already been added.');
-					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
 			// Get YouTube data from id
-			(next) => {
+			(song, next) => {
+				if (song) return next('This song has already been added.');
+				//TODO Add err object as first param of callback
+				console.log(52, songId);
 				utils.getSongFromYouTube(songId, (song) => {
 				utils.getSongFromYouTube(songId, (song) => {
 					song.artists = [];
 					song.artists = [];
 					song.genres = [];
 					song.genres = [];
@@ -92,32 +168,40 @@ module.exports = {
 				});
 				});
 			},
 			},
 			(newSong, next) => {
 			(newSong, next) => {
+				//TODO Add err object as first param of callback
 				utils.getSongFromSpotify(newSong, (song) => {
 				utils.getSongFromSpotify(newSong, (song) => {
 					next(null, song);
 					next(null, song);
 				});
 				});
 			},
 			},
 			(newSong, next) => {
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
 				const song = new db.models.queueSong(newSong);
-
-				// check if song already exists
-
-				song.save(err => {
-
-					if (err) {
-						console.error(err);
-						return next('Failed to add song to database');
+				song.save((err, song) => {
+					console.log(err);
+					if (err) return next(err);
+					next(null, song);
+				});
+			},
+			(newSong, next) => {
+				db.models.user.findOne({ _id: 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);
+						});
 					}
 					}
-
-					//stations.getStation(station).playlist.push(newSong);
-					next(null, newSong);
 				});
 				});
 			}
 			}
-		],
-		(err, newSong) => {
-			if (err) return cb({ status: 'error', message: err });
+		], (err, newSong) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				return cb({status: 'failure', message: err});
+			}
 			cache.pub('queue.newSong', newSong._id);
 			cache.pub('queue.newSong', newSong._id);
+			logger.success("QUEUE_ADD", `User "${userId}" successfully added queuesong "${songId}".`);
 			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
 			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
 		});
 		});
 	})
 	})
-
 };
 };

+ 203 - 65
backend/logic/actions/reports.js

@@ -3,103 +3,241 @@
 const async = require('async');
 const async = require('async');
 
 
 const db = require('../db');
 const db = require('../db');
+const cache = require('../cache');
+const utils = require('../utils');
+const logger = require('../logger');
 const hooks = require('./hooks');
 const hooks = require('./hooks');
+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'
+		]
+	}
+];
+
+cache.sub('report.resolve', reportId => {
+	utils.emitToRoom('admin.reports', 'event:admin.report.resolved', reportId);
+});
+
+cache.sub('report.create', report => {
+	utils.emitToRoom('admin.reports', 'event:admin.report.created', report);
+});
 
 
 module.exports = {
 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((session, cb) => {
 	index: hooks.adminRequired((session, cb) => {
-		db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec((err, reports) => {
-			if (err) console.error(err);
+		async.waterfall([
+			(next) => {
+				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
+			}
+		], (err, reports) => {
+			if (err) {
+				err = 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 });
 			cb({ status: 'success', data: reports });
 		});
 		});
 	}),
 	}),
 
 
-	resolve: hooks.adminRequired((session, _id, cb) => {
-		db.models.report.findOne({ _id }).sort({ released: 'desc' }).exec((err, report) => {
-			if (err) console.error(err);
-			report.resolved = true;
-			report.save(err => {
-				if (err) console.error(err);
-				else cb({ status: 'success', message: 'Successfully resolved 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((session, reportId, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.report.findOne({ _id: reportId }).exec(next);
+			}
+		], (err, report) => {
+			if (err) {
+				err = 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 });
 		});
 		});
 	}),
 	}),
 
 
-	create: hooks.loginRequired((session, data, cb) => {
+	/**
+	 * Gets all reports for a songId
+	 *
+	 * @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([
 		async.waterfall([
+			(next) => {
+				db.models.report.find({ 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);
+			}
+		], (err, data) => {
+			if (err) {
+				err = 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	resolve: hooks.adminRequired((session, reportId, cb, userId) => {
+		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.report.find({ createdBy: data.createdBy, createdAt: data.createdAt }).exec((err, report) => {
-					if (err) console.error(err);
-					if (report) return cb({ status: 'failure', message: 'Report already exists' });
+				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();
 					else next();
 				});
 				});
-			},
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${userId}". "${err}"`);
+				return cb({ 'status': 'failure', 'message': err});
+			} else {
+				cache.pub('report.resolve', reportId);
+				logger.success("REPORTS_RESOLVE", `User "${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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	create: hooks.loginRequired((session, data, cb, userId) => {
+		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				let issues = [
-					{
-						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'
-						]
-					}
-				];
+				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.');
+
 
 
 				for (let z = 0; z < data.issues.length; z++) {
 				for (let z = 0; z < data.issues.length; z++) {
-					if (issues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
-						for (let r = 0; r < issues.length; r++) {
-							if (issues[r].reasons.every(reason => data.issues[z].reasons.indexOf(reason) < -1)) {
-								return cb({ 'status': 'failure', 'message': 'Invalid data' });
+					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' });
+					} 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();
 			},
 			},
 
 
 			(next) => {
 			(next) => {
+				data.createdBy = userId;
+				data.createdAt = Date.now();
 				db.models.report.create(data, next);
 				db.models.report.create(data, next);
 			}
 			}
 
 
-		], err => {
-			if (err) return cb({ 'status': 'failure', 'message': 'Something went wrong'});
-			return cb({ 'status': 'success', 'message': 'Successfully created report' });
+		], (err, report) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("REPORTS_CREATE", `Creating report for "${data.songId}" failed by user "${userId}". "${err}"`);
+				return cb({ 'status': 'failure', 'message': err });
+			} else {
+				cache.pub('report.create', report);
+				logger.success("REPORTS_CREATE", `User "${userId}" created report for "${data.songId}".`);
+				return cb({ 'status': 'success', 'message': 'Successfully created report' });
+			}
 		});
 		});
 	})
 	})
 
 
-};
+};

+ 257 - 82
backend/logic/actions/songs.js

@@ -4,12 +4,30 @@ const db = require('../db');
 const io = require('../io');
 const io = require('../io');
 const songs = require('../songs');
 const songs = require('../songs');
 const cache = require('../cache');
 const cache = require('../cache');
+const async = require('async');
 const utils = require('../utils');
 const utils = require('../utils');
+const logger = require('../logger');
 const hooks = require('./hooks');
 const hooks = require('./hooks');
 const queueSongs = require('./queueSongs');
 const queueSongs = require('./queueSongs');
 
 
+cache.sub('song.removed', songId => {
+	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
+});
+
+cache.sub('song.added', songId => {
+	db.models.song.findOne({songId}, (err, song) => {
+		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
+	});
+});
+
+cache.sub('song.updated', songId => {
+	db.models.song.findOne({songId}, (err, song) => {
+		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
+	});
+});
+
 cache.sub('song.like', (data) => {
 cache.sub('song.like', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.like', {songId: data.songId, undisliked: data.undisliked});
+	utils.emitToRoom(`song.${data.songId}`, 'event:song.like', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
 	utils.socketsFromUser(data.userId, (sockets) => {
 	utils.socketsFromUser(data.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: true, disliked: false});
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: true, disliked: false});
@@ -18,7 +36,7 @@ cache.sub('song.like', (data) => {
 });
 });
 
 
 cache.sub('song.dislike', (data) => {
 cache.sub('song.dislike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.dislike', {songId: data.songId, unliked: data.unliked});
+	utils.emitToRoom(`song.${data.songId}`, 'event:song.dislike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
 	utils.socketsFromUser(data.userId, (sockets) => {
 	utils.socketsFromUser(data.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: true});
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: true});
@@ -27,7 +45,7 @@ cache.sub('song.dislike', (data) => {
 });
 });
 
 
 cache.sub('song.unlike', (data) => {
 cache.sub('song.unlike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.unlike', {songId: data.songId});
+	utils.emitToRoom(`song.${data.songId}`, 'event:song.unlike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
 	utils.socketsFromUser(data.userId, (sockets) => {
 	utils.socketsFromUser(data.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
@@ -36,7 +54,7 @@ cache.sub('song.unlike', (data) => {
 });
 });
 
 
 cache.sub('song.undislike', (data) => {
 cache.sub('song.undislike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.undislike', {songId: data.songId});
+	utils.emitToRoom(`song.${data.songId}`, 'event:song.undislike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
 	utils.socketsFromUser(data.userId, (sockets) => {
 	utils.socketsFromUser(data.userId, (sockets) => {
 		sockets.forEach((socket) => {
 		sockets.forEach((socket) => {
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
 			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
@@ -46,138 +64,295 @@ cache.sub('song.undislike', (data) => {
 
 
 module.exports = {
 module.exports = {
 
 
-	index: (session, cb) => {
-		db.models.song.find({}, (err, songs) => {
-			if (err) throw err;
-			cb(songs);
+	/**
+	 * Returns the length of the songs list
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	length: hooks.adminRequired((session, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.song.count({}, next);
+			}
+		], (err, count) => {
+			if (err) {
+				err = 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({}).limit(15 * set).exec(next);
+			}
+		], (err, songs) => {
+			if (err) {
+				err = 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.splice(Math.max(songs.length - 15, 0)));
+		});
+	}),
 
 
+	/**
+	 * 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) => {
 	update: hooks.adminRequired((session, songId, song, cb) => {
-		db.models.song.update({ _id: songId }, song, { upsert: true }, (err, updatedSong) => {
-			if (err) console.error(err);
-			songs.updateSong(songId, (err, song) => {
-				if (err) console.error(err);
-				cb({ status: 'success', message: 'Song has been successfully updated', data: song });
-			});
+		async.waterfall([
+			(next) => {
+				db.models.song.update({_id: songId}, song, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				songs.updateSong(songId, next);
+			}
+		], (err, song) => {
+			if (err) {
+				err = 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) => {
 	remove: hooks.adminRequired((session, songId, cb) => {
-		db.models.song.remove({ _id: songId });
+		async.waterfall([
+			(next) => {
+				db.models.song.remove({_id: songId}, next);
+			},
+
+			(res, next) => {//TODO Check if res gets returned from above
+				cache.hdel('songs', songId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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
+	 * @param userId
+	 */
 	add: hooks.adminRequired((session, song, cb, userId) => {
 	add: hooks.adminRequired((session, song, cb, userId) => {
-		queueSongs.remove(session, song._id, () => {
-			const newSong = new db.models.song(song);
-			db.models.song.findOne({ _id: song._id }, (err, existingSong) => {
-				if (err) console.error(err);
+		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 = userId;
 				newSong.acceptedBy = userId;
 				newSong.acceptedAt = Date.now();
 				newSong.acceptedAt = Date.now();
-				if (!existingSong) newSong.save(err => {
-					console.log(err, 1);
-					if (err) console.error(err);
-					else cb({ status: 'success', message: 'Song has been moved from Queue' })
+				newSong.save(next);
+			},
+
+			(next) => {
+				queueSongs.remove(session, song._id, () => {
+					next();
 				});
 				});
-			});
-			//TODO Check if video is in queue and Add the song to the appropriate stations
+			},
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("SONGS_ADD", `User "${userId}" failed to add song. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("SONGS_ADD", `User "${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
+	 * @param userId
+	 */
 	like: hooks.loginRequired((session, songId, cb, userId) => {
 	like: hooks.loginRequired((session, songId, cb, userId) => {
 		db.models.user.findOne({ _id: userId }, (err, user) => {
 		db.models.user.findOne({ _id: userId }, (err, user) => {
 			if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
 			if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-			let dislikes = 0;
-			if (user.disliked.indexOf(songId) !== -1) dislikes = -1;
-			db.models.song.update({ _id: songId }, { $inc: { likes: 1, dislikes: dislikes } }, err => {
+			db.models.user.update({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
 				if (!err) {
 				if (!err) {
-					db.models.user.update({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
-						if (!err) {
-							console.log(JSON.stringify({ songId, userId: userId }));
-							songs.updateSong(songId, (err, song) => {});
-							cache.pub('song.like', JSON.stringify({ songId, userId: session.userId, undisliked: (dislikes === -1) }));
-						} else db.models.song.update({ _id: songId }, { $inc: { likes: -1, dislikes: -dislikes } }, err => {
-							return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
+					db.models.user.count({"liked": songId}, (err, likes) => {
+						if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
+						db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
+							db.models.song.update({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, userId: session.userId, likes: likes, dislikes: dislikes }));
+								return cb({ status: 'success', message: 'You have successfully liked this song.' });
+							});
 						});
 						});
 					});
 					});
-				} else {
-					return cb({ status: 'failure', message: 'Something went wrong while liking 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
+	 * @param userId
+	 */
 	dislike: hooks.loginRequired((session, songId, cb, userId) => {
 	dislike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({_id: userId}, (err, user) => {
+		db.models.user.findOne({ _id: userId }, (err, user) => {
 			if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
 			if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-			let likes = 0;
-			if (user.liked.indexOf(songId) !== -1) likes = -1;
-			db.models.song.update({_id: songId}, {$inc: {likes: likes, dislikes: 1}}, (err) => {
+			db.models.user.update({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
 				if (!err) {
 				if (!err) {
-					db.models.user.update({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, (err) => {
-						if (!err) {
-							songs.updateSong(songId, (err, song) => {});
-							cache.pub('song.dislike', JSON.stringify({songId, userId: userId, unliked: (likes === -1)}));
-						} else db.models.song.update({_id: songId}, {$inc: {likes: -likes, dislikes: -1}}, (err) => {
-							return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+					db.models.user.count({"liked": songId}, (err, likes) => {
+						if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+						db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+							db.models.song.update({songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+								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, 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.' });
-				}
+				} 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
+	 * @param userId
+	 */
 	undislike: hooks.loginRequired((session, songId, cb, userId) => {
 	undislike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({_id: userId}, (err, user) => {
+		db.models.user.findOne({ _id: userId }, (err, user) => {
 			if (user.disliked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not disliked this song.' });
 			if (user.disliked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not disliked this song.' });
-			db.models.song.update({_id: songId}, {$inc: {dislikes: -1}}, (err) => {
+			db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
 				if (!err) {
 				if (!err) {
-					db.models.user.update({_id: userId}, {$pull: {disliked: songId}}, (err) => {
-						if (!err) {
-							songs.updateSong(songId, (err, song) => {});
-							cache.pub('song.undislike', JSON.stringify({songId, userId: userId}));
-						} else db.models.song.update({_id: songId}, {$inc: {dislikes: 1}}, (err) => {
-							return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
+					db.models.user.count({"liked": songId}, (err, likes) => {
+						if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
+						db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
+							db.models.song.update({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, 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.' });
-				}
+				} 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
+	 * @param userId
+	 */
 	unlike: hooks.loginRequired((session, songId, cb, userId) => {
 	unlike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({_id: userId}, (err, user) => {
+		db.models.user.findOne({ _id: userId }, (err, user) => {
 			if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
 			if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-			db.models.song.update({_id: songId}, {$inc: {likes: -1}}, (err) => {
+			db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
 				if (!err) {
 				if (!err) {
-					db.models.user.update({_id: userId}, {$pull: {liked: songId}}, (err) => {
-						if (!err) {
-							songs.updateSong(songId, (err, song) => {});
-							cache.pub('song.unlike', JSON.stringify({songId, userId: userId}));
-						} else db.models.song.update({_id: songId}, {$inc: {likes: 1}}, (err) => {
-							return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+					db.models.user.count({"liked": songId}, (err, likes) => {
+						if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+						db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
+							db.models.song.update({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, 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.' });
-				}
+				} else return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
 			});
 			});
 		});
 		});
 	}),
 	}),
 
 
-	getOwnSongRatings: hooks.loginRequired(function(session, songId, cb, userId) {
+	/**
+	 * Gets user's own song ratings
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 * @param userId
+	 */
+	getOwnSongRatings: hooks.loginRequired((session, songId, cb, userId) => {
 		db.models.user.findOne({_id: userId}, (err, user) => {
 		db.models.user.findOne({_id: userId}, (err, user) => {
-			return cb({
-				status: 'success',
-				songId: songId,
-				liked: (user.liked.indexOf(songId) !== -1),
-				disliked: (user.disliked.indexOf(songId) !== -1)
-			});
+			if (!err && user) {
+				return cb({
+					status: 'success',
+					songId: songId,
+					liked: (user.liked.indexOf(songId) !== -1),
+					disliked: (user.disliked.indexOf(songId) !== -1)
+				});
+			} else {
+				return cb({
+					status: 'failure',
+					message: utils.getError(err)
+				});
+			}
 		});
 		});
 	})
 	})
-
 };
 };

+ 869 - 354
backend/logic/actions/stations.js

@@ -2,16 +2,132 @@
 
 
 const async   = require('async'),
 const async   = require('async'),
 	  request = require('request'),
 	  request = require('request'),
-	  config  = require('config');
+	  config  = require('config'),
+	  _		  =  require('underscore')._;
 
 
 const io = require('../io');
 const io = require('../io');
 const db = require('../db');
 const db = require('../db');
 const cache = require('../cache');
 const cache = require('../cache');
 const notifications = require('../notifications');
 const notifications = require('../notifications');
 const utils = require('../utils');
 const utils = require('../utils');
+const logger = require('../logger');
 const stations = require('../stations');
 const stations = require('../stations');
 const songs = require('../songs');
 const songs = require('../songs');
 const hooks = require('./hooks');
 const hooks = require('./hooks');
+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) {
+		let socket = utils.socketFromSession(socketId);
+		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) => {
+			console.log("Updating count of ", stationId);
+			cache.pub('station.updateUserCount', stationId);
+		});
+
+		stationsUpdated.forEach((stationId) => {
+			console.log("Updating ", stationId);
+			cache.pub('station.updateUsers', stationId);
+		});
+
+		//console.log("Userlist", usersPerStation);
+	});
+}, 3000);
+
+cache.sub('station.updateUsers', stationId => {
+	let list = usersPerStation[stationId] || [];
+	utils.emitToRoom(`station.${stationId}`, "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, (err, station) => {
+		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
+		else {
+			let sockets = 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.sub('station.updatePartyMode', data => {
+	utils.emitToRoom(`station.${data.stationId}`, "event:partyMode.updated", data.partyMode);
+});
+
+cache.sub('privatePlaylist.selected', data => {
+	utils.emitToRoom(`station.${data.stationId}`, "event:privatePlaylist.selected", data.playlistId);
+});
 
 
 cache.sub('station.pause', stationId => {
 cache.sub('station.pause', stationId => {
 	utils.emitToRoom(`station.${stationId}`, "event:stations.pause");
 	utils.emitToRoom(`station.${stationId}`, "event:stations.pause");
@@ -19,15 +135,13 @@ cache.sub('station.pause', stationId => {
 
 
 cache.sub('station.resume', stationId => {
 cache.sub('station.resume', stationId => {
 	stations.getStation(stationId, (err, station) => {
 	stations.getStation(stationId, (err, station) => {
-		utils.emitToRoom(`station.${stationId}`, "event:stations.resume", {timePaused: station.timePaused});
+		utils.emitToRoom(`station.${stationId}`, "event:stations.resume", { timePaused: station.timePaused });
 	});
 	});
 });
 });
 
 
 cache.sub('station.queueUpdate', stationId => {
 cache.sub('station.queueUpdate', stationId => {
 	stations.getStation(stationId, (err, station) => {
 	stations.getStation(stationId, (err, station) => {
-		if (!err) {
-			utils.emitToRoom(`station.${stationId}`, "event:queue.update", station.queue);
-		}
+		if (!err) utils.emitToRoom(`station.${stationId}`, "event:queue.update", station.queue);
 	});
 	});
 });
 });
 
 
@@ -35,28 +149,27 @@ cache.sub('station.voteSkipSong', stationId => {
 	utils.emitToRoom(`station.${stationId}`, "event:song.voteSkipSong");
 	utils.emitToRoom(`station.${stationId}`, "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.sub('station.create', stationId => {
 cache.sub('station.create', stationId => {
 	stations.initializeStation(stationId, (err, station) => {
 	stations.initializeStation(stationId, (err, station) => {
-		console.log("*************", err, station);
-		//TODO Emit to admin station page
-
+		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
 		// TODO If community, check if on whitelist
-		console.log("*************", station.privacy);
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		else {
 		else {
 			let sockets = utils.getRoomSockets('home');
 			let sockets = utils.getRoomSockets('home');
-			console.log("*************", sockets.length);
 			for (let socketId in sockets) {
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
 				let session = sockets[socketId].session;
-				console.log("*************", session);
 				if (session.sessionId) {
 				if (session.sessionId) {
 					cache.hget('sessions', session.sessionId, (err, session) => {
 					cache.hget('sessions', session.sessionId, (err, session) => {
-						console.log("*************", err, session);
 						if (!err && session) {
 						if (!err && session) {
-							console.log("*************");
 							db.models.user.findOne({_id: session.userId}, (err, user) => {
 							db.models.user.findOne({_id: session.userId}, (err, user) => {
-								console.log("*************", err, user.role, station.type, station.owner, session.userId);
 								if (user.role === 'admin') socket.emit("event:stations.created", station);
 								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);
 								else if (station.type === "community" && station.owner === session.userId) socket.emit("event:stations.created", station);
 							});
 							});
@@ -78,196 +191,300 @@ module.exports = {
 	 * @return {{ status: String, stations: Array }}
 	 * @return {{ status: String, stations: Array }}
 	 */
 	 */
 	index: (session, cb) => {
 	index: (session, cb) => {
-		cache.hgetall('stations', (err, stations) => {
+		async.waterfall([
+			(next) => {
+				cache.hgetall('stations', next);
+			},
 
 
-			if (err && err !== true) {
-				return cb({
-					status: 'error',
-					message: 'An error occurred while obtaining the stations'
-				});
-			}
+			(stations, next) => {
+				let resultStations = [];
+				for (let id in stations) {
+					resultStations.push(stations[id]);
+				}
+				next(null, stations);
+			},
 
 
-			let arr = [];
-			let done = 0;
-			for (let prop in stations) {
-				// TODO If community, check if on whitelist
-				let station = stations[prop];
-				console.log(station)
-				if (station.privacy === 'public') add(true, station);
-				else if (!session.sessionId) add(false);
-				else {
-					cache.hget('sessions', session.sessionId, (err, session) => {
-						if (err || !session) {
-							add(false);
-						} else {
-							db.models.user.findOne({_id: session.userId}, (err, user) => {
-								if (err || !user) add(false);
-								else if (user.role === 'admin') add(true, station);
-								else if (station.type === 'official') add(false);
-								else if (station.owner === session.userId) add(true, station);
-								else add(false);
-							});
+			(stations, next) => {
+				let resultStations = [];
+				async.each(stations, (station, next) => {
+					async.waterfall([
+						(next) => {
+							if (station.privacy === 'public') return next(true);
+							if (!session.sessionId) return next(`Insufficient permissions.`);
+							cache.hget('sessions', session.sessionId, next);
+						},
+
+						(session, next) => {
+							if (!session) return next(`Insufficient permissions.`);
+							db.models.user.findOne({_id: session.userId}, next);
+						},
+
+						(user, next) => {
+							if (!user) return next(`Insufficient permissions.`);
+							if (user.role === 'admin') return next(true);
+							if (station.type === 'official') return next(`Insufficient permissions.`);
+							if (station.owner === session.userId) return next(true);
+							next(`Insufficient permissions.`);
 						}
 						}
+					], (err) => {
+						station.userCount = usersPerStationCount[station._id] || 0;
+						if (err === true) resultStations.push(station);
+						next();
 					});
 					});
-				}
+				}, () => {
+					next(null, resultStations);
+				});
+			}
+		], (err, stations) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
 			}
 			}
+			logger.success("STATIONS_INDEX", `Indexing stations successful.`);
+			return cb({'status': 'success', 'stations': stations});
+		});
+	},
 
 
-			function add(add, station) {
-				console.log("ADD!", add, station);
-				if (add) arr.push(station);
-				done++;
-				if (done === Object.keys(stations).length) {
-					console.log("DONE!", done);
-					cb({ status: 'success', stations: arr });
-				}
+	/**
+	 * Finds a station by name
+	 *
+	 * @param session
+	 * @param stationName - the station name
+	 * @param cb
+	 */
+	findByName: (session, stationName, cb) => {
+		async.waterfall([
+			(next) => {
+				stations.getStationByName(stationName, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				next(null, station);
 			}
 			}
+		], (err, station) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("STATIONS_FIND_BY_NAME", `Finding station "${stationName}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_FIND_BY_NAME", `Found station "${stationName}" successfully.`);
+			cb({status: 'success', data: station});
 		});
 		});
 	},
 	},
 
 
+	/**
+	 * Gets the official playlist for a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
 	getPlaylist: (session, stationId, cb) => {
 	getPlaylist: (session, stationId, cb) => {
-		let playlist = [];
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
 
 
-		stations.getStation(stationId, (err, station) => {
-			for (let s = 1; s < station.playlist.length; s++) {
-				songs.getSong(station.playlist[s], (err, song) => {
-					playlist.push(song);
-				});
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				if (station.type !== 'official') return next('This is not an official station.');
+				next();
+			},
+
+			(next) => {
+				cache.hget("officialPlaylists", stationId, next);
+			},
+
+			(playlist, next) => {
+				if (!playlist) return next('Playlist not found.');
+				next(null, playlist);
+			}
+		], (err, playlist) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
 			}
 			}
+			logger.success("STATIONS_GET_PLAYLIST", `Got playlist for station "${stationId}" successfully.`);
+			cb({status: 'success', data: playlist.songs})
 		});
 		});
-
-		cb({ status: 'success', data: playlist })
 	},
 	},
 
 
 	/**
 	/**
-	 * Joins the station by its id
+	 * Joins the station by its name
 	 *
 	 *
 	 * @param session
 	 * @param session
-	 * @param stationId - the station id
+	 * @param stationName - the station name
 	 * @param cb
 	 * @param cb
 	 * @return {{ status: String, userCount: Integer }}
 	 * @return {{ status: String, userCount: Integer }}
 	 */
 	 */
-	join: (session, stationId, cb) => {
+	join: (session, stationName, cb) => {
+		async.waterfall([
+			(next) => {
+				stations.getStationByName(stationName, next);
+			},
 
 
-		stations.getStation(stationId, (err, station) => {
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				async.waterfall([
+					(next) => {
+						if (station.privacy !== 'private') return next(true);
+						if (!session.userId) return next('An error occurred while joining the station.');
+						next();
+					},
 
 
-			if (err && err !== true) return cb({ status: 'error', message: 'An error occurred while joining the station' });
+					(next) => {
+						db.models.user.findOne({_id: session.userId}, next);
+					},
 
 
-			if (station) {
+					(user, next) => {
+						if (!user) return next('An error occurred while joining the station.');
+						if (user.role === 'admin') return next(true);
+						if (station.type === 'official') return next('An error occurred while joining the station.');
+						if (station.owner === session.userId) return next(true);
+						next('An error occurred while joining the station.');
+					}
+				], (err) => {
+					if (err === true) return next(null, station);
+					next(utils.getError(err));
+				});
+			},
 
 
-				if (station.privacy !== 'private') {
-					func();
-				} else {
-					// TODO If community, check if on whitelist
-					if (!session.userId) return cb({ status: 'error', message: 'An error occurred while joining the station1' });
-					db.models.user.findOne({_id: session.userId}, (err, user) => {
-						if (err || !user) return cb({ status: 'error', message: 'An error occurred while joining the station2' });
-						if (user.role === 'admin') return func();
-						if (station.type === 'official') return cb({ status: 'error', message: 'An error occurred while joining the station3' });
-						if (station.owner === session.userId) return func();
-						return cb({ status: 'error', message: 'An error occurred while joining the station4' });
-					});
-				}
+			(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,
+					description: station.description,
+					displayName: station.displayName,
+					privacy: station.privacy,
+					partyMode: station.partyMode,
+					owner: station.owner,
+					privatePlaylist: station.privatePlaylist
+				};
+				userList[session.socketId] = station._id;
+				next(null, data);
+			},
 
 
-				function func() {
-					utils.socketJoinRoom(session.socketId, `station.${stationId}`);
-					if (station.currentSong) {
-						utils.socketJoinSongRoom(session.socketId, `song.${station.currentSong._id}`);
-						//TODO Emit to cache, listen on cache
-						songs.getSong(station.currentSong._id, (err, song) => {
-							if (!err && song) {
-								station.currentSong.likes = song.likes;
-								station.currentSong.dislikes = song.dislikes;
-							} else {
-								station.currentSong.likes = -1;
-								station.currentSong.dislikes = -1;
-							}
-							station.currentSong.skipVotes = station.currentSong.skipVotes.length;
-							cb({
-								status: 'success',
-								data: {
-									type: station.type,
-									currentSong: station.currentSong,
-									startedAt: station.startedAt,
-									paused: station.paused,
-									timePaused: station.timePaused,
-									description: station.description,
-									displayName: station.displayName,
-									privacy: station.privacy,
-									partyMode: station.partyMode,
-									owner: station.owner,
-									privatePlaylist: station.privatePlaylist
-								}
-							});
-						});
+			(data, next) => {
+				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 {
 					} else {
-						cb({
-							status: 'success',
-							data: {
-								type: station.type,
-								currentSong: null,
-								startedAt: station.startedAt,
-								paused: station.paused,
-								timePaused: station.timePaused,
-								description: station.description,
-								displayName: station.displayName,
-								privacy: station.privacy,
-								partyMode: station.partyMode,
-								owner: station.owner
-							}
-						});
+						data.currentSong.likes = -1;
+						data.currentSong.dislikes = -1;
 					}
 					}
-				}
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist` });
+					next(null, data);
+				});
 			}
 			}
+		], (err, data) => {
+			if (err) {
+				err = 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});
 		});
 		});
 	},
 	},
 
 
 	/**
 	/**
-	 * Skips the users current station
+	 * Votes to skip a station
 	 *
 	 *
 	 * @param session
 	 * @param session
 	 * @param stationId - the station id
 	 * @param stationId - the station id
 	 * @param cb
 	 * @param cb
+	 * @param userId
 	 */
 	 */
 	voteSkip: hooks.loginRequired((session, stationId, cb, userId) => {
 	voteSkip: hooks.loginRequired((session, stationId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			if (!station.currentSong) return cb({ status: 'failure', message: 'There is currently no song to skip.' });
-			if (station.currentSong.skipVotes.indexOf(userId) !== -1) return cb({ status: 'failure', message: 'You have already voted to skip this song.' });
-			db.models.station.update({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, (err) => {
-				if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-				stations.updateStation(stationId, (err, station) => {
-					cache.pub('station.voteSkipSong', stationId);
-					if (station.currentSong && station.currentSong.skipVotes.length >= 1) {
-						stations.skipStation(stationId)();
-					}
-					cb({ status: 'success', message: 'Successfully voted to skip the song.' });
-				})
-			});
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				utils.canUserBeInStation(station, userId, (canBe) => {
+					if (canBe) 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(userId) !== -1) return next('You have already voted to skip this song.');
+				next(null, station);
+			},
+
+			(station, next) => {
+				db.models.station.update({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				next(null, station);
+			}
+		], (err, station) => {
+			if (err) {
+				err = 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);
+			if (station.currentSong && station.currentSong.skipVotes.length >= 3) stations.skipStation(stationId)();
+			cb({ status: 'success', message: 'Successfully voted to skip the song.' });
 		});
 		});
 	}),
 	}),
 
 
+	/**
+	 * Force skips a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
 	forceSkip: hooks.ownerRequired((session, stationId, cb) => {
 	forceSkip: hooks.ownerRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while skipping the station' });
-			}
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
 
 
-			if (station) {
-				notifications.unschedule(`stations.nextSong?id=${stationId}`);
-				//notifications.schedule(`stations.nextSong?id=${stationId}`, 100);
-				stations.skipStation(stationId)();
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				next();
 			}
 			}
-			else {
-				cb({ status: 'failure', message: `That station doesn't exist` });
+		], (err) => {
+			if (err) {
+				err = 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 users current station
+	 * Leaves the user's current station
 	 *
 	 *
 	 * @param session
 	 * @param session
 	 * @param stationId
 	 * @param stationId
@@ -275,281 +492,579 @@ module.exports = {
 	 * @return {{ status: String, userCount: Integer }}
 	 * @return {{ status: String, userCount: Integer }}
 	 */
 	 */
 	leave: (session, stationId, cb) => {
 	leave: (session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
 
 
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while leaving the station' });
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				next();
 			}
 			}
-
-			if (session) session.stationId = null;
-			else if (station) {
-				cache.client.hincrby('station.userCounts', stationId, -1, (err, userCount) => {
-					if (err) return cb({ status: 'error', message: 'An error occurred while leaving the station' });
-					utils.socketLeaveRooms(session);
-					cb({ status: 'success', userCount });
-				});
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist, it may have been deleted` });
+		], (err, userCount) => {
+			if (err) {
+				err = 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.update({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newName}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_UPDATE_DISPLAY_NAME", `Updated station "${stationId}" displayName 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) => {
 	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
-		db.models.station.update({_id: stationId}, {$set: {displayName: newDisplayName}}, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			stations.updateStation(stationId, () => {
-				//TODO Pub/sub for displayName change
-				cb({ status: 'success', message: 'Successfully updated the display name.' });
-			})
+		async.waterfall([
+			(next) => {
+				db.models.station.update({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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) => {
 	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
-		db.models.station.update({_id: stationId}, {$set: {description: newDescription}}, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			stations.updateStation(stationId, () => {
-				//TODO Pub/sub for description change
-				cb({ status: 'success', message: 'Successfully updated the description.' });
-			})
+		async.waterfall([
+			(next) => {
+				db.models.station.update({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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) => {
 	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
-		db.models.station.update({_id: stationId}, {$set: {privacy: newPrivacy}}, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			stations.updateStation(stationId, () => {
-				//TODO Pub/sub for privacy change
-				cb({ status: 'success', message: 'Successfully updated the privacy.' });
-			})
+		async.waterfall([
+			(next) => {
+				db.models.station.update({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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 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) => {
 	updatePartyMode: hooks.ownerRequired((session, stationId, newPartyMode, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb({ status: 'failure', message: err });
-			if (station.partyMode === newPartyMode) return cb({ status: 'failure', message: 'The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.') });
-			db.models.station.update({_id: stationId}, {$set: {partyMode: newPartyMode}}, (err) => {
-				if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-				stations.updateStation(stationId, () => {
-					//TODO Pub/sub for privacy change
-					stations.skipStation(stationId)();
-					cb({ status: 'success', message: 'Successfully updated the party mode.' });
-				})
-			});
+		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.update({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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) => {
 	pause: hooks.ownerRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while pausing the station' });
-			} else if (station) {
-				if (!station.paused) {
-					station.paused = true;
-					station.pausedAt = Date.now();
-					db.models.station.update({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, () => {
-						if (err) return cb({ status: 'failure', message: 'An error occurred while pausing the station.' });
-						stations.updateStation(stationId, () => {
-							cache.pub('station.pause', stationId);
-							notifications.unschedule(`stations.nextSong?id=${stationId}`);
-							cb({ status: 'success' });
-						});
-					});
-				} else {
-					cb({ status: 'failure', message: 'That station was already paused.' });
-				}
-				cb({ status: 'success' });
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist, it may have been deleted` });
+		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.update({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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) => {
 	resume: hooks.ownerRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while resuming the station' });
-			} else if (station) {
-				if (station.paused) {
-					station.paused = false;
-					station.timePaused += (Date.now() - station.pausedAt);
-					console.log("&&&", station.timePaused, station.pausedAt, Date.now(), station.timePaused);
-					db.models.station.update({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, () => {
-						stations.updateStation(stationId, (err, station) => {
-							cache.pub('station.resume', stationId);
-							cb({ status: 'success' });
-						});
-					});
-				} else {
-					cb({ status: 'failure', message: 'That station is not paused.' });
-				}
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist, it may have been deleted` });
+		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.update({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err) => {
+			if (err) {
+				err = 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) => {
 	remove: hooks.ownerRequired((session, stationId, cb) => {
-		db.models.station.remove({ _id: stationId }, (err) => {
-			console.log(err, stationId);
-			if (err) return cb({status: 'failure', message: 'Something went wrong when deleting that station.'});
-			cache.hdel('stations', stationId, () => {
-				return cb({ status: 'success', message: 'Station successfully removed' });
-			});
+		async.waterfall([
+			(next) => {
+				db.models.station.remove({ _id: stationId }, err => next(err));
+			},
+
+			(next) => {
+				cache.hdel('stations', stationId, err => next(err));
+			}
+		], (err) => {
+			if (err) {
+				err = 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);
+			return cb({ 'status': 'success', 'message': 'Successfully removed.' });
 		});
 		});
 	}),
 	}),
 
 
-	create: hooks.loginRequired((session, data, cb) => {
-		data._id = data._id.toLowerCase();
+	/**
+	 * Create a station
+	 *
+	 * @param session
+	 * @param data - the station data
+	 * @param cb
+	 * @param userId
+	 */
+	create: hooks.loginRequired((session, data, cb, userId) => {
+		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([
 		async.waterfall([
-
 			(next) => {
 			(next) => {
-				return (data) ? next() : cb({ 'status': 'failure', 'message': 'Invalid data' });
+				if (!data) return next('Invalid data.');
+				next();
 			},
 			},
 
 
 			(next) => {
 			(next) => {
-				db.models.station.findOne({ $or: [{_id: data._id}, {displayName: new RegExp(`^${data.displayName}$`, 'i')}] }, next);
+				db.models.station.findOne({ $or: [{name: data.name}, {displayName: new RegExp(`^${data.displayName}$`, 'i')}] }, next);
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
-				if (station) return next({ 'status': 'failure', 'message': 'A station with that name or display name already exists' });
-				const { _id, displayName, description, genres, playlist, type, blacklistedGenres } = data;
-				cache.hget('sessions', session.sessionId, (err, session) => {
-					if (type == 'official') {
-						db.models.user.findOne({_id: session.userId}, (err, user) => {
-							if (err) return next({ 'status': 'failure', 'message': 'Something went wrong when getting your user info.' });
-							if (!user) return next({ 'status': 'failure', 'message': 'User not found.' });
-							if (user.role !== 'admin') return next({ 'status': 'failure', 'message': 'Admin required.' });
-							db.models.station.create({
-								_id,
-								displayName,
-								description,
-								type,
-								privacy: 'private',
-								playlist,
-								genres,
-								blacklistedGenres,
-								currentSong: stations.defaultSong
-							}, next);
-						});
-					} else if (type == 'community') {
+				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: 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({
 						db.models.station.create({
-							_id,
+							name,
 							displayName,
 							displayName,
 							description,
 							description,
 							type,
 							type,
 							privacy: 'private',
 							privacy: 'private',
-							owner: session.userId,
-							queue: [],
-							currentSong: null
+							playlist,
+							genres,
+							blacklistedGenres,
+							currentSong: stations.defaultSong
 						}, next);
 						}, 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: userId,
+						queue: [],
+						currentSong: null
+					}, next);
+				}
 			}
 			}
-
 		], (err, station) => {
 		], (err, station) => {
 			if (err) {
 			if (err) {
-				console.error(err);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong.'});
+				err = utils.getError(err);
+				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
 			}
 			}
-			cache.pub('station.create', data._id);
-			cb({ 'status': 'success', 'message': 'Successfully created station.' });
+			logger.success("STATIONS_CREATE", `Created station "${station._id}" successfully.`);
+			cache.pub('station.create', 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
+	 * @param userId
+	 */
 	addToQueue: hooks.loginRequired((session, stationId, songId, cb, userId) => {
 	addToQueue: hooks.loginRequired((session, stationId, songId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (station.type === 'community') {
-				let has = false;
-				station.queue.forEach((queueSong) => {
-					if (queueSong._id === songId) {
-						has = true;
-					}
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				if (station.type !== 'community') return next('That station is not a community station.');
+				utils.canUserBeInStation(station, userId, (canBe) => {
+					if (canBe) return next(null, station);
+					return next('Insufficient permissions.');
 				});
 				});
-				if (has) return cb({'status': 'failure', 'message': 'That song has already been added to the queue.'});
-				if (station.currentSong && station.currentSong._id === songId) return cb({'status': 'failure', 'message': 'That song is currently playing.'});
+			},
 
 
+			(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) => {
 				songs.getSong(songId, (err, song) => {
-					if (err) {
-						utils.getSongFromYouTube(songId, (song) => {
-							song.artists = [];
-							song.skipDuration = 0;
-							song.likes = -1;
-							song.dislikes = -1;
-							song.thumbnail = "empty";
-							song.explicit = false;
-							cont(song);
-						});
-					} else cont(song);
-					function cont(song) {
-						db.models.station.update({_id: stationId}, {$push: {queue: song}}, (err) => {
-							console.log(err);
-							if (err) return cb({'status': 'failure', 'message': 'Something went wrong.'});
-							stations.updateStation(stationId, (err, station) => {
-								if (err) return cb(err);
-								cache.pub('station.queueUpdate', stationId);
-								cb({'status': 'success', 'message': 'Added that song to the queue.'});
-							});
-						});
+					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 = 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;
 					}
 					}
 				});
 				});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
+
+				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.update({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err, station) => {
+			if (err) {
+				err = 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
+	 * @param userId
+	 */
 	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb, userId) => {
 	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (station.type === 'community') {
-				let has = false;
-				station.queue.forEach((queueSong) => {
-					if (queueSong._id === songId) {
-						has = true;
-					}
-				});
-				if (!has) return cb({'status': 'failure', 'message': 'That song is not in the queue.'});
-				db.models.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, (err) => {
-					if (err) return cb({'status': 'failure', 'message': 'Something went wrong.'});
-					stations.updateStation(stationId, (err, station) => {
-						if (err) return cb(err);
-						cache.pub('station.queueUpdate', stationId);
-					});
+		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.');
 				});
 				});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
+			},
+
+			(next) => {
+				db.models.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
+			},
+
+			(next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err, station) => {
+			if (err) {
+				err = 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.'});
 		});
 		});
 	}),
 	}),
 
 
-	getQueue: hooks.adminRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (!station) return cb({'status': 'failure', 'message': 'Station not found.'});
-			if (station.type === 'community') {
-				cb({'status': 'success', queue: station.queue});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
+	/**
+	 * 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) => {
+				utils.canUserBeInStation(station, session.userId, (canBe) => {
+					if (canBe) return next(null, station);
+					return next('Insufficient permissions.');
+				});
+			}
+		], (err, station) => {
+			if (err) {
+				err = 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
+	 * @param userId
+	 */
 	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb, userId) => {
 	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (station.type === 'community') {
-				if (station.privatePlaylist === playlistId) return cb({'status': 'failure', 'message': 'That playlist is already selected.'});
-				db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-					if (err) return cb(err);
-					if (playlist) {
-						db.models.station.update({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: 0}}, (err) => {
-							if (err) return cb(err);
-							stations.updateStation(stationId, (err, station) => {
-								if (err) return cb(err);
-								stations.skipStation(stationId)();
-								cb({'status': 'success', 'message': 'Playlist selected.'});
-							});
-						});
-					} else cb({'status': 'failure', 'message': 'Playlist not found.'});
-				});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
+		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.update({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err, station) => {
+			if (err) {
+				err = 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.'});
 		});
 		});
 	}),
 	}),
-
 };
 };

+ 781 - 103
backend/logic/actions/users.js

@@ -6,12 +6,107 @@ const request = require('request');
 const bcrypt = require('bcrypt');
 const bcrypt = require('bcrypt');
 
 
 const db = require('../db');
 const db = require('../db');
+const mail = require('../mail');
 const cache = require('../cache');
 const cache = require('../cache');
 const utils = require('../utils');
 const utils = require('../utils');
 const hooks = require('./hooks');
 const hooks = require('./hooks');
+const sha256 = require('sha256');
+const logger = require('../logger');
+
+cache.sub('user.updateUsername', user => {
+	utils.socketsFromUser(user._id, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.username.changed', user.username);
+		});
+	});
+});
+
+cache.sub('user.linkPassword', userId => {
+	console.log("LINK4", userId);
+	utils.socketsFromUser(userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.linkPassword');
+		});
+	});
+});
+
+cache.sub('user.linkGitHub', userId => {
+	console.log("LINK1", userId);
+	utils.socketsFromUser(userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.linkGitHub');
+		});
+	});
+});
+
+cache.sub('user.unlinkPassword', userId => {
+	console.log("LINK2", userId);
+	utils.socketsFromUser(userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.unlinkPassword');
+		});
+	});
+});
+
+cache.sub('user.unlinkGitHub', userId => {
+	console.log("LINK3", userId);
+	utils.socketsFromUser(userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.unlinkGitHub');
+		});
+	});
+});
 
 
 module.exports = {
 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);
+			}
+		], (err, users) => {
+			if (err) {
+				err = 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) => {
 	login: (session, identifier, password, cb) => {
 
 
 		identifier = identifier.toLowerCase();
 		identifier = identifier.toLowerCase();
@@ -19,56 +114,65 @@ module.exports = {
 		async.waterfall([
 		async.waterfall([
 
 
 			// check if a user with the requested identifier exists
 			// check if a user with the requested identifier exists
-			(next) => db.models.user.findOne({
-				$or: [{ 'username': identifier }, { 'email.address': identifier }]
-			}, next),
+			(next) => {
+				db.models.user.findOne({
+					$or: [{ 'email.address': identifier }]
+				}, next)
+			},
 
 
 			// if the user doesn't exist, respond with a failure
 			// if the user doesn't exist, respond with a failure
 			// otherwise compare the requested password and the actual users password
 			// otherwise compare the requested password and the actual users password
 			(user, next) => {
 			(user, next) => {
-				if (!user) return next(true, { status: 'failure', message: 'User not found' });
-				bcrypt.compare(password, user.services.password.password, (err, match) => {
-
+				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 (err) return next(err);
+					if (!match) return next('Incorrect password');
+					next(null, user);
+				});
+			},
 
 
-					// if the passwords match
-					if (match) {
-
-						// store the session in the cache
-						let sessionId = utils.guid();
-						cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
-							if (!err) {
-								//TODO See if it is necessary to add new SID to socket.
-								next(null, { status: 'success', message: 'Login successful', user, SID: sessionId });
-							} else {
-								next(null, { status: 'failure', message: 'Something went wrong' });
-							}
-						});
-					}
-					else {
-						next(null, { status: 'failure', message: 'Incorrect password' });
-					}
+			(user, next) => {
+				let sessionId = utils.guid();
+				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
+					if (err) return next(err);
+					next(null, sessionId);
 				});
 				});
 			}
 			}
 
 
-		], (err, payload) => {
-
-			// log this error somewhere
+		], (err, sessionId) => {
 			if (err && err !== true) {
 			if (err && err !== true) {
-				console.error(err);
-				return cb({ status: 'error', message: 'An error occurred while logging in' });
+				err = utils.getError(err);
+				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
+				return cb({status: 'failure', message: err});
 			}
 			}
-
-			cb(payload);
+			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: function(session, username, email, password, recaptcha, cb) {
 	register: function(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
 		email = email.toLowerCase();
+		let verificationToken = utils.generateRandomString(64);
 		async.waterfall([
 		async.waterfall([
 
 
 			// verify the request with google recaptcha
 			// 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) => {
 			(next) => {
 				request({
 				request({
 					url: 'https://www.google.com/recaptcha/api/siteverify',
 					url: 'https://www.google.com/recaptcha/api/siteverify',
@@ -84,37 +188,37 @@ module.exports = {
 			// if it is, we check if a user with the requested username already exists
 			// if it is, we check if a user with the requested username already exists
 			(response, body, next) => {
 			(response, body, next) => {
 				let json = JSON.parse(body);
 				let json = JSON.parse(body);
-				console.log(response, body);
-				if (json.success !== true) return next('Response from recaptcha was not successful');
+				if (json.success !== true) return next('Response from recaptcha was not successful.');
 				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
 				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
 			},
 			},
 
 
 			// if the user already exists, respond with that
 			// if the user already exists, respond with that
 			// otherwise check if a user with the requested email already exists
 			// otherwise check if a user with the requested email already exists
 			(user, next) => {
 			(user, next) => {
-				if (user) return next(true, { status: 'failure', message: 'A user with that username already exists' });
+				if (user) return next('A user with that username already exists.');
 				db.models.user.findOne({ 'email.address': email }, next);
 				db.models.user.findOne({ 'email.address': email }, next);
 			},
 			},
 
 
 			// if the user already exists, respond with that
 			// if the user already exists, respond with that
 			// otherwise, generate a salt to use with hashing the new users password
 			// otherwise, generate a salt to use with hashing the new users password
 			(user, next) => {
 			(user, next) => {
-				if (user) return next(true, { status: 'failure', message: 'A user with that email already exists' });
+				if (user) return next('A user with that email already exists.');
 				bcrypt.genSalt(10, next);
 				bcrypt.genSalt(10, next);
 			},
 			},
 
 
 			// hash the password
 			// hash the password
 			(salt, next) => {
 			(salt, next) => {
-				bcrypt.hash(password, salt, next)
+				bcrypt.hash(sha256(password), salt, next)
 			},
 			},
 
 
 			// save the new user to the database
 			// save the new user to the database
 			(hash, next) => {
 			(hash, next) => {
 				db.models.user.create({
 				db.models.user.create({
+					_id: utils.generateRandomString(12),//TODO Check if exists
 					username,
 					username,
 					email: {
 					email: {
 						address: email,
 						address: email,
-						verificationToken: utils.generateRandomString(64)
+						verificationToken
 					},
 					},
 					services: {
 					services: {
 						password: {
 						password: {
@@ -127,20 +231,23 @@ module.exports = {
 			// respond with the new user
 			// respond with the new user
 			(newUser, next) => {
 			(newUser, next) => {
 				//TODO Send verification email
 				//TODO Send verification email
-				next(null, { status: 'success', user: newUser })
+				mail.schemas.verifyEmail(email, username, verificationToken, () => {
+					next();
+				});
 			}
 			}
 
 
-		], (err, payload) => {
-			// log this error somewhere
+		], (err) => {
 			if (err && err !== true) {
 			if (err && err !== true) {
-				console.error(err);
-				return cb({ status: 'error', message: 'An error occurred while registering for an account' });
+				err = utils.getError(err);
+				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
+				cb({status: 'failure', message: err});
 			} else {
 			} else {
 				module.exports.login(session, email, password, (result) => {
 				module.exports.login(session, email, password, (result) => {
 					let obj = {status: 'success', message: 'Successfully registered.'};
 					let obj = {status: 'success', message: 'Successfully registered.'};
 					if (result.status === 'success') {
 					if (result.status === 'success') {
 						obj.SID = result.SID;
 						obj.SID = result.SID;
 					}
 					}
+					logger.success("USER_PASSWORD_REGISTER", `Register successful with password for user "${username}".`);
 					cb(obj);
 					cb(obj);
 				});
 				});
 			}
 			}
@@ -148,29 +255,64 @@ module.exports = {
 
 
 	},
 	},
 
 
+	/**
+	 * 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) => {
 	logout: (session, cb) => {
 
 
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session) return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
+		async.waterfall([
+			(next) => {
+				cache.hget('sessions', session.sessionId, next);
+			},
 
 
-			cache.hdel('sessions', session.sessionId, (err) => {
-				if (err) return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
-				return cb({ 'status': 'success', message: 'You have been successfully logged out.' });
-			});
+			(session, next) => {
+				if (!session) return next('Session not found');
+				next(null, session);
+			},
+
+			(session, next) => {
+				cache.hdel('sessions', session.sessionId, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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.'});
+			}
 		});
 		});
 
 
 	},
 	},
 
 
+	/**
+	 * 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) => {
 	findByUsername: (session, username, cb) => {
-		db.models.user.find({ username }, (err, account) => {
-			if (err) throw err;
-			else if (account.length == 0) {
-				return cb({
-					status: 'error',
-					message: 'Username cannot be found'
-				});
+		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);
+			}
+		], (err, account) => {
+			if (err && err !== true) {
+				err = utils.getError(err);
+				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
+				cb({status: 'failure', message: err});
 			} else {
 			} else {
-				account = account[0];
+				logger.success("FIND_BY_USERNAME", `User found for username "${username}".`);
 				return cb({
 				return cb({
 					status: 'success',
 					status: 'success',
 					data: {
 					data: {
@@ -178,7 +320,6 @@ module.exports = {
 						username: account.username,
 						username: account.username,
 						role: account.role,
 						role: account.role,
 						email: account.email.address,
 						email: account.email.address,
-						password: '',
 						createdAt: account.createdAt,
 						createdAt: account.createdAt,
 						statistics: account.statistics,
 						statistics: account.statistics,
 						liked: account.liked,
 						liked: account.liked,
@@ -190,62 +331,599 @@ module.exports = {
 	},
 	},
 
 
 	//TODO Fix security issues
 	//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) => {
 	findBySession: (session, cb) => {
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err) return cb({ 'status': 'error', message: err });
-			if (!session) return cb({ 'status': 'error', message: 'You are not logged in' });
-			db.models.user.findOne({ _id: session.userId }, {username: 1, "email.address": 1}, (err, user) => {
-				if (err) { throw err; } else if (user) {
-					return cb({
-						status: 'success',
-						data: user
-					});
-				}
-			});
-		});
+		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);
+			}
+		], (err, user) => {
+			if (err && err !== true) {
+				err = 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
+					},
+					username: user.username
+				};
+				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
+				});
+			}
+		});
 	},
 	},
 
 
-	updateUsername: hooks.loginRequired((session, newUsername, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (err) console.error(err);
-			if (!user) return cb({ status: 'error', message: 'User not found.' });
-			if (user.username !== newUsername) {
-				if (user.username.toLowerCase() !== newUsername.toLowerCase()) {
-					db.models.user.findOne({username: new RegExp(`^${newUsername}$`, 'i')}, (err, _user) => {
-						if (err) return cb({ status: 'error', message: err.message });
-						if (_user) return cb({ status: 'failure', message: 'That username is already in use.' });
-						db.models.user.update({_id: userId}, {$set: {username: newUsername}}, (err) => {
-							if (err) return cb({ status: 'error', message: err.message });
-							cb({ status: 'success', message: 'Username updated successfully.' });
-						});
-					});
-				} else {
-					db.models.user.update({_id: userId}, {$set: {username: newUsername}}, (err) => {
-						if (err) return cb({ status: 'error', message: err.message });
-						cb({ status: 'success', message: 'Username updated successfully.' });
-					});
-				}
-			} else cb({ status: 'error', message: 'Username has not changed. Your new username cannot be the same as your old username.' });
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updateUsername: hooks.loginRequired((session, updatingUserId, newUsername, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				if (updatingUserId === userId) return next(null, true);
+				db.models.user.findOne({_id: 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.update({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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' });
+			}
 		});
 		});
 	}),
 	}),
 
 
-	updateEmail: hooks.loginRequired((session, newEmail, cb, userId) => {
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updateEmail: hooks.loginRequired((session, updatingUserId, newEmail, cb, userId) => {
 		newEmail = newEmail.toLowerCase();
 		newEmail = newEmail.toLowerCase();
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (err) console.error(err);
-			if (!user) return cb({ status: 'error', message: 'User not found.' });
-			if (user.email.address !== newEmail) {
-				db.models.user.findOne({"email.address": newEmail}, (err, _user) => {
-					if (err) return cb({ status: 'error', message: err.message });
-					if (_user) return cb({ status: 'failure', message: 'That email is already in use.' });
-					db.models.user.update({_id: userId}, {$set: {"email.address": newEmail}}, (err) => {
-						if (err) return cb({ status: 'error', message: err.message });
-						cb({ status: 'success', message: 'Email updated successfully.' });
-					});
+		let verificationToken = utils.generateRandomString(64);
+		async.waterfall([
+			(next) => {
+				if (updatingUserId === userId) return next(null, true);
+				db.models.user.findOne({_id: 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.');
+			},
+
+			(next) => {
+				db.models.user.update({_id: updatingUserId}, {$set: {"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();
+				});
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb, userId) => {
+		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.update({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
+			}
+
+		], (err) => {
+			if (err && err !== true) {
+				err = utils.getError(err);
+				logger.error("UPDATE_ROLE", `User "${userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("UPDATE_ROLE", `User "${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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updatePassword: hooks.loginRequired((session, newPassword, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: 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.update({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${err}'.`);
+				return cb({ status: 'failure', message: err });
+			}
+
+			logger.success("UPDATE_PASSWORD", `User '${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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	requestPassword: hooks.loginRequired((session, cb, userId) => {
+		let code = utils.generateRandomString(8);
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: 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);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = utils.getError(err);
+				logger.error("REQUEST_PASSWORD", `UserId '${userId}' failed to request password. '${err}'`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("REQUEST_PASSWORD", `UserId '${userId}' successfully requested a password.`);
+				cb({
+					status: 'success',
+					message: 'Successfully requested password.'
 				});
 				});
-			} else cb({ status: 'error', message: 'Email has not changed. Your new email cannot be the same as your old email.' });
+			}
 		});
 		});
-	})
+	}),
+
+	/**
+	 * 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	verifyPasswordCode: hooks.loginRequired((session, code, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code1.');
+				db.models.user.findOne({"services.password.set.code": code, _id: 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);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	changePasswordWithCode: hooks.loginRequired((session, code, newPassword, cb, userId) => {
+		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.update({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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', 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	unlinkPassword: hooks.loginRequired((session, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: 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.update({_id: userId}, {$unset: {"services.password": ''}}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = utils.getError(err);
+				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${userId}'. '${err}'`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("UNLINK_PASSWORD", `Unlinking password successful for userId '${userId}'.`);
+				cache.pub('user.unlinkPassword', 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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	unlinkGitHub: hooks.loginRequired((session, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: 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.update({_id: userId}, {$unset: {"services.github": ''}}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = utils.getError(err);
+				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${userId}'. '${err}'`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("UNLINK_GITHUB", `Unlinking GitHub successful for userId '${userId}'.`);
+				cache.pub('user.unlinkGitHub', 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: (session, email, cb) => {
+		let code = 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);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				err = 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.'
+				});
+			}
+		});
+	}
 };
 };

+ 27 - 0
backend/logic/api.js

@@ -0,0 +1,27 @@
+module.exports = {
+	init: (cb) => {
+		const { app } = require('./app.js');
+		const actions = require('./actions');
+
+		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}`;
+
+				app.get(name, (req, res) => {
+					actions[namespace][action](null, (result) => {
+						if (typeof cb === 'function') return res.json(result);
+					});
+				});
+			})
+		});
+
+		cb();
+	}
+}

+ 169 - 66
backend/logic/app.js

@@ -1,18 +1,21 @@
 'use strict';
 'use strict';
 
 
-// This file contains all the logic for Express
-
 const express = require('express');
 const express = require('express');
 const bodyParser = require('body-parser');
 const bodyParser = require('body-parser');
+const cookieParser = require('cookie-parser');
 const cors = require('cors');
 const cors = require('cors');
 const config = require('config');
 const config = require('config');
+const async = require('async');
+const logger = require('./logger');
+const mail = require('./mail');
 const request = require('request');
 const request = require('request');
-const cache = require('./cache');
-const db = require('./db');
-let utils;
 const OAuth2 = require('oauth').OAuth2;
 const OAuth2 = require('oauth').OAuth2;
 
 
+const api = require('./api');
+const cache = require('./cache');
+const db = require('./db');
 
 
+let utils;
 
 
 const lib = {
 const lib = {
 
 
@@ -25,7 +28,9 @@ const lib = {
 
 
 		let app = lib.app = express();
 		let app = lib.app = express();
 
 
-		lib.server = app.listen(8080);
+		lib.server = app.listen(config.get('serverPort'));
+
+		app.use(cookieParser());
 
 
 		app.use(bodyParser.json());
 		app.use(bodyParser.json());
 		app.use(bodyParser.urlencoded({ extended: true }));
 		app.use(bodyParser.urlencoded({ extended: true }));
@@ -55,76 +60,174 @@ const lib = {
 			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
 			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
 		});
 		});
 
 
+		app.get('/auth/github/link', (req, res) => {
+			let params = [
+				`client_id=${config.get('apis.github.client')}`,
+				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
+				`scope=user:email`,
+				`state=${req.cookies.SID}`
+			].join('&');
+			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+		});
+
 		function redirectOnErr (res, err){
 		function redirectOnErr (res, err){
-			return res.redirect(config.get('domain') + '/?err=' + encodeURIComponent('err'));
+			return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
 		}
 		}
 
 
 		app.get('/auth/github/authorize/callback', (req, res) => {
 		app.get('/auth/github/authorize/callback', (req, res) => {
 			let code = req.query.code;
 			let code = req.query.code;
-			oauth2.getOAuthAccessToken(code, { redirect_uri }, (err, access_token, refresh_token, results) => {
-				if (!err) request.get({
+			let access_token;
+			let body;
+			let address;
+			const state = req.query.state;
+
+			async.waterfall([
+				(next) => {
+					oauth2.getOAuthAccessToken(code, {redirect_uri}, next);
+				},
+
+				(_access_token, refresh_token, results, next) => {
+					access_token = _access_token;
+					request.get({
 						url: `https://api.github.com/user?access_token=${access_token}`,
 						url: `https://api.github.com/user?access_token=${access_token}`,
-						headers: { 'User-Agent': 'request' }
-					}, (err, httpResponse, body) => {
-						if (err) return redirectOnErr(res, 'err');
-						body = JSON.parse(body);
-						db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
-							if (err) return redirectOnErr(res, 'err');
-							if (user) {
-								user.services.github.access_token = access_token;
-								user.save(err => {
-									if (err) return redirectOnErr(res, 'err');
-									let sessionId = utils.guid();
-									cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
-										if (err) return redirectOnErr(res, 'err');
-										res.cookie('SID', sessionId);
-										res.redirect(`http://${config.get('domain')}/`);
-									});
-								});
-							} else {
-								db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i') }, (err, user) => {
-									if (err) return redirectOnErr(res, 'err');
-									if (user) return redirectOnErr(res, 'err');
-									else request.get({
-										url: `https://api.github.com/user/emails?access_token=${access_token}`,
-										headers: {'User-Agent': 'request'}
-									}, (err, httpResponse, body2) => {
-										if (err) return redirectOnErr(res, 'err');
-										body2 = JSON.parse(body2);
-										let address;
-										if (!Array.isArray(body2)) return redirectOnErr(res, body2.message);
-										body2.forEach(email => {
-											if (email.primary) address = email.email.toLowerCase();
-										});
-										db.models.user.findOne({'email.address': address}, (err, user) => {
-											if (err) return redirectOnErr(res, 'err');
-											if (user) return redirectOnErr(res, 'err');
-											else db.models.user.create({
-												username: body.login,
-												email: {
-													address,
-													verificationToken: utils.generateRandomString(64)
-												},
-												services: {
-													github: {id: body.id, access_token}
-												}
-											}, (err, user) => {
-												if (err) return redirectOnErr(res, 'err');
-												//TODO Send verification email
-												let sessionId = utils.guid();
-												cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
-													if (err) return redirectOnErr(res, 'err');
-													res.cookie('SID', sessionId);
-													res.redirect(`http://${config.get('domain')}/`);
-												});
-											});
-										});
-									});
+						headers: {'User-Agent': 'request'}
+					}, next);
+				},
+
+				(httpResponse, _body, next) => {
+					body = _body = JSON.parse(_body);
+					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.update({_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);
+					}
+					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();
 					});
 					});
-				else return redirectOnErr(res, 'err');
+					db.models.user.findOne({'email.address': address}, next);
+				},
+
+				(user, next) => {
+					const verificationToken = utils.generateRandomString(64);
+					if (user) return next('An account with that email address already exists.');
+					db.models.user.create({
+						_id: utils.generateRandomString(12),//TODO Check if exists
+						username: body.login,
+						email: {
+							address,
+							verificationToken: verificationToken
+						},
+						services: {
+							github: {id: body.id, access_token}
+						}
+					}, next);
+				},
+
+				(user, next) => {
+					mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
+					next(null, user._id);
+				}
+			], (err, userId) => {
+				if (err && err !== true) {
+					err = utils.getError(err);
+					logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
+					return redirectOnErr(res, err);
+				}
+
+				const sessionId = 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('SID', 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', (req, res) => {
+			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.update({"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"));
 			});
 			});
 		});
 		});
 
 

+ 29 - 14
backend/logic/cache/index.js

@@ -1,6 +1,7 @@
 'use strict';
 'use strict';
 
 
 const redis = require('redis');
 const redis = require('redis');
+const mongoose = require('mongoose');
 
 
 // Lightweight / convenience wrapper around redis module for our needs
 // Lightweight / convenience wrapper around redis module for our needs
 
 
@@ -16,6 +17,7 @@ const lib = {
 		session: require('./schemas/session'),
 		session: require('./schemas/session'),
 		station: require('./schemas/station'),
 		station: require('./schemas/station'),
 		playlist: require('./schemas/playlist'),
 		playlist: require('./schemas/playlist'),
+		officialPlaylist: require('./schemas/officialPlaylist'),
 		song: require('./schemas/song')
 		song: require('./schemas/song')
 	},
 	},
 
 
@@ -29,7 +31,10 @@ const lib = {
 		lib.url = url;
 		lib.url = url;
 
 
 		lib.client = redis.createClient({ url: lib.url });
 		lib.client = redis.createClient({ url: lib.url });
-		lib.client.on('error', (err) => console.error(err));
+		lib.client.on('error', (err) => {
+			console.error(err);
+			process.exit();
+		});
 
 
 		initialized = true;
 		initialized = true;
 		callbacks.forEach((callback) => {
 		callbacks.forEach((callback) => {
@@ -58,14 +63,14 @@ const lib = {
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 */
 	 */
 	hset: (table, key, value, cb, stringifyJson = true) => {
 	hset: (table, key, value, cb, stringifyJson = true) => {
-
+		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		// automatically stringify objects and arrays into JSON
 		// automatically stringify objects and arrays into JSON
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 
 
 		lib.client.hset(table, key, value, err => {
 		lib.client.hset(table, key, value, err => {
 			if (cb !== undefined) {
 			if (cb !== undefined) {
 				if (err) return cb(err);
 				if (err) return cb(err);
-				cb(null);
+				cb(null, JSON.parse(value));
 			}
 			}
 		});
 		});
 	},
 	},
@@ -79,9 +84,14 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 */
 	 */
 	hget: (table, key, cb, parseJson = true) => {
 	hget: (table, key, cb, parseJson = true) => {
+		if (!key || !table) return typeof cb === 'function' ? cb(null, null) : null;
+		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		lib.client.hget(table, key, (err, value) => {
 		lib.client.hget(table, key, (err, value) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (parseJson) try { value = JSON.parse(value); } catch (e) {}
+			if (parseJson) try {
+				value = JSON.parse(value);
+			} catch (e) {
+			}
 			if (typeof cb === 'function') cb(null, value);
 			if (typeof cb === 'function') cb(null, value);
 		});
 		});
 	},
 	},
@@ -94,9 +104,11 @@ const lib = {
 	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
 	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
 	 */
 	 */
 	hdel: (table, key, cb) => {
 	hdel: (table, key, cb) => {
+		if (!key || !table) return cb(null, null);
+		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		lib.client.hdel(table, key, (err) => {
 		lib.client.hdel(table, key, (err) => {
-			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (typeof cb === 'function') cb(null);
+			if (err) return cb(err);
+			else return cb(null);
 		});
 		});
 	},
 	},
 
 
@@ -108,6 +120,7 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 */
 	 */
 	hgetall: (table, cb, parseJson = true) => {
 	hgetall: (table, cb, parseJson = true) => {
+		if (!table) return cb(null, null);
 		lib.client.hgetall(table, (err, obj) => {
 		lib.client.hgetall(table, (err, obj) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			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) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });
@@ -143,28 +156,30 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - parse the message as JSON
 	 * @param {Boolean} [parseJson=true] - parse the message as JSON
 	 */
 	 */
 	sub: (channel, cb, parseJson = true) => {
 	sub: (channel, cb, parseJson = true) => {
-		if (initialized) {
-			func();
-		} else {
+		if (initialized) subToChannel();
+		else {
 			callbacks.push(() => {
 			callbacks.push(() => {
-				func();
+				subToChannel();
 			});
 			});
 		}
 		}
-		function func() {
+		function subToChannel() {
 			if (subs[channel] === undefined) {
 			if (subs[channel] === undefined) {
 				subs[channel] = { client: redis.createClient({ url: lib.url }), cbs: [] };
 				subs[channel] = { client: redis.createClient({ url: lib.url }), cbs: [] };
-				subs[channel].client.on('error', (err) => console.error(err));
+				subs[channel].client.on('error', (err) => {
+					console.error(err);
+					process.exit();
+				});
 				subs[channel].client.on('message', (channel, message) => {
 				subs[channel].client.on('message', (channel, message) => {
 					if (parseJson) try { message = JSON.parse(message); } catch (e) {}
 					if (parseJson) try { message = JSON.parse(message); } catch (e) {}
 					subs[channel].cbs.forEach((cb) => cb(message));
 					subs[channel].cbs.forEach((cb) => cb(message));
 				});
 				});
 				subs[channel].client.subscribe(channel);
 				subs[channel].client.subscribe(channel);
 			}
 			}
-			
+
 			subs[channel].cbs.push(cb);
 			subs[channel].cbs.push(cb);
 		}
 		}
 	}
 	}
 
 
 };
 };
 
 
-module.exports = lib;
+module.exports = lib;

+ 8 - 0
backend/logic/cache/schemas/officialPlaylist.js

@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = (stationId, songs) => {
+	return {
+		stationId,
+		songs
+	}
+};

+ 1 - 0
backend/logic/cache/schemas/session.js

@@ -4,6 +4,7 @@ module.exports = (sessionId, userId) => {
 	return {
 	return {
 		sessionId: sessionId,
 		sessionId: sessionId,
 		userId: userId,
 		userId: userId,
+		refreshDate: Date.now(),
 		created: Date.now()
 		created: Date.now()
 	};
 	};
 };
 };

+ 154 - 5
backend/logic/db/index.js

@@ -2,6 +2,22 @@
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
+const bluebird = require('bluebird');
+
+const regex = {
+	azAZ09_: /^[A-Za-z0-9_]+$/,
+	az09_: /^[a-z0-9_]+$/,
+	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
+	password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[$@$!%*?&])[A-Za-z\d$@$!%*?&]/,
+	ascii: /^[\x00-\x7F]+$/
+};
+
+const isLength = (string, min, max) => {
+	return !(typeof string !== 'string' || string.length < min || string.length > max);
+}
+
+mongoose.Promise = bluebird;
+
 let lib = {
 let lib = {
 
 
 	connection: null,
 	connection: null,
@@ -12,7 +28,10 @@ let lib = {
 
 
 		lib.connection = mongoose.connect(url).connection;
 		lib.connection = mongoose.connect(url).connection;
 
 
-		lib.connection.on('error', err => console.error('Database error: ' + err.message));
+		lib.connection.on('error', err => {
+			console.error('Database error: ' + err.message)
+			process.exit();
+		});
 
 
 		lib.connection.once('open', _ => {
 		lib.connection.once('open', _ => {
 
 
@@ -26,10 +45,6 @@ let lib = {
 				report: new mongoose.Schema(require(`./schemas/report`))
 				report: new mongoose.Schema(require(`./schemas/report`))
 			};
 			};
 
 
-			lib.schemas.station.path('_id').validate((id) => {
-				return /^[a-z]+$/.test(id);
-			}, 'The id can only have the letters a-z.');
-
 			lib.models = {
 			lib.models = {
 				song: mongoose.model('song', lib.schemas.song),
 				song: mongoose.model('song', lib.schemas.song),
 				queueSong: mongoose.model('queueSong', lib.schemas.queueSong),
 				queueSong: mongoose.model('queueSong', lib.schemas.queueSong),
@@ -40,8 +55,142 @@ let lib = {
 				report: mongoose.model('report', lib.schemas.report)
 				report: mongoose.model('report', lib.schemas.report)
 			};
 			};
 
 
+			lib.schemas.user.path('username').validate((username) => {
+				return (isLength(username, 2, 32) && regex.azAZ09_.test(username));
+			}, 'Invalid username.');
+
+			lib.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);
+			}, 'Invalid email.');
+
+			lib.schemas.station.path('name').validate((id) => {
+				return (isLength(id, 2, 16) && regex.az09_.test(id));
+			}, 'Invalid station name.');
+
+			lib.schemas.station.path('displayName').validate((displayName) => {
+				return (isLength(displayName, 2, 32) && regex.azAZ09_.test(displayName));
+			}, 'Invalid display name.');
+
+			lib.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.');
+
+
+			lib.schemas.station.path('owner').validate((owner, callback) => {
+				lib.models.station.count({owner: owner}, (err, c) => {
+					callback(!(err || c >= 3));
+				});
+			}, 'User already has 3 stations.');
+
+			/*
+			lib.schemas.station.path('queue').validate((queue, callback) => {
+				let totalDuration = 0;
+				queue.forEach((song) => {
+					totalDuration += song.duration;
+				});
+				return callback(totalDuration <= 3600 * 3);
+			}, 'The max length of the queue is 3 hours.');
+
+			lib.schemas.station.path('queue').validate((queue, callback) => {
+				if (queue.length === 0) return callback(true);
+				let totalDuration = 0;
+				const userId = queue[queue.length - 1].requestedBy;
+				queue.forEach((song) => {
+					if (userId === song.requestedBy) {
+						totalDuration += song.duration;
+					}
+				});
+				return callback(totalDuration <= 900);
+			}, 'The max length of songs per user is 15 minutes.');
+
+			lib.schemas.station.path('queue').validate((queue, callback) => {
+				if (queue.length === 0) return callback(true);
+				let totalSongs = 0;
+				const userId = queue[queue.length - 1].requestedBy;
+				queue.forEach((song) => {
+					if (userId === song.requestedBy) {
+						totalSongs++;
+					}
+				});
+				if (totalSongs <= 2) return callback(true);
+				if (totalSongs > 3) return callback(false);
+				if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return callback(true);
+				return callback(false);
+			}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
+			*/
+
+			let songTitle = (title) => {
+				return (isLength(title, 1, 64) && regex.ascii.test(title));
+			};
+			lib.schemas.song.path('title').validate(songTitle, 'Invalid title.');
+			lib.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
+
+			lib.schemas.song.path('artists').validate((artists) => {
+				return !(artists.length < 1 || artists.length > 10);
+			}, 'Invalid artists.');
+			lib.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, 32) && regex.ascii.test(artist) && artist !== "NONE");
+					}).length === artists.length;
+			};
+			lib.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
+			lib.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
+
+			let songGenres = (genres) => {
+				return genres.filter((genre) => {
+						return (isLength(genre, 1, 16) && regex.az09_.test(genre));
+					}).length === genres.length;
+			};
+			lib.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
+			lib.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');
+
+			lib.schemas.song.path('thumbnail').validate((thumbnail) => {
+				return isLength(thumbnail, 8, 256);
+			}, 'Invalid thumbnail.');
+			lib.schemas.queueSong.path('thumbnail').validate((thumbnail) => {
+				return isLength(thumbnail, 0, 256);
+			}, 'Invalid thumbnail.');
+
+			lib.schemas.playlist.path('displayName').validate((displayName) => {
+				return (isLength(displayName, 1, 16) && regex.ascii.test(displayName));
+			}, 'Invalid display name.');
+
+			lib.schemas.playlist.path('createdBy').validate((createdBy, callback) => {
+				lib.models.playlist.count({createdBy: createdBy}, (err, c) => {
+					callback(!(err || c >= 10));
+				});
+			}, 'Max 10 playlists per user.');
+
+			lib.schemas.playlist.path('songs').validate((songs) => {
+				return songs.length <= 2000;
+			}, 'Max 2000 songs per playlist.');
+
+			lib.schemas.playlist.path('songs').validate((songs) => {
+				if (songs.length === 0) return true;
+				return songs[0].duration <= 10800;
+			}, 'Max 3 hours per song.');
+
+			lib.schemas.report.path('description').validate((description) => {
+				return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
+			}, 'Invalid description.');
+
 			cb();
 			cb();
 		});
 		});
+	},
+
+	passwordValid: (password) => {
+		if (!isLength(password, 6, 200)) return false;
+		return regex.password.test(password);
 	}
 	}
 };
 };
 
 

+ 0 - 1
backend/logic/db/schemas/playlist.js

@@ -1,5 +1,4 @@
 module.exports = {
 module.exports = {
-	name: { type: String, lowercase: true, max: 16, min: 2 },
 	displayName: { type: String, min: 2, max: 32, required: true },
 	displayName: { type: String, min: 2, max: 32, required: true },
 	songs: { type: Array },
 	songs: { type: Array },
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },

+ 1 - 1
backend/logic/db/schemas/queueSong.js

@@ -1,5 +1,5 @@
 module.exports = {
 module.exports = {
-	_id: { type: String, unique: true, required: true },
+	songId: { type: String, min: 11, max: 11, required: true, index: true },
 	title: { type: String, required: true },
 	title: { type: String, required: true },
 	artists: [{ type: String }],
 	artists: [{ type: String }],
 	genres: [{ type: String }],
 	genres: [{ type: String }],

+ 1 - 1
backend/logic/db/schemas/report.js

@@ -1,7 +1,7 @@
 module.exports = {
 module.exports = {
 	resolved: { type: Boolean, default: false, required: true },
 	resolved: { type: Boolean, default: false, required: true },
 	songId: { type: String, required: true },
 	songId: { type: String, required: true },
-	description: { type: String, required: true },
+	description: { type: String },
 	issues: [{
 	issues: [{
 		name: String,
 		name: String,
 		reasons: Array
 		reasons: Array

+ 1 - 1
backend/logic/db/schemas/song.js

@@ -1,5 +1,5 @@
 module.exports = {
 module.exports = {
-	_id: { type: String, unique: true, required: true },
+	songId: { type: String, min: 11, max: 11, required: true, index: true },
 	title: { type: String, required: true },
 	title: { type: String, required: true },
 	artists: [{ type: String }],
 	artists: [{ type: String }],
 	genres: [{ type: String }],
 	genres: [{ type: String }],

+ 6 - 4
backend/logic/db/schemas/station.js

@@ -1,11 +1,13 @@
+const mongoose = require('mongoose');
+
 module.exports = {
 module.exports = {
-	_id: { type: String, lowercase: true, maxlength: 16, minlength: 2, index: true, unique: true, required: true },
+	name: { type: String, lowercase: true, maxlength: 16, minlength: 2, index: true, unique: true, required: true },
 	type: { type: String, enum: ["official", "community"], required: true },
 	type: { type: String, enum: ["official", "community"], required: true },
 	displayName: { type: String, minlength: 2, maxlength: 32, required: true, unique: true },
 	displayName: { type: String, minlength: 2, maxlength: 32, required: true, unique: true },
 	description: { type: String, minlength: 2, maxlength: 128, required: true },
 	description: { type: String, minlength: 2, maxlength: 128, required: true },
 	paused: { type: Boolean, default: false, required: true },
 	paused: { type: Boolean, default: false, required: true },
 	currentSong: {
 	currentSong: {
-		_id: { type: String },
+		songId: { type: String },
 		title: { type: String },
 		title: { type: String },
 		artists: [{ type: String }],
 		artists: [{ type: String }],
 		duration: { type: Number },
 		duration: { type: Number },
@@ -25,7 +27,7 @@ module.exports = {
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
 	locked: { type: Boolean, default: false },
 	locked: { type: Boolean, default: false },
 	queue: [{
 	queue: [{
-		_id: { type: String, required: true },
+		songId: { type: String, required: true },
 		title: { type: String },
 		title: { type: String },
 		artists: [{ type: String }],
 		artists: [{ type: String }],
 		duration: { type: Number },
 		duration: { type: Number },
@@ -36,6 +38,6 @@ module.exports = {
 		requestedBy: { type: String, required: true }
 		requestedBy: { type: String, required: true }
 	}],
 	}],
 	owner: { type: String },
 	owner: { type: String },
-	privatePlaylist: { type: String },
+	privatePlaylist: { type: mongoose.Schema.Types.ObjectId },
 	partyMode: { type: Boolean }
 	partyMode: { type: Boolean }
 };
 };

+ 11 - 1
backend/logic/db/schemas/user.js

@@ -1,4 +1,5 @@
 module.exports = {
 module.exports = {
+	_id: { type: String, required: true, index: true, unique: true, min: 12, max: 12 },
 	username: { type: String, required: true },
 	username: { type: String, required: true },
 	role: { type: String, default: 'default', required: true },
 	role: { type: String, default: 'default', required: true },
 	email: {
 	email: {
@@ -8,10 +9,19 @@ module.exports = {
 	},
 	},
 	services: {
 	services: {
 		password: {
 		password: {
-			password: String
+			password: String,
+			reset: {
+				code: { type: String, min: 8, max: 8 },
+				expires: { type: Date }
+			},
+			set: {
+				code: { type: String, min: 8, max: 8 },
+				expires: { type: Date }
+			}
 		},
 		},
 		github: {
 		github: {
 			id: Number,
 			id: Number,
+			access_token: String
 		}
 		}
 	},
 	},
 	ban: {
 	ban: {

+ 21 - 6
backend/logic/io.js

@@ -4,6 +4,7 @@
 
 
 const app = require('./app');
 const app = require('./app');
 const actions = require('./actions');
 const actions = require('./actions');
+const async = require('async');
 const cache = require('./cache');
 const cache = require('./cache');
 const utils = require('./utils');
 const utils = require('./utils');
 const db = require('./db');
 const db = require('./db');
@@ -20,12 +21,25 @@ module.exports = {
 			let cookies = socket.request.headers.cookie;
 			let cookies = socket.request.headers.cookie;
 			let SID = utils.cookies.parseCookies(cookies).SID;
 			let SID = utils.cookies.parseCookies(cookies).SID;
 
 
-			if (!SID) SID = "NONE";
-			cache.hget('sessions', SID, (err, session) => {
-				if (err) SID = null;
-				socket.session = (session) ? session : {};
-				socket.session.socketId = socket.id;
-				return next();
+			async.waterfall([
+				(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);
+				}
+			], () => {
+				if (!socket.session) {
+					socket.session = {socketId: socket.id};
+				} else socket.session.socketId = socket.id;
+				next();
 			});
 			});
 		});
 		});
 
 
@@ -90,6 +104,7 @@ module.exports = {
 					if (err && err !== true) socket.emit('ready', false);
 					if (err && err !== true) socket.emit('ready', false);
 					else if (session && session.userId) {
 					else if (session && session.userId) {
 						db.models.user.findOne({ _id: session.userId }, (err, user) => {
 						db.models.user.findOne({ _id: session.userId }, (err, user) => {
+							if (err || !user) return socket.emit('ready', false);
 							let role = '';
 							let role = '';
 							let username = '';
 							let username = '';
 							let userId = '';
 							let userId = '';

+ 173 - 0
backend/logic/logger.js

@@ -0,0 +1,173 @@
+'use strict';
+
+const dir = `${__dirname}/../../log`;
+const fs = require('fs');
+const config = require('config');
+let utils;
+
+if (!config.isDocker && !fs.existsSync(`${dir}`)) {
+	fs.mkdirSync(dir);
+}
+
+let started;
+let success = 0;
+let successThisMinute = 0;
+let successThisHour = 0;
+let error = 0;
+let errorThisMinute = 0;
+let errorThisHour = 0;
+let info = 0;
+let infoThisMinute = 0;
+let infoThisHour = 0;
+
+let successUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
+let errorUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
+let infoUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
+
+let successUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
+let errorUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
+let infoUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
+
+function calculateUnits(units, unit) {
+	units.push(unit);
+	if (units.length > 10) units.shift();
+	return units;
+}
+
+function calculateHourUnits() {
+	successUnitsPerHour = calculateUnits(successUnitsPerHour, successThisHour);
+	errorUnitsPerHour = calculateUnits(errorUnitsPerHour, errorThisHour);
+	infoUnitsPerHour = calculateUnits(infoUnitsPerHour, infoThisHour);
+
+	successThisHour = 0;
+	errorThisHour = 0;
+	infoThisHour = 0;
+
+	utils.emitToRoom('admin.statistics', 'event:admin.statistics.success.units.hour', successUnitsPerHour);
+	utils.emitToRoom('admin.statistics', 'event:admin.statistics.error.units.hour', errorUnitsPerHour);
+	utils.emitToRoom('admin.statistics', 'event:admin.statistics.info.units.hour', infoUnitsPerHour);
+
+	setTimeout(calculateHourUnits, 1000 * 60 * 60)
+}
+
+function calculateMinuteUnits() {
+	successUnitsPerMinute = calculateUnits(successUnitsPerMinute, successThisMinute);
+	errorUnitsPerMinute = calculateUnits(errorUnitsPerMinute, errorThisMinute);
+	infoUnitsPerMinute = calculateUnits(infoUnitsPerMinute, infoThisMinute);
+
+	successThisMinute = 0;
+	errorThisMinute = 0;
+	infoThisMinute = 0;
+
+	utils.emitToRoom('admin.statistics', 'event:admin.statistics.success.units.minute', successUnitsPerMinute);
+	utils.emitToRoom('admin.statistics', 'event:admin.statistics.error.units.minute', errorUnitsPerMinute);
+	utils.emitToRoom('admin.statistics', 'event:admin.statistics.info.units.minute', infoUnitsPerMinute);
+	
+	setTimeout(calculateMinuteUnits, 1000 * 60)
+}
+
+let twoDigits = (num) => {
+	return (num < 10) ? '0' + num : num;
+};
+
+let getTime = (cb) => {
+	let time = new Date();
+	return cb ({
+		year: time.getFullYear(),
+		month: time.getMonth() + 1,
+		day: time.getDate(),
+		hour: time.getHours(),
+		minute: time.getMinutes(),
+		second: time.getSeconds()
+	});
+};
+
+module.exports = {
+	init: function(cb) {
+		utils = require('./utils');
+		started = Date.now();
+
+		setTimeout(calculateMinuteUnits, 1000 * 60);
+		setTimeout(calculateHourUnits, 1000 * 60 * 60);
+		setTimeout(this.calculate, 1000 * 30);
+
+		cb();
+	},
+	success: (type, message) => {
+		success++;
+		successThisMinute++;
+		successThisHour++;
+		getTime((time) => {
+			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
+			fs.appendFile(dir + '/all.log', `${timeString} SUCCESS - ${type} - ${message}\n`, ()=>{});
+			fs.appendFile(dir + '/success.log', `${timeString} SUCCESS - ${type} - ${message}\n`, ()=>{});
+			console.info('\x1b[32m', timeString, 'SUCCESS', '-', type, '-', message, '\x1b[0m');
+		});
+	},
+	error: (type, message) => {
+		error++;
+		errorThisMinute++;
+		errorThisHour++;
+		getTime((time) => {
+			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
+			fs.appendFile(dir + '/all.log', `${timeString} ERROR - ${type} - ${message}\n`, ()=>{});
+			fs.appendFile(dir + '/error.log', `${timeString} ERROR - ${type} - ${message}\n`, ()=>{});
+			console.warn('\x1b[31m', timeString, 'ERROR', '-', type, '-', message, '\x1b[0m');
+		});
+	},
+	info: (type, message) => {
+		info++;
+		infoThisMinute++;
+		infoThisHour++;
+		getTime((time) => {
+			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
+			fs.appendFile(dir + '/all.log', `${timeString} INFO - ${type} - ${message}\n`, ()=>{});
+			fs.appendFile(dir + '/info.log', `${timeString} INFO - ${type} - ${message}\n`, ()=>{});
+
+			console.info('\x1b[36m', timeString, 'INFO', '-', type, '-', message, '\x1b[0m');
+		});
+	},
+	calculatePerSecond: function(number) {
+		let secondsRunning = Math.floor((Date.now() - started) / 1000);
+		let perSecond = number / secondsRunning;
+		return perSecond;
+	},
+	calculatePerMinute: function(number) {
+		let perMinute = this.calculatePerSecond(number) * 60;
+		return perMinute;
+	},
+	calculatePerHour: function(number) {
+		let perHour = this.calculatePerMinute(number) * 60;
+		return perHour;
+	},
+	calculatePerDay: function(number) {
+		let perDay = this.calculatePerHour(number) * 24;
+		return perDay;
+	},
+	calculate: function() {
+		let _this = module.exports;
+		utils.emitToRoom('admin.statistics', 'event:admin.statistics.logs', {
+			second: {
+				success: _this.calculatePerSecond(success),
+				error: _this.calculatePerSecond(error),
+				info: _this.calculatePerSecond(info)
+			},
+			minute: {
+				success: _this.calculatePerMinute(success),
+				error: _this.calculatePerMinute(error),
+				info: _this.calculatePerMinute(info)
+			},
+			hour: {
+				success: _this.calculatePerHour(success),
+				error: _this.calculatePerHour(error),
+				info: _this.calculatePerHour(info)
+			},
+			day: {
+				success: _this.calculatePerDay(success),
+				error: _this.calculatePerDay(error),
+				info: _this.calculatePerDay(info)
+			}
+		});
+		setTimeout(_this.calculate, 1000 * 30);
+	}
+};

+ 26 - 0
backend/logic/mail/index.js

@@ -0,0 +1,26 @@
+'use strict';
+
+const config = require('config');
+const mailgun = require('mailgun-js')({apiKey: config.get("apis.mailgun.key"), domain: config.get("apis.mailgun.domain")});
+
+let lib = {
+
+	schemas: {},
+
+	init: (cb) => {
+		lib.schemas = {
+			verifyEmail: require('./schemas/verifyEmail'),
+			resetPasswordRequest: require('./schemas/resetPasswordRequest'),
+			passwordRequest: require('./schemas/passwordRequest')
+		};
+
+		cb();
+	},
+
+	sendMail: (data, cb) => {
+		if (!cb) cb = ()=>{};
+		mailgun.messages().send(data, cb);
+	}
+};
+
+module.exports = lib;

+ 30 - 0
backend/logic/mail/schemas/passwordRequest.js

@@ -0,0 +1,30 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a request password email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the password code of the recipient
+ * @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:
+			`
+				Hello there ${username},
+				<br>
+				<br>
+				Someone has requested to add a password to your account. If this was not you, you can ignore this email.
+				<br>
+				<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);
+};

+ 30 - 0
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -0,0 +1,30 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a request password reset email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the password reset code of the recipient
+ * @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:
+			`
+				Hello there ${username},
+				<br>
+				<br>
+				Someone has requested to reset the password of your account. If this was not you, you can ignore this email.
+				<br>
+				<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);
+};

+ 27 - 0
backend/logic/mail/schemas/verifyEmail.js

@@ -0,0 +1,27 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a verify email email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the email reset code of the recipient
+ * @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:
+			`
+				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>.
+			`
+	};
+
+	mail.sendMail(data, cb);
+};

+ 5 - 2
backend/logic/notifications.js

@@ -19,7 +19,10 @@ const lib = {
 	init: (url, cb) => {
 	init: (url, cb) => {
 		pub = redis.createClient({ url: url });
 		pub = redis.createClient({ url: url });
 		sub = redis.createClient({ url: url });
 		sub = redis.createClient({ url: url });
-		sub.on('error', (err) => console.error);
+		sub.on('error', (err) => {
+			console.error(err);
+			process.exit();
+		});
 		sub.on('pmessage', (pattern, channel, expiredKey) => {
 		sub.on('pmessage', (pattern, channel, expiredKey) => {
 			subscriptions.forEach((sub) => {
 			subscriptions.forEach((sub) => {
 				if (sub.name !== expiredKey) return;
 				if (sub.name !== expiredKey) return;
@@ -40,7 +43,7 @@ const lib = {
 	 * @param {Function} cb - gets called when the notification has been scheduled
 	 * @param {Function} cb - gets called when the notification has been scheduled
 	 */
 	 */
 	schedule: (name, time, cb) => {
 	schedule: (name, time, cb) => {
-		console.log(time);
+		time = Math.round(time);
 		pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
 		pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
 	},
 	},
 
 

+ 103 - 19
backend/logic/playlists.js

@@ -6,33 +6,88 @@ const async = require('async');
 
 
 module.exports = {
 module.exports = {
 
 
+	/**
+	 * Initializes the playlists module, and exits if it is unsuccessful
+	 *
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
 	init: cb => {
 	init: cb => {
-		db.models.playlist.find({}, (err, playlists) => {
-			if (!err) {
-				playlists.forEach((playlist) => {
-					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist));
-				});
+		async.waterfall([
+			(next) => {
+				cache.hgetall('playlists', next);
+			},
+
+			(playlists, next) => {
+				if (!playlists) return next();
+				let playlistIds = Object.keys(playlists);
+				async.each(playlistIds, (playlistId, next) => {
+					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+						if (err) next(err);
+						else if (!playlist) {
+							cache.hdel('playlists', playlistId, next);
+						}
+						else next();
+					});
+				}, next);
+			},
+
+			(next) => {
+				db.models.playlist.find({}, next);
+			},
+
+			(playlists, next) => {
+				async.each(playlists, (playlist, next) => {
+					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist), next);
+				}, next);
+			}
+		], (err) => {
+			if (err) {
+				console.log(`FAILED TO INITIALIZE PLAYLISTS. ABORTING. "${err.message}"`);
+				process.exit();
+			} else {
 				cb();
 				cb();
 			}
 			}
 		});
 		});
 	},
 	},
 
 
-	getPlaylist: (_id, cb) => {
+	/**
+	 * 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
+	 */
+	getPlaylist: (playlistId, cb) => {
 		async.waterfall([
 		async.waterfall([
+			(next) => {
+				cache.hgetall('playlists', next);
+			},
+
+			(playlists, next) => {
+				if (!playlists) return next();
+				let playlistIds = Object.keys(playlists);
+				async.each(playlistIds, (playlistId, next) => {
+					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+						if (err) next(err);
+						else if (!playlist) {
+							cache.hdel('playlists', playlistId, next);
+						}
+						else next();
+					});
+				}, next);
+			},
 
 
 			(next) => {
 			(next) => {
-				cache.hget('playlists', _id, next);
+				cache.hget('playlists', playlistId, next);
 			},
 			},
 
 
 			(playlist, next) => {
 			(playlist, next) => {
 				if (playlist) return next(true, playlist);
 				if (playlist) return next(true, playlist);
-				db.models.playlist.findOne({ _id }, next);
+				db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 			},
 
 
 			(playlist, next) => {
 			(playlist, next) => {
 				if (playlist) {
 				if (playlist) {
-					cache.hset('playlists', _id, playlist);
-					next(true, playlist);
+					cache.hset('playlists', playlistId, playlist, next);
 				} else next('Playlist not found');
 				} else next('Playlist not found');
 			},
 			},
 
 
@@ -42,25 +97,54 @@ module.exports = {
 		});
 		});
 	},
 	},
 
 
-	updatePlaylist: (_id, cb) => {
+	/**
+	 * 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
+	 */
+	updatePlaylist: (playlistId, cb) => {
 		async.waterfall([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.playlist.findOne({ _id }, next);
+				db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 			},
 
 
 			(playlist, next) => {
 			(playlist, next) => {
-				if (!playlist) return next('Playlist not found');
-				cache.hset('playlists', _id, playlist, (err) => {
-					if (err) return next(err);
-					return next(null, playlist);
-				});
+				if (!playlist) {
+					cache.hdel('playlists', playlistId);
+					return next('Playlist not found');
+				}
+				cache.hset('playlists', playlistId, playlist, next);
 			}
 			}
 
 
 		], (err, playlist) => {
 		], (err, playlist) => {
-			if (err && err !== true) cb(err);
+			if (err && err !== true) return cb(err);
 			cb(null, playlist);
 			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
+	 */
+	deletePlaylist: (playlistId, cb) => {
+		async.waterfall([
+
+			(next) => {
+				db.models.playlist.remove({ _id: playlistId }, next);
+			},
+
+			(res, next) => {
+				cache.hdel('playlists', playlistId, next);
+			}
 
 
+		], (err) => {
+			if (err && err !== true) return cb(err);
+
+			cb(null);
+		});
+	}
 };
 };

+ 106 - 23
backend/logic/songs.js

@@ -5,38 +5,72 @@ const db = require('./db');
 const io = require('./io');
 const io = require('./io');
 const utils = require('./utils');
 const utils = require('./utils');
 const async = require('async');
 const async = require('async');
+const mongoose = require('mongoose');
 
 
 module.exports = {
 module.exports = {
 
 
+	/**
+	 * Initializes the songs module, and exits if it is unsuccessful
+	 *
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
 	init: cb => {
 	init: cb => {
-		db.models.song.find({}, (err, songs) => {
-			if (!err) {
-				songs.forEach((song) => {
-					cache.hset('songs', song._id, cache.schemas.song(song));
-				});
-				cb();
+		async.waterfall([
+			(next) => {
+				cache.hgetall('songs', next);
+			},
+
+			(songs, next) => {
+				if (!songs) return next();
+				let songIds = Object.keys(songs);
+				async.each(songIds, (songId, next) => {
+					db.models.song.findOne({songId}, (err, song) => {
+						if (err) next(err);
+						else if (!song) cache.hdel('songs', songId, next);
+						else next();
+					});
+				}, next);
+			},
+
+			(next) => {
+				db.models.song.find({}, next);
+			},
+
+			(songs, next) => {
+				async.each(songs, (song, next) => {
+					cache.hset('songs', song.songId, cache.schemas.song(song), next);
+				}, next);
 			}
 			}
+		], (err) => {
+			if (err) {
+				console.log(`FAILED TO INITIALIZE SONGS. ABORTING. "${err.message}"`);
+				process.exit();
+			} else cb();
 		});
 		});
 	},
 	},
 
 
-	// Attempts to get the song from Reids. If it's not in Redis, get it from Mongo and add it to Redis.
-	getSong: function(_id, cb) {
+	/**
+	 * 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
+	 */
+	getSong: function(id, cb) {
 		async.waterfall([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				cache.hget('songs', _id, next);
+				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
+				cache.hget('songs', id, next);
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
 				if (song) return next(true, song);
 				if (song) return next(true, song);
-
-				db.models.song.findOne({ _id }, next);
+				db.models.song.findOne({_id: id}, next);
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
 				if (song) {
 				if (song) {
-					cache.hset('songs', _id, song);
-					next(true, song);
+					cache.hset('songs', id, song, next);
 				} else next('Song not found.');
 				} else next('Song not found.');
 			},
 			},
 
 
@@ -47,27 +81,76 @@ module.exports = {
 		});
 		});
 	},
 	},
 
 
-	updateSong: (_id, cb) => {
+	/**
+	 * 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 id of the song we are trying to get
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
+	getSongFromId: function(songId, cb) {
+		async.waterfall([
+
+			(next) => {
+				db.models.song.findOne({songId}, next);
+			}
+
+		], (err, song) => {
+			if (err && err !== true) return cb(err);
+
+			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
+	 */
+	updateSong: (songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.song.findOne({ _id }, next);
+				db.models.song.findOne({_id: songId}, next);
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
-				if (!song) return next('Song not found.');
+				if (!song) {
+					cache.hdel('songs', songId);
+					return next('Song not found.');
+				}
 
 
-				cache.hset('songs', _id, song, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
+				cache.hset('songs', songId, song, next);
 			}
 			}
 
 
 		], (err, song) => {
 		], (err, song) => {
-			if (err && err !== true) cb(err);
+			if (err && err !== true) return cb(err);
 
 
 			cb(null, song);
 			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
+	 */
+	deleteSong: (songId, cb) => {
+		async.waterfall([
+
+			(next) => {
+				db.models.song.remove({ songId }, next);
+			},
+
+			(next) => {
+				cache.hdel('songs', songId, next);
+			}
+
+		], (err) => {
+			if (err && err !== true) cb(err);
+
+			cb(null);
+		});
+	}
+};

+ 322 - 290
backend/logic/stations.js

@@ -4,10 +4,10 @@ const cache = require('./cache');
 const db = require('./db');
 const db = require('./db');
 const io = require('./io');
 const io = require('./io');
 const utils = require('./utils');
 const utils = require('./utils');
+const logger = require('./logger');
 const songs = require('./songs');
 const songs = require('./songs');
 const notifications = require('./notifications');
 const notifications = require('./notifications');
 const async = require('async');
 const async = require('async');
-let skipTimeout = null;
 
 
 //TEMP
 //TEMP
 cache.sub('station.pause', (stationId) => {
 cache.sub('station.pause', (stationId) => {
@@ -26,63 +26,99 @@ cache.sub('station.queueUpdate', (stationId) => {
 	});
 	});
 });
 });
 
 
+cache.sub('station.newOfficialPlaylist', (stationId) => {
+	cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
+		if (!err && playlistObj) {
+			utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
+		}
+	})
+});
+
 module.exports = {
 module.exports = {
 
 
 	init: function(cb) {
 	init: function(cb) {
-		let _this = this;
-		//TODO Add async waterfall
-		db.models.station.find({}, (err, stations) => {
-			if (!err) {
-				stations.forEach((station) => {
-					console.info("Initializing Station: " + station._id);
-					_this.initializeStation(station._id);
-				});
-				cb();
+		async.waterfall([
+			(next) => {
+				cache.hgetall('stations', next);
+			},
+
+			(stations, next) => {
+				if (!stations) return next();
+				let stationIds = Object.keys(stations);
+				async.each(stationIds, (stationId, next) => {
+					db.models.station.findOne({_id: stationId}, (err, station) => {
+						if (err) next(err);
+						else if (!station) {
+							cache.hdel('stations', stationId, next);
+						} else next();
+					});
+				}, next);
+			},
+
+			(next) => {
+				db.models.station.find({}, next);
+			},
+
+			(stations, next) => {
+				async.each(stations, (station, next) => {
+					async.waterfall([
+						(next) => {
+							cache.hset('stations', station._id, cache.schemas.station(station), next);
+						},
+
+						(station, next) => {
+							this.initializeStation(station._id, next);
+						}
+					], (err) => {
+						next(err);
+					});
+				}, next);
 			}
 			}
+		], (err) => {
+			if (err) {
+				console.log(`FAILED TO INITIALIZE STATIONS. ABORTING. "${err.message}"`);
+				process.exit();
+			} else cb();
 		});
 		});
 	},
 	},
 
 
 	initializeStation: function(stationId, cb) {
 	initializeStation: function(stationId, cb) {
-		console.log(112233, stationId, cb);
 		if (typeof cb !== 'function') cb = ()=>{};
 		if (typeof cb !== 'function') cb = ()=>{};
 		let _this = this;
 		let _this = this;
-		_this.getStation(stationId, (err, station) => {
-			if (!err) {
-				console.log("###");
-				if (station) {
-					console.log("###1");
-					let notification = notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true);
-					if (!station.paused ) {
-						/*if (!station.startedAt) {
-							station.startedAt = Date.now();
-							station.timePaused = 0;
-							cache.hset('stations', stationId, station);
-						}*/
-						if (station.currentSong) {
-							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) {
-								console.log("Test");
-								this.skipStation(station._id)((err, station) => {
-									console.log(45, err, station);
-									cb(err, station);
-								});
-							} else {
-								notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft);
-								cb(null, station);
-							}
-						} else {
-							_this.skipStation(station._id)((err, station) => {
-								console.log(47, err, station);
-								cb(err, station);
-							});
-						}
-					} else {
-						notifications.unschedule(`stations.nextSong?id${station._id}`);
-						cb(null, station);
-					}
-				} else cb("Station not found.");
-			} else cb(err);
+		async.waterfall([
+			(next) => {
+				_this.getStation(stationId, next);
+			},
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true);
+				if (station.paused) {
+					notifications.unschedule(`stations.nextSong?id${station._id}`);
+					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);
+					});
+				}
+				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);
+					});
+				} else {
+					notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft);
+					next(null, station);
+				}
+			}
+		], (err, station) => {
+			if (err && err !== true) return cb(err);
+			cb(null, station);
 		});
 		});
 	},
 	},
 
 
@@ -101,7 +137,6 @@ module.exports = {
 									let found = false;
 									let found = false;
 									song.genres.forEach((songGenre) => {
 									song.genres.forEach((songGenre) => {
 										if (station.blacklistedGenres.indexOf(songGenre) !== -1) found = true;
 										if (station.blacklistedGenres.indexOf(songGenre) !== -1) found = true;
-										console.log(songGenre, station.blacklistedGenres, station.blacklistedGenres.indexOf(songGenre), found);
 									});
 									});
 									if (!found) {
 									if (!found) {
 										songList.push(song._id);
 										songList.push(song._id);
@@ -110,9 +145,7 @@ module.exports = {
 							});
 							});
 						}
 						}
 						genresDone.push(genre);
 						genresDone.push(genre);
-						if (genresDone.length === station.genres.length) {
-							next();
-						}
+						if (genresDone.length === station.genres.length) next();
 					});
 					});
 				});
 				});
 			},
 			},
@@ -125,7 +158,16 @@ module.exports = {
 				station.playlist.filter((songId) => {
 				station.playlist.filter((songId) => {
 					if (songList.indexOf(songId) !== -1) playlist.push(songId);
 					if (songList.indexOf(songId) !== -1) playlist.push(songId);
 				});
 				});
-				db.models.station.update({_id: station._id}, {$set: {playlist: playlist}}, (err) => {
+
+				playlist = utils.shuffle(playlist);
+
+				_this.calculateOfficialPlaylistList(station._id, playlist, () => {
+					next(null, playlist);
+				});
+			},
+
+			(playlist, next) => {
+				db.models.station.update({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
 					_this.updateStation(station._id, () => {
 					_this.updateStation(station._id, () => {
 						next(err, playlist);
 						next(err, playlist);
 					});
 					});
@@ -139,6 +181,7 @@ module.exports = {
 
 
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
 	getStation: function(stationId, cb) {
 	getStation: function(stationId, cb) {
+		let _this = this;
 		async.waterfall([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
@@ -147,27 +190,54 @@ module.exports = {
 
 
 			(station, next) => {
 			(station, next) => {
 				if (station) return next(true, station);
 				if (station) return next(true, station);
-
 				db.models.station.findOne({ _id: stationId }, next);
 				db.models.station.findOne({ _id: stationId }, next);
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
 				if (station) {
 				if (station) {
+					if (station.type === 'official') {
+						_this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
+					}
 					station = cache.schemas.station(station);
 					station = cache.schemas.station(station);
-					console.log(1234321, stationId);
 					cache.hset('stations', stationId, station);
 					cache.hset('stations', stationId, station);
 					next(true, station);
 					next(true, station);
-				} else next('Station not found.');
+				} else next('Station not found');
 			},
 			},
 
 
 		], (err, station) => {
 		], (err, station) => {
-			if (err && err !== true) cb(err);
+			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.
+	getStationByName: function(stationName, cb) {
+		let _this = this;
+		async.waterfall([
+
+			(next) => {
+				db.models.station.findOne({name: stationName}, next);
+			},
 
 
+			(station, next) => {
+				if (station) {
+					if (station.type === 'official') {
+						_this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
+					}
+					station = cache.schemas.station(station);
+					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);
 			cb(null, station);
 		});
 		});
 	},
 	},
 
 
-	updateStation: (stationId, cb) => {
+	updateStation: function(stationId, cb) {
+		let _this = this;
 		async.waterfall([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
@@ -175,264 +245,226 @@ module.exports = {
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
-				if (!station) return next('Station not found.');
-				console.log(123444321, stationId);
-				cache.hset('stations', stationId, station, (err) => {
-					if (err) return next(err);
-					next(null, station);
-				});
+				if (!station) {
+					cache.hdel('stations', stationId);
+					return next('Station not found');
+				}
+				cache.hset('stations', stationId, station, next);
 			}
 			}
 
 
 		], (err, station) => {
 		], (err, station) => {
-			if (err && err !== true) cb(err);
-
+			if (err && err !== true) return cb(err);
 			cb(null, station);
 			cb(null, station);
 		});
 		});
 	},
 	},
 
 
+	calculateOfficialPlaylistList: (stationId, songList, cb) => {
+		let lessInfoPlaylist = [];
+
+		async.each(songList, (song, next) => {
+			songs.getSongFromId(song, (err, song) => {
+				if (!err && song) {
+					let newSong = {
+						songId: song.songId,
+						title: song.title,
+						artists: song.artists,
+						duration: song.duration
+					};
+					lessInfoPlaylist.push(newSong);
+				}
+				next();
+			});
+		}, () => {
+			cache.hset("officialPlaylists", stationId, cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
+				cache.pub("station.newOfficialPlaylist", stationId);
+				cb();
+			});
+		});
+	},
+
 	skipStation: function(stationId) {
 	skipStation: function(stationId) {
+		console.log("SKIP!", stationId);
 		let _this = this;
 		let _this = this;
 		return (cb) => {
 		return (cb) => {
 			if (typeof cb !== 'function') cb = ()=>{};
 			if (typeof cb !== 'function') cb = ()=>{};
-			console.log("###2");
-			console.log("NOTIFICATION!!!");
-			_this.getStation(stationId, (err, station) => {
-				console.log("###3");
-				if (station) {
-					console.log("###4");
-					// notify all the sockets on this station to go to the next song
-					async.waterfall([
 
 
-						(next) => {
-							console.log("###5");
-							if (station.type === "official") {
-								if (station.playlist.length > 0) {
-									function func() {
-										if (station.currentSongIndex < station.playlist.length - 1) {
-											songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
-												if (!err) {
-													let $set = {};
-
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														artists: song.artists,
-														duration: song.duration,
-														likes: song.likes,
-														dislikes: song.dislikes,
-														skipDuration: song.skipDuration,
-														thumbnail: song.thumbnail
-													};
-													$set.startedAt = Date.now();
-													$set.timePaused = 0;
-													$set.currentSongIndex = station.currentSongIndex + 1;
-													next(null, $set);
-												} else {
-													db.models.station.update({_id: station._id}, {$inc: {currentSongIndex: 1}}, (err) => {
-														_this.updateStation(station._id, () => {
-															func();
-														});
-													});
-												}
-											});
-										} else {
-											db.models.station.update({_id: station._id}, {$set: {currentSongIndex: 0}}, (err) => {
-												_this.updateStation(station._id, (err, station) => {
-													console.log(12345678, err, station);
-													_this.calculateSongForStation(station, (err, newPlaylist) => {
-														console.log('New playlist: ', newPlaylist);
-														if (!err) {
-															songs.getSong(newPlaylist[0], (err, song) => {
-																let $set = {};
-																if (song) {
-																	$set.currentSong = {
-																		_id: song._id,
-																		title: song.title,
-																		artists: song.artists,
-																		duration: song.duration,
-																		likes: song.likes,
-																		dislikes: song.dislikes,
-																		skipDuration: song.skipDuration,
-																		thumbnail: song.thumbnail
-																	};
-																	station.playlist = newPlaylist;
-																} else {
-																	$set.currentSong = _this.defaultSong;
-																}
-																$set.startedAt = Date.now();
-																$set.timePaused = 0;
-																next(null, $set);
-															});
-														} else {
-															let $set = {};
-															$set.currentSong = _this.defaultSong;
-															$set.startedAt = Date.now();
-															$set.timePaused = 0;
-															next(null, $set);
-														}
-													})
-												});
-											});
-										}
+			async.waterfall([
+				(next) => {
+					_this.getStation(stationId, next);
+				},
+				(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
+						return db.models.station.update({_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 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);
 									}
 									}
-
-									func();
-								} else {
-									_this.calculateSongForStation(station, (err, playlist) => {
-										if (!err && playlist.length === 0) {
-											let $set = {};
-											$set.currentSongIndex = 0;
-											$set.currentSong = _this.defaultSong;
-											$set.startedAt = Date.now();
-											$set.timePaused = 0;
-											next(null, $set);
-										} else {
-											songs.getSong(playlist[0], (err, song) => {
-												let $set = {};
-												if (!err) {
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														artists: song.artists,
-														duration: song.duration,
-														likes: song.likes,
-														dislikes: song.dislikes,
-														skipDuration: song.skipDuration,
-														thumbnail: song.thumbnail
-													};
-												} else {
-													$set.currentSong = _this.defaultSong;
-												}
-												$set.currentSongIndex = 0;
-												$set.startedAt = Date.now();
-												$set.timePaused = 0;
-												next(null, $set);
-											});
-										}
-									});
-								}
-							} else {
-								if (station.partyMode === true) {
-									if (station.queue.length > 0) {
-										console.log("##");
-										db.models.station.update({_id: stationId}, {$pull: {queue: {songId: station.queue[0]._id}}}, (err) => {
-											console.log("##1", err);
-											if (err) return next(err);
-											let $set = {};
-											$set.currentSong = station.queue[0];
-											$set.startedAt = Date.now();
-											$set.timePaused = 0;
-											if (station.paused) {
-												$set.pausedAt = Date.now();
-											}
-											next(null, $set);
-										});
-									} else {
-										console.log("##2");
-										next(null, {currentSong: null});
+								};
+								if (playlist[currentSongIndex]._id) songs.getSong(playlist[currentSongIndex]._id, callback);
+								else 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 {
+								songs.getSong(playlist[0], (err, song) => {
+									if (err || !song) return next(null, _this.defaultSong, 0, station);
+									return next(null, song, 0, station);
+								});
+							}
+						});
+					}
+					if (station.type === 'official' && station.playlist.length > 0) {
+						async.doUntil((next) => {
+							if (station.currentSongIndex < station.playlist.length - 1) {
+								songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
+									if (!err) return next(null, song, station.currentSongIndex + 1);
+									else {
+										station.currentSongIndex++;
+										next(null, null);
 									}
 									}
-								} else {
-									db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
-										console.log(station.privatePlaylist, err, playlist);
-										if (err || !playlist) return next(null, {currentSong: null});
-										playlist = playlist.songs;
-										if (playlist.length > 0) {
-											let $set = {};
-											if (station.currentSongIndex < playlist.length - 1) {
-												$set.currentSongIndex = station.currentSongIndex + 1;
-											} else {
-												$set.currentSongIndex = 0;
-											}
-											songs.getSong(playlist[$set.currentSongIndex]._id, (err, song) => {
-												if (!err && song) {
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														artists: song.artists,
-														duration: song.duration,
-														likes: song.likes,
-														dislikes: song.dislikes,
-														skipDuration: song.skipDuration,
-														thumbnail: song.thumbnail
-													};
-												} else {
-													let song = playlist[$set.currentSongIndex];
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														duration: song.duration,
-														likes: -1,
-														dislikes: -1
-													};
-												}
-												$set.startedAt = Date.now();
-												$set.timePaused = 0;
-												next(null, $set);
-											});
-										} else {
-											next(null, {currentSong: null});
-										}
+								});
+							} else {
+								_this.calculateSongForStation(station, (err, newPlaylist) => {
+									if (err) return next(null, _this.defaultSong, 0);
+									songs.getSong(newPlaylist[0], (err, song) => {
+										if (err || !song) return next(null, _this.defaultSong, 0);
+										station.playlist = newPlaylist;
+										next(null, song, 0);
 									});
 									});
-								}
+								});
 							}
 							}
-						},
+						}, (song) => {
+							return !!song;
+						}, (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,
+							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) => {
+					db.models.station.update({_id: station._id}, {$set}, (err) => {
+						_this.updateStation(station._id, (err, station) => {
+							if (station.type === 'community' && station.partyMode === true)
+								cache.pub('station.queueUpdate', stationId);
+							next(null, station);
+						});
+					});
+				},
+			], (err, station) => {
+				if (!err) {
+					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
+						station.currentSong.skipVotes = 0;
+					}
+					//TODO Pub/Sub this
+					utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
+						currentSong: station.currentSong,
+						startedAt: station.startedAt,
+						paused: station.paused,
+						timePaused: 0
+					});
 
 
-						($set, next) => {
-							console.log("$set", $set);
-							db.models.station.update({_id: station._id}, {$set}, (err) => {
-								console.log("##2.5", err);
-								_this.updateStation(station._id, (err, station) => {
-									console.log("##2.6", err);
-									if (station.type === 'community' && station.partyMode === true) {
-										cache.pub('station.queueUpdate', stationId);
+					if (station.privacy === 'public') utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
+					else {
+						let sockets = 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 (!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);
+											}
+										});
 									}
 									}
-									next(null, station);
 								});
 								});
-							});
-						},
-
-
-					], (err, station) => {
-						console.log("##3", err);
-						if (!err) {
-							if (station.currentSong !== null && station.currentSong._id !== undefined) {
-								station.currentSong.skipVotes = 0;
-							}
-							utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
-								currentSong: station.currentSong,
-								startedAt: station.startedAt,
-								paused: station.paused,
-								timePaused: 0
-							});
-							if (station.currentSong !== null && station.currentSong._id !== undefined) {
-								utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong._id}`);
-								console.log("NEXT SONG!!!", station.currentSong);
-								if (!station.paused) {
-									notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000);
-								}
-							} else {
-								console.log("22", !!(station.currentSong));
-								utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong._id}`);
 							}
 							}
-							cb(null, station);
-						} else cb(err);
-					});
-				}
-				// the station doesn't exist anymore, unsubscribe from it
-				else {
-					cb("Station not found.");
+						}
+					}
+					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
+						utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
+						if (!station.paused) {
+							notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000);
+						}
+					} else {
+						utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`));
+					}
+					cb(null, station);
+				} else {
+					err = utils.getError(err);
+					logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
+					cb(err);
 				}
 				}
 			});
 			});
 		}
 		}
 	},
 	},
 
 
 	defaultSong: {
 	defaultSong: {
-		_id: '60ItHLz5WEA',
-		title: 'Faded',
-		artists: ['Alan Walker'],
+		songId: '60ItHLz5WEA',
+		title: 'Faded - Alan Walker',
 		duration: 212,
 		duration: 212,
-		skipDuration: 0,
-		thumbnail: 'https://i.scdn.co/image/2ddde58427f632037093857ebb71a67ddbdec34b'
+		likes: -1,
+		dislikes: -1
 	}
 	}
 
 
-};
+};

+ 128 - 0
backend/logic/tasks.js

@@ -0,0 +1,128 @@
+'use strict';
+
+const cache = require("./cache");
+const logger = require("./logger");
+const Stations = require("./stations");
+const async = require("async");
+let utils;
+let tasks = {};
+
+let testTask = (callback) => {
+	//Stuff
+	console.log("Starting task");
+	setTimeout(() => {
+		console.log("Callback");
+		callback();
+	}, 10000);
+};
+
+let checkStationSkipTask = (callback) => {
+	logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`);
+	async.waterfall([
+		(next) => {
+			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 {
+					logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
+					Stations.skipStation(station._id);
+					next2();
+				}
+			}, () => {
+				next();
+			});
+		}
+	], () => {
+		callback();
+	});
+};
+
+let sessionClearingTask = (callback) => {
+	logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`);
+	async.waterfall([
+		(next) => {
+			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) {
+					logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
+					cache.hdel('sessions', sessionId, () => {
+						next2();
+					});
+				} else if (!session.refreshDate) {
+					session.refreshDate = Date.now();
+					cache.hset('sessions', sessionId, session, () => {
+						next2();
+					});
+				} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
+					utils.socketsFromSessionId(session.sessionId, (sockets) => {
+						if (sockets.length > 0) {
+							session.refreshDate = Date.now();
+							cache.hset('sessions', sessionId, session, () => {
+								next2()
+							});
+						} else {
+							logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
+							cache.hdel('sessions', session.sessionId, () => {
+								next2();
+							});
+						}
+					});
+				} else {
+					logger.error("TASK_SESSION_CLEAR", "This should never log.");
+					next2();
+				}
+			}, () => {
+				next();
+			});
+		}
+	], () => {
+		callback();
+	});
+};
+
+module.exports = {
+	init: function(cb) {
+		utils = require('./utils');
+		this.createTask("testTask", testTask, 5000, true);
+		this.createTask("stationSkipTask", checkStationSkipTask, 1000 * 60 * 30);
+		this.createTask("sessionClearTask", sessionClearingTask, 1000 * 60 * 60 * 6);
+
+		cb();
+	},
+	createTask: function(name, fn, timeout, paused = false) {
+		tasks[name] = {
+			name,
+			fn,
+			timeout,
+			lastRan: 0,
+			timer: null
+		};
+		if (!paused) this.handleTask(tasks[name]);
+	},
+	pauseTask: (name) => {
+		tasks[name].timer.pause();
+	},
+	resumeTask: (name) => {
+		tasks[name].timer.resume();
+	},
+	handleTask: function(task) {
+		if (task.timer) task.timer.pause();
+
+		task.fn(() => {
+			task.lastRan = Date.now();
+			task.timer = new utils.Timer(() => {
+				this.handleTask(task);
+			}, task.timeout, false);
+		});
+	}
+};

+ 190 - 74
backend/logic/utils.js

@@ -2,7 +2,9 @@
 
 
 const moment  = require('moment'),
 const moment  = require('moment'),
 	  io      = require('./io'),
 	  io      = require('./io'),
+	  db      = require('./db'),
 	  config  = require('config'),
 	  config  = require('config'),
+	  async	  = require('async'),
 	  request = require('request'),
 	  request = require('request'),
 	  cache   = require('./cache');
 	  cache   = require('./cache');
 
 
@@ -12,7 +14,7 @@ class Timer {
 		this.timerId = undefined;
 		this.timerId = undefined;
 		this.start = undefined;
 		this.start = undefined;
 		this.paused = paused;
 		this.paused = paused;
-		this.remaining = moment.duration(delay, "hh:mm:ss").asSeconds() * 1000;
+		this.remaining = delay;
 		this.timeWhenPaused = 0;
 		this.timeWhenPaused = 0;
 		this.timePaused = Date.now();
 		this.timePaused = Date.now();
 
 
@@ -93,6 +95,10 @@ function convertTime (duration) {
 	return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
 	return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
 }
 }
 
 
+let youtubeRequestCallbacks = [];
+let youtubeRequestsPending = 0;
+let youtubeRequestsActive = false;
+
 module.exports = {
 module.exports = {
 	htmlEntities: str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
 	htmlEntities: str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
 	generateRandomString: function(len) {
 	generateRandomString: function(len) {
@@ -137,27 +143,34 @@ module.exports = {
 			return ns.connected[socketId];
 			return ns.connected[socketId];
 		}
 		}
 	},
 	},
+	socketsFromSessionId: function(sessionId, cb) {
+		let ns = io.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);
+			});
+		}
+	},
 	socketsFromUser: function(userId, cb) {
 	socketsFromUser: function(userId, cb) {
 		let ns = io.io.of("/");
 		let ns = io.io.of("/");
 		let sockets = [];
 		let sockets = [];
 		if (ns) {
 		if (ns) {
-			let total = Object.keys(ns.connected).length;
-			let done = 0;
-			for (let id in ns.connected) {
+			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
 				let session = ns.connected[id].session;
 				cache.hget('sessions', session.sessionId, (err, session) => {
 				cache.hget('sessions', session.sessionId, (err, session) => {
 					if (!err && session && session.userId === userId) {
 					if (!err && session && session.userId === userId) {
 						sockets.push(ns.connected[id]);
 						sockets.push(ns.connected[id]);
 					}
 					}
-					checkComplete();
+					next();
 				});
 				});
-			}
-			function checkComplete() {
-				done++;
-				if (done === total) {
-					cb(sockets);
-				}
-			}
+			}, () => {
+				cb(sockets);
+			});
 		}
 		}
 	},
 	},
 	socketLeaveRooms: function(socketid) {
 	socketLeaveRooms: function(socketid) {
@@ -179,9 +192,7 @@ module.exports = {
 		let socket = this.socketFromSession(socketId);
 		let socket = this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 		for (let room in rooms) {
-			if (room.indexOf('song.') !== -1) {
-				socket.leave(rooms);
-			}
+			if (room.indexOf('song.') !== -1) socket.leave(rooms);
 		}
 		}
 		socket.join(room);
 		socket.join(room);
 	},
 	},
@@ -190,9 +201,7 @@ module.exports = {
 			let socket = sockets[id];
 			let socket = sockets[id];
 			let rooms = socket.rooms;
 			let rooms = socket.rooms;
 			for (let room in rooms) {
 			for (let room in rooms) {
-				if (room.indexOf('song.') !== -1) {
-					socket.leave(room);
-				}
+				if (room.indexOf('song.') !== -1) socket.leave(room);
 			}
 			}
 			socket.join(room);
 			socket.join(room);
 		}
 		}
@@ -202,9 +211,7 @@ module.exports = {
 			let socket = sockets[id];
 			let socket = sockets[id];
 			let rooms = socket.rooms;
 			let rooms = socket.rooms;
 			for (let room in rooms) {
 			for (let room in rooms) {
-				if (room.indexOf('song.') !== -1) {
-					socket.leave(room);
-				}
+				if (room.indexOf('song.') !== -1) socket.leave(room);
 			}
 			}
 		}
 		}
 	},
 	},
@@ -226,55 +233,66 @@ module.exports = {
 		let roomSockets = [];
 		let roomSockets = [];
 		for (let id in sockets) {
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let socket = sockets[id];
-			if (socket.rooms[room]) {
-				roomSockets.push(socket);
-			}
+			if (socket.rooms[room]) roomSockets.push(socket);
 		}
 		}
 		return roomSockets;
 		return roomSockets;
 	},
 	},
 	getSongFromYouTube: (songId, cb) => {
 	getSongFromYouTube: (songId, cb) => {
-		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.push({cb: (test) => {
+			youtubeRequestsActive = true;
+			const youtubeParams = [
+				'part=snippet,contentDetails,statistics,status',
+				`id=${encodeURIComponent(songId)}`,
+				`key=${config.get('apis.youtube.key')}`
+			].join('&');
 
 
-			if (err) {
-				console.error(err);
-				return next('Failed to find song from YouTube');
-			}
+			request(`https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`, (err, res, body) => {
 
 
-			body = JSON.parse(body);
+				youtubeRequestCallbacks.splice(0, 1);
+				if (youtubeRequestCallbacks.length > 0) {
+					youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
+				} else youtubeRequestsActive = false;
 
 
-			//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 '';
+				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});
 
 
-			let song = {
-				_id: body.items[0].id,
-				title: body.items[0].snippet.title,
-				duration
-			};
-			cb(song);
-		});
+		if (!youtubeRequestsActive) {
+			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
+		}
 	},
 	},
 	getPlaylistFromYouTube: (url, cb) => {
 	getPlaylistFromYouTube: (url, cb) => {
 		
 		
@@ -282,22 +300,32 @@ module.exports = {
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
 		let playlistId = regex.exec(url)[1];
 		let playlistId = regex.exec(url)[1];
 
 
-		const youtubeParams = [
-			'part=contentDetails',
-			`playlistId=${encodeURIComponent(playlistId)}`,
-			`maxResults=50`,
-			`key=${config.get('apis.youtube.key')}`
-		].join('&');
+		function getPage(pageToken, songs) {
+			let nextPageToken = (pageToken) ? `pageToken=${pageToken}` : '';
+			const youtubeParams = [
+				'part=contentDetails',
+				`playlistId=${encodeURIComponent(playlistId)}`,
+				`maxResults=5`,
+				`key=${config.get('apis.youtube.key')}`,
+				nextPageToken
+			].join('&');
 
 
-		request(`https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`, (err, res, body) => {
-			if (err) {
-				console.error(err);
-				return next('Failed to find playlist from YouTube');
-			}
+			request(`https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`, (err, res, body) => {
+				if (err) {
+					console.error(err);
+					return next('Failed to find playlist from YouTube');
+				}
 
 
-			body = JSON.parse(body);
-			cb(body.items);
-		});
+				body = JSON.parse(body);
+				songs = songs.concat(body.items);
+				if (body.nextPageToken) getPage(body.nextPageToken, songs);
+				else {
+					console.log(songs);
+					cb(songs);
+				}
+			});
+		}
+		getPage(null, []);
 	},
 	},
 	getSongFromSpotify: (song, cb) => {
 	getSongFromSpotify: (song, cb) => {
 		const spotifyParams = [
 		const spotifyParams = [
@@ -336,5 +364,93 @@ module.exports = {
 
 
 			cb(song);
 			cb(song);
 		});
 		});
+	},
+	getSongsFromSpotify: (title, artist, cb) => {
+		const spotifyParams = [
+			`q=${encodeURIComponent(title)}`,
+			`type=track`
+		].join('&');
+
+		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+			if (err) return console.error(err);
+			body = JSON.parse(body);
+			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);
+		});
+	},
+	shuffle: (array) => {
+		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;
+	},
+	getError: (err) => {
+		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;
+	},
+	canUserBeInStation: (station, userId, cb) => {
+		async.waterfall([
+			(next) => {
+				if (station.privacy !== 'private') return next(true);
+				if (!userId) return next(false);
+				next();
+			},
+
+			(next) => {
+				db.models.user.findOne({_id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next(false);
+				if (user.role === 'admin') return next(true);
+				if (station.type === 'official') return next(false);
+				if (station.owner === userId) return next(true);
+				next(false);
+			}
+		], (err) => {
+			if (err === true) return cb(true);
+			return cb(false);
+		});
 	}
 	}
 };
 };

+ 5 - 2
backend/package.json

@@ -20,8 +20,9 @@
     "cors": "^2.8.1",
     "cors": "^2.8.1",
     "express": "^4.14.0",
     "express": "^4.14.0",
     "express-session": "^1.14.0",
     "express-session": "^1.14.0",
+    "mailgun-js": "^0.8.0",
     "moment": "^2.15.2",
     "moment": "^2.15.2",
-    "mongoose": "^4.6.0",
+    "mongoose": "^4.9.0",
     "oauth": "^0.9.14",
     "oauth": "^0.9.14",
     "passport": "^0.3.2",
     "passport": "^0.3.2",
     "passport-discord": "^0.1.1",
     "passport-discord": "^0.1.1",
@@ -30,6 +31,8 @@
     "passport.socketio": "^3.7.0",
     "passport.socketio": "^3.7.0",
     "redis": "^2.6.3",
     "redis": "^2.6.3",
     "request": "^2.74.0",
     "request": "^2.74.0",
-    "socket.io": "^1.5.0"
+    "sha256": "^0.2.0",
+    "socket.io": "^1.5.0",
+    "underscore": "^1.8.3"
   }
   }
 }
 }

+ 30 - 0
docker-compose-production.yml

@@ -0,0 +1,30 @@
+version: '2'
+services:
+  backend:
+    build: ./backend
+    ports:
+    - "8081:8081"
+    volumes:
+    - ./backend:/opt/app
+    links:
+    - mongo
+    - redis
+    environment:
+    - NGINX_PORT=81
+  frontend:
+    build: ./frontend
+    ports:
+    - "81:81"
+    volumes:
+    - ./frontend:/opt/app
+  mongo:
+    image: mongo
+    ports:
+    - "27017:27017"
+  mongoclient:
+    image: mongoclient/mongoclient
+    ports:
+    - "3000:3000"
+  redis:
+    image: redis
+    command: "--notify-keyspace-events Ex"

+ 3 - 0
docker-compose.yml

@@ -6,9 +6,12 @@ services:
     - "8080:8080"
     - "8080:8080"
     volumes:
     volumes:
     - ./backend:/opt/app
     - ./backend:/opt/app
+    - ./log:/opt/log
     links:
     links:
     - mongo
     - mongo
     - redis
     - redis
+    environment:
+    - NGINX_PORT=80
   frontend:
   frontend:
     build: ./frontend
     build: ./frontend
     ports:
     ports:

+ 5 - 0
frontend/.babelrc

@@ -0,0 +1,5 @@
+{
+	"presets": ["es2015"],
+	"plugins": ["transform-runtime"],
+	"comments": false
+}

+ 148 - 27
frontend/App.vue

@@ -1,12 +1,11 @@
 <template>
 <template>
 	<div>
 	<div>
-		<h1 v-if="!socketConnected" class="socketNotConnected">Could not connect to the server.</h1>
+		<h1 v-if="!socketConnected" class="alert">Could not connect to the server.</h1>
 		<router-view></router-view>
 		<router-view></router-view>
 		<toast></toast>
 		<toast></toast>
 		<what-is-new></what-is-new>
 		<what-is-new></what-is-new>
 		<login-modal v-if='isLoginActive'></login-modal>
 		<login-modal v-if='isLoginActive'></login-modal>
 		<register-modal v-if='isRegisterActive'></register-modal>
 		<register-modal v-if='isRegisterActive'></register-modal>
-		<create-community-station v-if='isCreateCommunityStationActive'></create-community-station>
 	</div>
 	</div>
 </template>
 </template>
 
 
@@ -16,9 +15,9 @@
 	import WhatIsNew from './components/Modals/WhatIsNew.vue';
 	import WhatIsNew from './components/Modals/WhatIsNew.vue';
 	import LoginModal from './components/Modals/Login.vue';
 	import LoginModal from './components/Modals/Login.vue';
 	import RegisterModal from './components/Modals/Register.vue';
 	import RegisterModal from './components/Modals/Register.vue';
-	import CreateCommunityStation from './components/Modals/CreateCommunityStation.vue';
 	import auth from './auth';
 	import auth from './auth';
 	import io from './io';
 	import io from './io';
+	import validation from './validation';
 
 
 	export default {
 	export default {
 		replace: false,
 		replace: false,
@@ -39,22 +38,23 @@
 				userId: '',
 				userId: '',
 				isRegisterActive: false,
 				isRegisterActive: false,
 				isLoginActive: false,
 				isLoginActive: false,
-				isCreateCommunityStationActive: false,
 				serverDomain: '',
 				serverDomain: '',
 				socketConnected: true
 				socketConnected: true
 			}
 			}
 		},
 		},
 		methods: {
 		methods: {
 			logout: function () {
 			logout: function () {
-				this.socket.emit('users.logout', result => {
+				let _this = this;
+				_this.socket.emit('users.logout', result => {
 					if (result.status === 'success') {
 					if (result.status === 'success') {
 						document.cookie = 'SID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
 						document.cookie = 'SID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
+						_this.$router.go('/');
 						location.reload();
 						location.reload();
 					} else Toast.methods.addToast(result.message, 4000);
 					} else Toast.methods.addToast(result.message, 4000);
 				});
 				});
 			},
 			},
 			'submitOnEnter': (cb, event) => {
 			'submitOnEnter': (cb, event) => {
-				if (event.which == 13) b(); return false;
+				if (event.which == 13) cb();
 			}
 			}
 		},
 		},
 		ready: function () {
 		ready: function () {
@@ -66,31 +66,54 @@
 				_this.username = username;
 				_this.username = username;
 				_this.userId = userId;
 				_this.userId = userId;
 			});
 			});
-			io.onConnect(() => {
+			io.onConnect(true, () => {
 				_this.socketConnected = true;
 				_this.socketConnected = true;
 			});
 			});
-			io.onDisconnect(() => {
+			io.onConnectError(true, () => {
+				_this.socketConnected = false;
+			});
+			io.onDisconnect(true, () => {
 				_this.socketConnected = false;
 				_this.socketConnected = false;
 			});
 			});
 			lofig.get('serverDomain', res => {
 			lofig.get('serverDomain', res => {
 				_this.serverDomain = res;
 				_this.serverDomain = res;
 			});
 			});
+			if (_this.$route.query.err) {
+				let err = _this.$route.query.err;
+				err = err.replace(new RegExp('<', 'g'), '&lt;').replace(new RegExp('>', 'g'), '&gt;');
+				Toast.methods.addToast(err, 20000);
+			}
 		},
 		},
 		events: {
 		events: {
-			'register': function () {
+			'register': function (recaptchaId) {
 				let { register: { email, username, password } } = this;
 				let { register: { email, username, password } } = this;
 				let _this = this;
 				let _this = this;
-				this.socket.emit('users.register', username, email, password, grecaptcha.getResponse(), result => {
+				if (!email || !username || !password) return Toast.methods.addToast('Please fill in all fields', 8000);
+
+
+				if (!validation.isLength(email, 3, 254)) return Toast.methods.addToast('Email must have between 3 and 254 characters.', 8000);
+				if (email.indexOf('@') !== email.lastIndexOf('@') || !validation.regex.emailSimple.test(email)) return Toast.methods.addToast('Invalid email format.', 8000);
+
+
+				if (!validation.isLength(username, 2, 32)) return Toast.methods.addToast('Username must have between 2 and 32 characters.', 8000);
+				if (!validation.regex.azAZ09_.test(username)) return Toast.methods.addToast('Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.', 8000);
+
+
+				if (!validation.isLength(password, 6, 200)) return Toast.methods.addToast('Password must have between 6 and 200 characters.', 8000);
+				if (!validation.regex.password.test(password)) return Toast.methods.addToast('Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.', 8000);
+
+				this.socket.emit('users.register', username, email, password, grecaptcha.getResponse(recaptchaId), result => {
 					if (result.status === 'success') {
 					if (result.status === 'success') {
 						Toast.methods.addToast(`You have successfully registered.`, 4000);
 						Toast.methods.addToast(`You have successfully registered.`, 4000);
-						setTimeout(() => {
-							if (result.SID) {
+						if (result.SID) {
+							lofig.get('cookie', cookie => {
 								let date = new Date();
 								let date = new Date();
 								date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
 								date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-								document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; path=/`;
+								let secure = (cookie.secure) ? 'secure=true; ' : '';
+								document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; ${secure}path=/`;
 								location.reload();
 								location.reload();
-							} else _this.$router.go('/login');
-						}, 4000);
+							});
+						} else _this.$router.go('/login');
 					} else Toast.methods.addToast(result.message, 8000);
 					} else Toast.methods.addToast(result.message, 8000);
 				});
 				});
 			},
 			},
@@ -99,12 +122,19 @@
 				let _this = this;
 				let _this = this;
 				this.socket.emit('users.login', email, password, result => {
 				this.socket.emit('users.login', email, password, result => {
 					if (result.status === 'success') {
 					if (result.status === 'success') {
-						let date = new Date();
-						date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-						document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; path=/`;
-						Toast.methods.addToast(`You have been successfully logged in`, 2000);
-						_this.$router.go('/');
-						location.reload();
+						lofig.get('cookie', cookie => {
+							let date = new Date();
+							date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+							let secure = (cookie.secure) ? 'secure=true; ' : '';
+							let domain = '';
+							if (cookie.domain !== 'localhost') {
+								domain = ` domain=${cookie.domain};`;
+							}
+							document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
+							Toast.methods.addToast(`You have been successfully logged in`, 2000);
+							_this.$router.go('/');
+							location.reload();
+						});
 					} else Toast.methods.addToast(result.message, 2000);
 					} else Toast.methods.addToast(result.message, 2000);
 				});
 				});
 			},
 			},
@@ -116,23 +146,36 @@
 					case 'login':
 					case 'login':
 						this.isLoginActive = !this.isLoginActive;
 						this.isLoginActive = !this.isLoginActive;
 						break;
 						break;
-					case 'createCommunityStation':
-						this.isCreateCommunityStationActive = !this.isCreateCommunityStationActive;
-						break;
 				}
 				}
 			},
 			},
 			'closeModal': function() {
 			'closeModal': function() {
 				this.$broadcast('closeModal');
 				this.$broadcast('closeModal');
 			}
 			}
 		},
 		},
-		components: { Toast, WhatIsNew, LoginModal, RegisterModal, CreateCommunityStation }
+		components: { Toast, WhatIsNew, LoginModal, RegisterModal }
 	}
 	}
 </script>
 </script>
 
 
 <style type='scss'>
 <style type='scss'>
 	#toast-container { z-index: 10000 !important; }
 	#toast-container { z-index: 10000 !important; }
 
 
-	.socketNotConnected {
+	html {
+		overflow: auto !important;
+	}
+
+	.modal-card {
+		margin: 0 !important;
+	}
+
+	.absolute-a {
+		width: 100%;
+		height: 100%;
+		position: absolute;
+		top: 0;
+		left: 0;
+	}
+
+	.alert {
 		padding: 20px;
 		padding: 20px;
 		color: white;
 		color: white;
 		background-color: red;
 		background-color: red;
@@ -143,4 +186,82 @@
 		border-radius: 5px;
 		border-radius: 5px;
 		z-index: 10000000;
 		z-index: 10000000;
 	}
 	}
-</style>
+
+	.tooltip {
+		position: relative;
+
+		&:after {
+			 position: absolute;
+			 min-width: 80px;
+			 margin-left: -75%;
+			 text-align: center;
+			 padding: 7.5px 6px;
+			 border-radius: 2px;
+			 background-color: #323232;
+			 font-size: .9em;
+			 color: #fff;
+			 content: attr(data-tooltip);
+			 opacity: 0;
+			 transition: all .2s ease-in-out .1s;
+			 visibility: hidden;
+		}
+
+		&:hover:after {
+			 opacity: 1;
+			 visibility: visible;
+		}
+	}
+
+	.tooltip-top {
+		&:after {
+			 bottom: 150%;
+		}
+
+		&:hover {
+			&:after { bottom: 120%; }
+		}
+	}
+
+
+	.tooltip-bottom {
+		&:after {
+			 top: 155%;
+		}
+
+		&:hover {
+			&:after { top: 125%; }
+		}
+	}
+
+	.tooltip-left {
+		&:after {
+			 bottom: -10px;
+			 right: 130%;
+			 min-width: 100px;
+		}
+
+		&:hover {
+			&:after { right: 110%; }
+		}
+	}
+
+	.tooltip-right {
+		&:after {
+			 bottom: -10px;
+			 left: 190%;
+			 min-width: 100px;
+		}
+
+		&:hover {
+			 &:after { left: 200%; }
+		}
+	}
+
+	.button:focus, .button:active { border-color: #dbdbdb !important; }
+	.input:focus, .input:active { border-color: #03a9f4 !important; }
+	button.delete:focus { background-color: rgba(10, 10, 10, 0.3); }
+
+	.tag { padding-right: 6px !important; }
+
+	.button.is-success { background-color: #00B16A !important; }
+</style>

+ 1 - 1
frontend/Dockerfile

@@ -3,7 +3,7 @@ FROM node
 RUN apt-get update
 RUN apt-get update
 RUN apt-get install nginx -y
 RUN apt-get install nginx -y
 
 
-RUN npm install -g webpack
+RUN npm install -g webpack@1.14.0
 
 
 RUN mkdir -p /opt
 RUN mkdir -p /opt
 WORKDIR /opt
 WORKDIR /opt

BIN
frontend/build/android-chrome-144x144.png


BIN
frontend/build/android-chrome-192x192.png


BIN
frontend/build/android-chrome-36x36.png


BIN
frontend/build/android-chrome-48x48.png


BIN
frontend/build/android-chrome-72x72.png


BIN
frontend/build/android-chrome-96x96.png


BIN
frontend/build/apple-touch-icon-114x114.png


BIN
frontend/build/apple-touch-icon-120x120.png


BIN
frontend/build/apple-touch-icon-144x144.png


BIN
frontend/build/apple-touch-icon-152x152.png


BIN
frontend/build/apple-touch-icon-180x180.png


BIN
frontend/build/apple-touch-icon-57x57.png


BIN
frontend/build/apple-touch-icon-60x60.png


BIN
frontend/build/apple-touch-icon-72x72.png


BIN
frontend/build/apple-touch-icon-76x76.png


BIN
frontend/build/apple-touch-icon-precomposed.png


BIN
frontend/build/apple-touch-icon.png


BIN
frontend/build/assets/favicon.ico


BIN
frontend/build/assets/notes-transparent.png


BIN
frontend/build/assets/notes.png


File diff suppressed because it is too large
+ 55 - 0
frontend/build/assets/social/discord.svg


+ 10 - 0
frontend/build/assets/social/facebook.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="-183 277 245 240" style="enable-background:new -183 277 245 240;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#4A4A4A;}
+</style>
+<path id="f" class="st0" d="M-48.5,474.3v-70.6h23.7l3.5-27.5h-27.2v-17.6c0-8,2.2-13.4,13.6-13.4l14.6,0v-24.6
+	c-2.5-0.3-11.2-1.1-21.2-1.1c-21,0-35.4,12.8-35.4,36.4v20.3h-23.7v27.5h23.7v70.6H-48.5z"/>
+</svg>

+ 59 - 0
frontend/build/assets/social/github.svg

@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="-183 277 245 240" style="enable-background:new -183 277 245 240;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:none;}
+	.st1{fill:#4A4A4A;}
+</style>
+<g>
+	<title>background</title>
+	<rect id="canvas_background" x="-184" y="276" class="st0" width="582" height="402"/>
+</g>
+<g>
+	<title>Layer 1</title>
+	<g id="svg_86">
+		<g id="svg_66" transform="matrix(1.1862952709197998,0,0,1.1862952709197998,27.944357648753794,19.726211432238415) ">
+			<title  fill="#4a4a4a">Layer 1</title>
+			<g id="svg_81" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_82" class="st1" d="M-55.3,376.9c-33.3,0-60.4-27-60.4-60.4c0-26.7,17.3-49.3,41.3-57.3c3-0.6,4.1,1.3,4.1,2.9
+					c0,1.4-0.1,6.2-0.1,11.2c-16.8-3.7-20.3,7.1-20.3,7.1c-2.7,7-6.7,8.8-6.7,8.8c-5.5,3.7,0.4,3.7,0.4,3.7c6.1-0.4,9.3-6.2,9.3-6.2
+					c5.4-9.2,14.1-6.6,17.6-5c0.5,3.9,2.1,6.6,3.8,8.1c-13.4,1.5-27.5,6.7-27.5,29.8c0,6.6,2.4,12,6.2,16.2c-0.6,1.5-2.7,7.7,0.6,16
+					c0,0,5.1,1.6,16.6-6.2c4.8,1.3,10,2,15.1,2c5.1,0,10.3-0.7,15.1-2c11.5,7.8,16.6,6.2,16.6,6.2c3.3-8.3,1.2-14.5,0.6-16
+					c3.9-4.2,6.2-9.6,6.2-16.2c0-23.2-14.1-28.3-27.6-29.8c2.2-1.9,4.1-5.5,4.1-11.2c0-8.1-0.1-14.6-0.1-16.6c0-1.6,1.1-3.5,4.1-2.9
+					c24,8,41.3,30.6,41.3,57.3C5.1,349.8-22,376.9-55.3,376.9"/>
+			</g>
+			<g id="svg_79" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_80" class="st1" d="M-92.8,290.2c-0.1-0.3-0.6-0.4-1-0.2c-0.4,0.2-0.7,0.6-0.5,0.9c0.1,0.3,0.6,0.4,1,0.2
+					C-92.9,290.9-92.7,290.5-92.8,290.2"/>
+			</g>
+			<g id="svg_77" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_78" class="st1" d="M-90.4,287.4c-0.3-0.3-0.9-0.1-1.2,0.3c-0.4,0.4-0.5,1-0.2,1.3c0.3,0.3,0.8,0.1,1.2-0.3
+					C-90.2,288.3-90.1,287.7-90.4,287.4"/>
+			</g>
+			<g id="svg_75" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_76" class="st1" d="M-88,284c-0.4-0.3-1,0-1.3,0.5c-0.4,0.5-0.4,1.2,0,1.4c0.4,0.3,1,0,1.4-0.5
+					C-87.6,284.9-87.6,284.2-88,284"/>
+			</g>
+			<g id="svg_73" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_74" class="st1" d="M-84.7,280.6c-0.3-0.4-1-0.3-1.6,0.2c-0.5,0.5-0.7,1.2-0.3,1.5c0.3,0.4,1,0.3,1.6-0.2
+					C-84.5,281.6-84.4,281-84.7,280.6"/>
+			</g>
+			<g id="svg_71" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_72" class="st1" d="M-80.2,278.6c-0.1-0.5-0.8-0.7-1.5-0.5c-0.7,0.2-1.1,0.8-1,1.2c0.1,0.5,0.8,0.7,1.5,0.5
+					C-80.5,279.7-80.1,279.1-80.2,278.6"/>
+			</g>
+			<g id="svg_69" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_70" class="st1" d="M-75.3,278.3c0-0.5-0.6-0.9-1.3-0.9c-0.7,0-1.3,0.4-1.3,0.9c0,0.5,0.6,0.9,1.3,0.9
+					C-75.9,279.2-75.3,278.8-75.3,278.3"/>
+			</g>
+			<g id="svg_67" transform="matrix(1.3333333,0,0,-1.3333333,0,735.98133) ">
+				<path id="svg_68" class="st1" d="M-70.7,279.1c0.1-0.5-0.4-1-1.1-1.1c-0.7-0.1-1.3,0.2-1.4,0.7c-0.1,0.5,0.4,1,1.1,1.1
+					C-71.4,279.8-70.8,279.6-70.7,279.1"/>
+			</g>
+		</g>
+	</g>
+	<g id="svg_22">
+	</g>
+</g>
+</svg>

+ 18 - 0
frontend/build/assets/social/twitter.svg

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="-183 277 245 240" style="enable-background:new -183 277 245 240;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:none;}
+	.st1{fill:#4A4A4A;}
+</style>
+<g>
+	<title>background</title>
+	<rect id="canvas_background" x="-184" y="276" class="st0" width="582" height="402"/>
+</g>
+<path class="st1" d="M-93.8,469.9c67.7,0,104.8-56.1,104.8-104.8c0-1.6,0-3.2-0.1-4.8c7.2-5.2,13.4-11.7,18.4-19.1
+	c-6.7,3-13.8,4.9-21.1,5.8c7.7-4.6,13.4-11.8,16.2-20.4c-7.2,4.3-15.1,7.3-23.4,8.9c-13.9-14.8-37.3-15.5-52.1-1.6
+	c-9.6,9-13.6,22.4-10.7,35.2c-29.6-1.5-57.2-15.5-75.9-38.5c-9.8,16.8-4.8,38.3,11.4,49.1c-5.9-0.2-11.6-1.8-16.7-4.6
+	c0,0.2,0,0.3,0,0.5c0,17.5,12.4,32.6,29.5,36.1c-5.4,1.5-11.1,1.7-16.6,0.6c4.8,15,18.6,25.3,34.4,25.6
+	c-13,10.2-29.1,15.8-45.7,15.8c-2.9,0-5.9-0.2-8.8-0.5C-133.4,464.2-113.8,469.9-93.8,469.9"/>
+</svg>

BIN
frontend/build/assets/wordmark.png


+ 12 - 0
frontend/build/browserconfig.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+  <msapplication>
+    <tile>
+      <square70x70logo src="/mstile-70x70.png?v=06042016"/>
+      <square150x150logo src="/mstile-150x150.png?v=06042016"/>
+      <square310x310logo src="/mstile-310x310.png?v=06042016"/>
+      <wide310x150logo src="/mstile-310x150.png?v=06042016"/>
+      <TileColor>#2d89ef</TileColor>
+    </tile>
+  </msapplication>
+</browserconfig>

+ 5 - 4
frontend/build/config/template.json

@@ -1,9 +1,10 @@
 {
 {
-	"socket": {
-		"url": ""
-	},
 	"recaptcha": {
 	"recaptcha": {
 		"key": ""
 		"key": ""
 	},
 	},
-  	"serverDomain": ""
+  	"serverDomain": "",
+  	"cookie": {
+		"domain": "",
+		"secure": false
+	}
 }
 }

BIN
frontend/build/favicon-16x16.png


BIN
frontend/build/favicon-194x194.png


BIN
frontend/build/favicon-32x32.png


BIN
frontend/build/favicon-96x96.png


BIN
frontend/build/favicon.ico


+ 50 - 14
frontend/build/index.html

@@ -1,22 +1,58 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
-<html lang="en">
+<html lang='en'>
 <head>
 <head>
-	<meta charset="UTF-8">
-	<meta name="viewport" content="width=device-width, initial-scale=1">
 	<title>Musare</title>
 	<title>Musare</title>
-	<link rel="shortcut icon" type="image/x-icon" href="https://musare.com/favicon.ico" />
-	<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.6.3/css/font-awesome.min.css" rel="stylesheet" type="text/css">
-	<link href="https://fonts.googleapis.com/css?family=Roboto:100,400" rel="stylesheet">
-	<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
-	<link rel="stylesheet" href="/index.css">
-	<script src="https://www.youtube.com/iframe_api"></script>
-	<script src="/vendor/jquery.min.js"></script>
-	<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.15.0/moment.min.js"></script>
-	<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.4.8/socket.io.min.js"></script>
-	<script type="text/javascript" src="https://cdn.rawgit.com/atjonathan/lofig/master/dist/lofig.min.js"></script>
+
+	<meta charset='UTF-8'>
+	<meta http-equiv='X-UA-Compatible' content='IE=edge'>
+	<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
+	<meta name='keywords' content='music, musare, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
+	<meta name='description' content='On Musare you can listen to lots of different songs, playing 24/7 in our official stations and in user-made community stations!'>
+	<meta name='copyright' content='© Copyright Musare 2015-2017 All Right Reserved'>
+
+	<link rel='apple-touch-icon' sizes='57x57' href='/apple-touch-icon-57x57.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='60x60' href='/apple-touch-icon-60x60.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='72x72' href='/apple-touch-icon-72x72.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='76x76' href='/apple-touch-icon-76x76.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='114x114' href='/apple-touch-icon-114x114.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='120x120' href='/apple-touch-icon-120x120.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='144x144' href='/apple-touch-icon-144x144.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='152x152' href='/apple-touch-icon-152x152.png?v=06042016'>
+	<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon-180x180.png?v=06042016'>
+	<link rel='icon' type='image/png' href='/favicon-32x32.png?v=06042016' sizes='32x32'>
+	<link rel='icon' type='image/png' href='/favicon-194x194.png?v=06042016' sizes='194x194'>
+	<link rel='icon' type='image/png' href='/favicon-96x96.png?v=06042016' sizes='96x96'>
+	<link rel='icon' type='image/png' href='/android-chrome-192x192.png?v=06042016' sizes='192x192'>
+	<link rel='icon' type='image/png' href='/favicon-16x16.png?v=06042016' sizes='16x16'>
+	<link rel='manifest' href='/manifest.json?v=06042016'>
+	<link rel='mask-icon' href='/safari-pinned-tab.svg?v=06042016' color='#03a9f4'>
+	<link rel='shortcut icon' href='/favicon.ico?v=06042016'>
+	<meta name='msapplication-TileColor' content='#03a9f4'>
+	<meta name='msapplication-TileImage' content='/mstile-144x144.png?v=06042016'>
+	<meta name='theme-color' content='#03a9f4'>
+	<meta name='google' content='nositelinkssearchbox' />
+
+
+	<link href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.6.3/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
+	<link href='https://fonts.googleapis.com/css?family=Roboto:100,400' rel='stylesheet'>
+	<link href='https://fonts.googleapis.com/icon?family=Material+Icons' rel='stylesheet'>
+	<link rel='stylesheet' href='/index.css'>
+	<script src='https://www.youtube.com/iframe_api'></script>
+	<script src='/vendor/jquery.min.js'></script>
+	<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.15.0/moment.min.js'></script>
+	<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.4.8/socket.io.min.js'></script>
+	<script type='text/javascript' src='https://cdn.rawgit.com/atjonathan/lofig/master/dist/lofig.min.js'></script>
+	<script>
+		(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+					(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+				m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+		})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
+
+		ga('create', 'UA-93460758-1', 'auto');
+	</script>
 </head>
 </head>
 <body>
 <body>
-	<script src="/bundle.js"></script>
 	<script src='https://www.google.com/recaptcha/api.js'></script>
 	<script src='https://www.google.com/recaptcha/api.js'></script>
+	<script src='/bundle.js'></script>
 </body>
 </body>
 </html>
 </html>

+ 41 - 0
frontend/build/manifest.json

@@ -0,0 +1,41 @@
+{
+	"name": "Musare",
+	"icons": [
+		{
+			"src": "\/android-chrome-36x36.png?v=06042016",
+			"sizes": "36x36",
+			"type": "image\/png",
+			"density": 0.75
+		},
+		{
+			"src": "\/android-chrome-48x48.png?v=06042016",
+			"sizes": "48x48",
+			"type": "image\/png",
+			"density": 1
+		},
+		{
+			"src": "\/android-chrome-72x72.png?v=06042016",
+			"sizes": "72x72",
+			"type": "image\/png",
+			"density": 1.5
+		},
+		{
+			"src": "\/android-chrome-96x96.png?v=06042016",
+			"sizes": "96x96",
+			"type": "image\/png",
+			"density": 2
+		},
+		{
+			"src": "\/android-chrome-144x144.png?v=06042016",
+			"sizes": "144x144",
+			"type": "image\/png",
+			"density": 3
+		},
+		{
+			"src": "\/android-chrome-192x192.png?v=06042016",
+			"sizes": "192x192",
+			"type": "image\/png",
+			"density": 4
+		}
+	]
+}

BIN
frontend/build/mstile-144x144.png


BIN
frontend/build/mstile-150x150.png


BIN
frontend/build/mstile-310x150.png


BIN
frontend/build/mstile-310x310.png


BIN
frontend/build/mstile-70x70.png


+ 2 - 0
frontend/build/robots.txt

@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /admin/

+ 116 - 0
frontend/build/safari-pinned-tab.svg

@@ -0,0 +1,116 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="2048.000000pt" height="2048.000000pt" viewBox="0 0 2048.000000 2048.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+</metadata>
+<g transform="translate(0.000000,2048.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M14570 15310 c-124 -16 -164 -22 -184 -26 -12 -3 -32 -7 -46 -10 -55
+-11 -110 -27 -223 -65 -477 -160 -941 -510 -1344 -1014 -220 -276 -418 -592
+-609 -975 -224 -448 -435 -1013 -575 -1540 -11 -41 -25 -79 -30 -85 -5 -5 -34
+-10 -64 -11 l-55 -2 5 42 c6 45 8 62 24 181 6 44 13 103 16 130 3 28 8 61 11
+75 2 14 7 48 10 75 2 28 7 62 9 76 3 15 7 47 10 70 6 53 15 115 20 149 2 14 7
+48 10 75 7 65 19 160 40 311 4 29 14 116 20 174 3 28 11 106 21 195 13 109 5
+524 -11 615 -2 14 -7 52 -10 85 -3 33 -8 69 -10 80 -2 11 -7 36 -10 55 -7 49
+-14 86 -27 150 -6 30 -12 60 -14 65 -33 158 -137 425 -221 565 -81 136 -221
+287 -338 366 -82 55 -250 124 -342 140 -15 3 -41 8 -58 13 -33 8 -108 14 -195
+14 -367 1 -745 -160 -1165 -496 -214 -172 -541 -522 -759 -812 -196 -261 -454
+-659 -598 -925 -19 -36 -49 -91 -67 -124 -46 -84 -235 -460 -267 -531 -15 -33
+-42 -94 -62 -135 -61 -132 -162 -369 -241 -570 -43 -107 -82 -204 -86 -215 -4
+-11 -9 -24 -11 -30 -4 -17 -64 -179 -74 -200 -42 -92 -244 -730 -329 -1045
+-34 -124 -68 -248 -76 -276 -8 -28 -15 -58 -15 -67 0 -9 -4 -18 -9 -21 -5 -4
+-5 10 0 32 13 49 40 180 53 252 3 19 8 46 11 60 3 14 7 37 10 53 3 15 12 59
+20 97 8 38 17 82 20 97 3 16 7 39 10 53 3 14 23 122 45 240 21 118 42 229 45
+245 10 50 63 358 70 399 3 21 7 48 10 60 6 27 14 71 20 116 3 19 8 51 11 70 4
+19 8 44 10 55 22 148 45 304 49 325 2 14 11 79 20 145 9 66 18 131 20 145 2
+14 7 48 10 75 3 28 7 59 9 71 2 11 7 53 11 93 3 40 8 82 10 93 3 11 7 61 10
+112 4 50 8 101 10 113 6 39 18 314 18 423 0 117 -13 431 -18 461 -2 11 -7 56
+-11 102 -3 45 -8 84 -9 87 -2 3 -6 33 -10 65 -27 248 -133 558 -248 728 -96
+141 -271 292 -403 347 -27 12 -56 25 -65 29 -27 15 -162 49 -244 61 -79 12
+-332 17 -390 7 -16 -3 -50 -8 -75 -12 -124 -18 -335 -84 -460 -143 -277 -131
+-564 -381 -802 -697 -50 -67 -157 -225 -187 -277 -10 -18 -40 -69 -66 -113
+-106 -182 -255 -508 -350 -771 -49 -132 -115 -333 -121 -364 -3 -14 -11 -32
+-19 -40 -31 -34 4 -244 63 -380 11 -25 34 -65 52 -89 l32 -44 60 7 c103 10
+188 60 225 130 24 45 146 200 258 325 102 115 326 329 445 427 19 16 37 31 40
+35 4 5 127 96 210 156 162 117 442 260 550 279 14 3 39 8 55 12 48 12 137 7
+171 -11 44 -23 81 -83 103 -167 16 -63 18 -102 15 -240 -2 -91 -6 -183 -8
+-205 -2 -22 -7 -78 -11 -125 -4 -47 -8 -105 -10 -130 -2 -25 -6 -88 -10 -140
+-4 -52 -9 -113 -11 -134 -2 -22 -6 -80 -9 -130 -3 -50 -7 -107 -10 -126 -2
+-19 -7 -74 -10 -122 -3 -48 -8 -107 -10 -130 -2 -24 -7 -79 -10 -123 -3 -44
+-8 -102 -10 -130 -3 -27 -7 -77 -10 -110 -5 -68 -23 -265 -30 -340 -3 -27 -7
+-77 -10 -110 -5 -57 -8 -90 -20 -207 -3 -29 -8 -69 -10 -88 -3 -19 -7 -66 -11
+-105 -3 -38 -7 -77 -9 -86 -2 -10 -7 -50 -10 -90 -4 -40 -8 -81 -10 -90 -2
+-10 -6 -44 -10 -76 -3 -32 -7 -69 -10 -83 -2 -14 -7 -47 -10 -75 -3 -27 -7
+-66 -10 -85 -2 -19 -7 -56 -10 -82 -3 -27 -7 -67 -10 -90 -3 -24 -7 -56 -9
+-73 -2 -16 -7 -46 -10 -65 -3 -19 -8 -51 -10 -70 -11 -82 -18 -137 -22 -160
+-2 -14 -7 -45 -10 -70 -3 -25 -7 -57 -10 -72 -2 -16 -6 -43 -9 -60 -3 -18 -7
+-44 -10 -58 -3 -14 -7 -38 -9 -55 -3 -16 -16 -104 -31 -195 -14 -91 -28 -176
+-30 -190 -2 -14 -11 -68 -20 -120 -9 -52 -18 -111 -21 -130 -37 -249 -118
+-689 -140 -767 l-15 -53 -62 0 c-133 -1 -307 -34 -441 -84 -324 -122 -603
+-357 -751 -631 -171 -316 -163 -730 20 -1097 51 -100 69 -128 140 -213 160
+-193 343 -299 630 -366 72 -16 131 -21 295 -24 113 -2 230 0 260 5 30 4 86 11
+124 15 88 8 232 45 322 80 201 80 383 223 527 412 49 64 151 231 179 293 9 19
+28 62 43 95 59 131 175 513 209 688 10 52 119 479 171 672 9 33 27 98 40 145
+149 558 448 1485 685 2125 10 28 35 95 55 150 20 55 41 109 45 120 5 11 28 70
+51 130 89 235 257 649 305 752 13 28 24 54 24 57 0 3 13 35 30 71 16 36 30 67
+30 70 0 2 16 38 35 79 19 42 35 78 35 80 0 2 15 37 34 78 19 40 57 123 84 183
+50 110 225 477 240 505 5 8 42 78 82 155 226 433 400 718 596 980 232 310 410
+453 572 460 312 13 503 -219 567 -688 4 -27 8 -56 10 -65 3 -18 16 -211 21
+-327 6 -117 6 -776 0 -835 -3 -30 -8 -111 -11 -180 -4 -69 -8 -141 -10 -160
+-2 -19 -6 -73 -10 -120 -3 -47 -8 -96 -10 -110 -2 -14 -6 -54 -10 -90 -6 -80
+-13 -141 -19 -189 -3 -20 -10 -81 -16 -136 -6 -55 -13 -111 -16 -125 -2 -14
+-7 -47 -10 -75 -3 -27 -7 -61 -9 -75 -3 -14 -7 -50 -10 -80 -4 -30 -8 -62 -11
+-70 -2 -8 -6 -35 -9 -60 -3 -25 -7 -56 -10 -70 -2 -14 -7 -45 -10 -70 -3 -25
+-8 -52 -10 -60 -2 -8 -7 -40 -10 -70 -3 -30 -8 -62 -10 -70 -3 -8 -7 -31 -9
+-50 -5 -35 -31 -194 -40 -240 -3 -14 -12 -68 -21 -120 -9 -52 -18 -106 -21
+-120 -7 -43 -43 -243 -49 -275 -2 -16 -7 -41 -10 -55 -3 -14 -8 -41 -11 -60
+-9 -49 -10 -55 -27 -140 -9 -41 -19 -93 -23 -115 -4 -22 -8 -44 -9 -50 -5 -19
+-26 -130 -32 -160 -6 -37 -74 -376 -109 -540 -14 -66 -36 -167 -50 -225 -14
+-58 -27 -114 -30 -124 -18 -92 -66 -181 -86 -161 -11 11 -132 19 -263 18 -428
+-3 -773 -158 -978 -438 -109 -149 -179 -338 -205 -554 -9 -75 -8 -350 1 -431
+15 -133 17 -149 32 -218 103 -490 697 -851 1380 -837 233 5 368 36 560 131
+406 201 716 597 899 1149 51 155 52 159 80 270 24 97 30 124 41 183 3 15 8 38
+11 52 8 33 15 70 51 260 25 135 68 359 89 460 14 69 27 136 30 150 2 14 7 36
+10 50 3 14 8 36 10 50 13 69 98 479 121 580 14 63 30 135 34 160 17 84 80 365
+110 490 30 122 64 270 75 320 3 14 14 61 25 105 12 44 22 87 24 95 2 8 11 44
+20 80 9 36 19 73 20 82 7 32 116 442 141 528 33 112 85 291 90 310 24 97 203
+644 279 856 71 196 220 577 270 689 15 33 39 87 53 120 119 267 331 633 479
+829 313 411 618 612 944 621 149 4 249 -30 330 -115 65 -69 122 -183 144 -289
+2 -9 8 -38 15 -65 6 -27 13 -70 16 -95 3 -25 7 -56 9 -69 24 -141 24 -665 0
+-1047 -3 -59 -16 -195 -20 -214 -1 -9 -6 -50 -10 -91 -4 -41 -9 -83 -11 -94
+-2 -10 -6 -44 -10 -75 -3 -31 -8 -63 -9 -71 -2 -9 -7 -43 -11 -75 -4 -33 -9
+-62 -9 -65 -1 -3 -5 -32 -9 -65 -5 -33 -10 -67 -11 -75 -2 -8 -6 -40 -11 -70
+-4 -30 -8 -57 -9 -60 -1 -3 -5 -27 -10 -55 -4 -27 -9 -59 -11 -70 -3 -11 -11
+-60 -19 -110 -8 -49 -17 -101 -20 -115 -3 -14 -8 -38 -11 -55 -3 -16 -16 -91
+-30 -165 -14 -74 -28 -148 -31 -165 -4 -25 -22 -113 -39 -190 -2 -11 -6 -32
+-9 -48 -3 -15 -12 -60 -21 -100 -8 -39 -17 -82 -20 -94 -2 -13 -6 -33 -8 -45
+-3 -13 -12 -57 -21 -98 -9 -41 -19 -86 -21 -100 -3 -14 -14 -63 -24 -110 -18
+-81 -23 -103 -36 -167 -3 -16 -11 -56 -19 -91 -8 -34 -17 -77 -20 -95 -4 -17
+-15 -70 -25 -117 -10 -47 -21 -96 -23 -110 -3 -14 -13 -61 -22 -105 -10 -44
+-19 -93 -21 -110 -2 -16 -7 -37 -10 -47 -2 -9 -7 -25 -9 -35 -7 -32 -47 -237
+-51 -263 -6 -34 -32 -170 -50 -257 -8 -39 -16 -89 -20 -110 -3 -21 -12 -74
+-19 -118 -8 -44 -17 -96 -20 -115 -3 -19 -7 -46 -10 -60 -2 -14 -7 -43 -10
+-65 -3 -23 -8 -52 -10 -65 -3 -14 -7 -43 -9 -65 -9 -83 -17 -151 -21 -170 -2
+-11 -7 -54 -11 -95 -3 -41 -8 -91 -9 -110 -21 -214 -26 -484 -11 -610 13 -114
+47 -272 77 -365 40 -119 145 -331 211 -426 115 -164 271 -326 390 -402 159
+-103 419 -215 563 -243 22 -5 42 -9 45 -9 3 -1 16 -5 30 -8 107 -27 369 -40
+510 -24 198 22 441 113 570 212 311 242 601 799 534 1025 -12 41 -12 43 3 25
+14 -18 15 -13 9 70 -11 167 -32 276 -83 425 -64 192 -135 315 -242 420 -210
+208 -521 310 -948 314 l-132 1 5 35 c5 32 15 96 30 175 19 110 76 396 84 425
+5 19 12 50 15 68 3 18 28 133 55 255 28 122 53 233 56 247 3 14 11 53 19 88 8
+34 17 74 20 90 2 15 9 42 14 60 5 17 8 32 6 32 -3 0 0 11 5 25 5 14 12 36 14
+48 4 23 64 291 71 317 8 34 46 207 51 235 3 17 12 55 19 85 8 30 26 111 40
+180 26 120 30 143 39 193 2 12 15 76 29 142 14 66 28 134 30 150 3 17 10 53
+16 80 6 28 12 64 15 80 2 17 11 68 20 115 20 107 23 124 41 240 9 52 18 106
+20 120 10 59 14 89 24 160 6 41 13 95 16 120 3 25 10 74 15 110 15 102 18 130
+25 205 4 39 8 77 10 85 2 8 7 58 10 110 4 52 8 111 11 130 24 228 24 849 -1
+1085 -19 176 -31 270 -49 362 -3 11 -8 43 -11 70 -4 26 -11 66 -17 88 -6 22
+-11 47 -12 55 -2 15 -6 32 -32 135 -30 117 -97 322 -141 430 -48 119 -142 308
+-155 313 -4 2 -8 8 -8 13 0 6 -19 38 -42 72 -179 266 -406 463 -628 542 -48
+18 -51 19 -120 36 -25 6 -54 13 -65 16 -33 8 -326 10 -385 3z"/>
+</g>
+</svg>

+ 227 - 0
frontend/components/Admin/News.vue

@@ -0,0 +1,227 @@
+<template>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Title</td>
+					<td>Description</td>
+					<td>Bugs</td>
+					<td>Features</td>
+					<td>Improvements</td>
+					<td>Upcoming</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, news) in news' track-by='$index'>
+					<td>
+						<strong>{{ news.title }}</strong>
+					</td>
+					<td>{{ news.description }}</td>
+					<td>{{ news.bugs.join(', ') }}</td>
+					<td>{{ news.features.join(', ') }}</td>
+					<td>{{ news.improvements.join(', ') }}</td>
+					<td>{{ news.upcoming.join(', ') }}</td>
+					<td>
+						<button class='button is-primary' @click='editNews(news)'>Edit</button>
+						<button class='button is-danger' @click='removeNews(news)'>Remove</button>
+					</td>
+				</tr>
+			</tbody>
+		</table>
+
+		<div class='card is-fullwidth'>
+			<header class='card-header'>
+				<p class='card-header-title'>Create News</p>
+			</header>
+			<div class='card-content'>
+				<div class='content'>
+
+					<label class='label'>Title & Description</label>
+					<div class='control is-horizontal'>
+						<div class='control is-grouped'>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Title' v-model='creating.title'>
+							</p>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Short description' v-model='creating.description'>
+							</p>
+						</div>
+					</div>
+
+					<div class="columns">
+						<div class="column">
+							<label class='label'>Bugs</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-bugs' type='text' placeholder='Bug' v-on:keyup.enter='addChange("bugs")'>
+								<a class='button is-info' href='#' @click='addChange("bugs")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, bug) in creating.bugs' track-by='$index'>
+								{{ bug }}
+								<button class='delete is-info' @click='removeChange("bugs", index)'></button>
+							</span>
+						</div>
+						<div class="column">
+							<label class='label'>Features</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-features' type='text' placeholder='Feature' v-on:keyup.enter='addChange("features")'>
+								<a class='button is-info' href='#' @click='addChange("features")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, feature) in creating.features' track-by='$index'>
+								{{ feature }}
+								<button class='delete is-info' @click='removeChange("features", index)'></button>
+							</span>
+						</div>
+					</div>
+
+					<div class="columns">
+						<div class="column">
+							<label class='label'>Improvements</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-improvements' type='text' placeholder='Improvement' v-on:keyup.enter='addChange("improvements")'>
+								<a class='button is-info' href='#' @click='addChange("improvements")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, improvement) in creating.improvements' track-by='$index'>
+								{{ improvement }}
+								<button class='delete is-info' @click='removeChange("improvements", index)'></button>
+							</span>
+						</div>
+						<div class="column">
+							<label class='label'>Upcoming</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-upcoming' type='text' placeholder='Upcoming' v-on:keyup.enter='addChange("upcoming")'>
+								<a class='button is-info' href='#' @click='addChange("upcoming")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, upcoming) in creating.upcoming' track-by='$index'>
+								{{ upcoming }}
+								<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
+							</span>
+						</div>
+					</div>
+
+				</div>
+			</div>
+			<footer class='card-footer'>
+				<a class='card-footer-item' @click='createNews()' href='#'>Create</a>
+			</footer>
+		</div>
+	</div>
+
+	<edit-news v-if='modals.editNews'></edit-news>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+	import io from '../../io';
+
+	import EditNews from '../Modals/EditNews.vue';
+
+	export default {
+		components: { EditNews },
+		data() {
+			return {
+				modals: { editNews: false },
+				news: [],
+				creating: {
+					title: '',
+					description: '',
+					bugs: [],
+					features: [],
+					improvements: [],
+					upcoming: []
+				},
+				editing: {}
+			}
+		},
+		methods: {
+			toggleModal: function () {
+				this.modals.editNews = !this.modals.editNews;
+			},
+			createNews: function () {
+				let _this = this;
+
+				let { creating: { bugs, features, improvements, upcoming } } = this;
+
+				if (this.creating.title === '') return Toast.methods.addToast('Field (Title) cannot be empty', 3000);
+				if (this.creating.description === '') return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
+				if (
+					bugs.length <= 0 && features.length <= 0 && 
+					improvements.length <= 0 && upcoming.length <= 0
+				) return Toast.methods.addToast('You must have at least one News Item', 3000);
+
+				_this.socket.emit('news.create', _this.creating, result => {
+					Toast.methods.addToast(result.message, 4000);
+					if (result.status == 'success') _this.creating = {
+						title: '',
+						description: '',
+						bugs: [],
+						features: [],
+						improvements: [],
+						upcoming: []
+					}
+				});
+			},
+			removeNews: function (news) {
+				this.socket.emit('news.remove', news, res => {
+					Toast.methods.addToast(res.message, 8000);
+				});
+			},
+			editNews: function (news) {
+				this.editing = news;
+				this.toggleModal();
+			},
+			updateNews: function (close) {
+				let _this = this;
+				this.socket.emit('news.update', _this.editing._id, _this.editing, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						if (close) _this.toggleModal();
+					}
+				});
+			},
+			addChange: function (type) {
+				let change = $(`#new-${type}`).val().trim();
+
+				if (this.creating[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
+
+				if (change) this.creating[type].push(change);
+				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
+			},
+			removeChange: function (type, index) {
+				this.creating[type].splice(index, 1);
+			},
+			init: function () {
+				this.socket.emit('apis.joinAdminRoom', 'news', data => {});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			io.getSocket((socket) => {
+				_this.socket = socket;
+				_this.socket.emit('news.index', result => {
+					_this.news = result.data;
+				});
+				_this.socket.on('event:admin.news.created', news => {
+					_this.news.unshift(news);
+				});
+				_this.socket.on('event:admin.news.removed', news => {
+					_this.news = _this.news.filter(item => item._id !== news._id);
+				});
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => {
+					_this.init();
+				});
+			});
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	.tag:not(:last-child) { margin-right: 5px; }
+
+	td { vertical-align: middle; }
+
+	.is-info:focus { background-color: #0398db; }
+
+	.card-footer-item { color: #03A9F4; }
+</style>

+ 95 - 130
frontend/components/Admin/QueueSongs.vue

@@ -1,41 +1,45 @@
 <template>
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>YouTube ID</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, song) in songs' track-by='$index'>
-						<td>
-							<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes.png'">
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song._id }}</td>
-						<td>{{ song.artists.join(', ') }}</td>
-						<td>{{ song.genres.join(', ') }}</td>
-						<td>{{ song.requestedBy }}</td>
-						<td>
-							<a class='button is-primary' @click='edit(song, index)'>Edit</a>
-							<a class='button is-success' @click='add(song)'>Add</a>
-							<a class='button is-danger' @click='remove(song._id, index)'>Remove</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
+		<br /><br />
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Thumbnail</td>
+					<td>Title</td>
+					<td>YouTube ID</td>
+					<td>Artists</td>
+					<td>Genres</td>
+					<td>Requested By</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
+					<td>
+						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
+					</td>
+					<td>
+						<strong>{{ song.title }}</strong>
+					</td>
+					<td>{{ song.songId }}</td>
+					<td>{{ song.artists.join(', ') }}</td>
+					<td>{{ song.genres.join(', ') }}</td>
+					<td>{{ song.requestedBy }}</td>
+					<td>
+						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
+						<button class='button is-success' @click='add(song)'>Add</button>
+						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 	</div>
-	<edit-song v-show='isEditActive'></edit-song>
+	<nav class="pagination">
+		<a class="button" href='#' @click='getSet(position - 1)' v-if='position > 1'><i class="material-icons">navigate_before</i></a>
+		<a class="button" href='#' @click='getSet(position + 1)' v-if='maxPosition > position'><i class="material-icons">navigate_next</i></a>
+	</nav>
+	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 </template>
 
 
 <script>
 <script>
@@ -48,127 +52,86 @@
 		components: { EditSong },
 		components: { EditSong },
 		data() {
 		data() {
 			return {
 			return {
+				position: 1,
+				maxPosition: 1,
+				searchQuery: '',
 				songs: [],
 				songs: [],
-				isEditActive: false,
-				editing: {
-					index: 0,
-					song: {}
-				},
-				video: {
-					player: null,
-					paused: false,
-					settings: function (type) {
-						switch(type) {
-							case 'stop':
-								this.player.stopVideo();
-								this.paused = true;
-								break;
-							case 'pause':
-								this.player.pauseVideo();
-								this.paused = true;
-								break;
-							case 'play':
-								this.player.playVideo();
-								this.paused = false;
-								break;
-							case 'skipToLast10Secs':
-								this.player.seekTo(this.player.getDuration() - 10);
-								break;
-						}
-					}
-				}
+				modals: { editSong: false }
+			}
+		},
+		computed: {
+			filteredSongs: function () {
+				return this.$eval('songs | filterBy searchQuery');
+			}
+		},
+		watch: {
+			'modals.editSong': function (value) {
+				if (!value) this.$broadcast('stopVideo');
 			}
 			}
 		},
 		},
 		methods: {
 		methods: {
-			changeVolume: function() {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
 			toggleModal: function () {
 			toggleModal: function () {
-				this.isEditActive = !this.isEditActive;
-				this.video.settings('stop');
+				this.modals.editSong = !this.modals.editSong;
 			},
 			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
-				}
-			},
-			removeTag: function (type, index) {
-				if (type == 'genres') this.editing.song.genres.splice(index, 1);
-				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
-			},
-			edit: function (song, index) {
-				this.editing = { index, song };
-				this.video.player.loadVideoById(song._id);
-				this.isEditActive = true;
-			},
-			save: function (song) {
+			getSet: function (position) {
 				let _this = this;
 				let _this = this;
-				this.socket.emit('queueSongs.update', song._id, song, function (res) {
-					if (res.status == 'success' || res.status == 'error') Toast.methods.addToast(res.message, 2000);
-					_this.toggleModal();
+				this.socket.emit('queueSongs.getSet', position, data => {
+					_this.songs = data;
+					this.position = position;
 				});
 				});
 			},
 			},
+			edit: function (song, index) {
+				this.$broadcast('editSong', song, index, 'queueSongs');
+			},
 			add: function (song) {
 			add: function (song) {
 				this.socket.emit('songs.add', song, res => {
 				this.socket.emit('songs.add', song, res => {
 					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
 					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
+					else Toast.methods.addToast(res.message, 4000);
 				});
 				});
 			},
 			},
 			remove: function (id, index) {
 			remove: function (id, index) {
-				this.songs.splice(index, 1);
+				console.log("Removing ", id);
 				this.socket.emit('queueSongs.remove', id, res => {
 				this.socket.emit('queueSongs.remove', id, res => {
 					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
 					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
+				else Toast.methods.addToast(res.message, 4000);
 				});
 				});
+			},
+			init: function() {
+				let _this = this;
+				_this.socket.emit('queueSongs.index', data => {
+					_this.songs = data.songs;
+					_this.maxPosition = Math.round(data.maxLength / 50);
+				});
+				_this.socket.emit('apis.joinAdminRoom', 'queue', data => {});
 			}
 			}
 		},
 		},
 		ready: function () {
 		ready: function () {
 			let _this = this;
 			let _this = this;
 			io.getSocket((socket) => {
 			io.getSocket((socket) => {
 				_this.socket = socket;
 				_this.socket = socket;
-				_this.socket.emit('queueSongs.index', data => {
-					_this.songs = data;
-				});
-			});
-
-			this.video.player = new YT.Player('player', {
-				height: 315,
-				width: 560,
-				videoId: this.editing.song._id,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
-				events: {
-					'onReady': () => {
-						let volume = parseInt(localStorage.getItem("volume"));
-						volume = (typeof volume === "number") ? volume : 20;
-						_this.video.player.setVolume(volume);
-						if (volume > 0) _this.video.player.unMute();
-					},
-					'onStateChange': event => {
-						if (event.data == 1) {
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) this.stopVideo();
+				if (_this.socket.connected) {
+					_this.init();
+					_this.socket.on('event:admin.queueSong.added', queueSong => {
+						_this.songs.push(queueSong);
+					});
+					_this.socket.on('event:admin.queueSong.removed', songId => {
+						_this.songs = _this.songs.filter(function(song) {
+							return song._id !== songId;
+						});
+					});
+					_this.socket.on('event:admin.queueSong.updated', updatedSong => {
+						for (let i = 0; i < _this.songs.length; i++) {
+							let song = _this.songs[i];
+							if (song._id === updatedSong._id) {
+								_this.songs.$set(i, updatedSong);
+							}
 						}
 						}
-					}
+					});
 				}
 				}
+				io.onConnect(() => {
+					_this.init();
+				});
 			});
 			});
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
 		}
 		}
 	}
 	}
 </script>
 </script>
@@ -181,4 +144,6 @@
 	}
 	}
 
 
 	td { vertical-align: middle; }
 	td { vertical-align: middle; }
+
+	.is-primary:focus { background-color: #029ce3 !important; }
 </style>
 </style>

+ 82 - 44
frontend/components/Admin/Reports.vue

@@ -1,62 +1,100 @@
 <template>
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>Song ID</td>
-						<td>Created By</td>
-						<td>Created At</td>
-						<td>Description</td>
-						<td>Issues</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, report) in reports' track-by='$index'>
-						<td>
-							<span>{{ report.songId }}</span>
-						</td>
-						<td>
-							<span>{{ report.createdBy }}</span>
-						</td>
-						<td>
-							<span>{{ report.createdAt }}</span>
-						</td>
-						<td>
-							<span>{{ report.issues }}</span>
-						</td>
-						<td>
-							<a class='button is-primary' @click='resolve()'>Resolve</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Song ID</td>
+					<td>Created By</td>
+					<td>Created At</td>
+					<td>Description</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, report) in reports' track-by='$index'>
+					<td>
+						<span>{{ report.songId }}</span>
+					</td>
+					<td>
+						<span>{{ report.createdBy }}</span>
+					</td>
+					<td>
+						<span>{{ report.createdAt }}</span>
+					</td>
+					<td>
+						<span>{{ report.description }}</span>
+					</td>
+					<td>
+						<a class='button is-warning' href='#' @click='toggleModal(report)'>Issues Modal</a>
+						<a class='button is-primary' href='#' @click='resolve(report._id)'>Resolve</a>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 	</div>
+
+	<issues-modal v-if='modals.report'></issues-modal>
 </template>
 </template>
 
 
 <script>
 <script>
 	import { Toast } from 'vue-roaster';
 	import { Toast } from 'vue-roaster';
+	import io from '../../io';
+
+	import IssuesModal from '../Modals/IssuesModal.vue';
 
 
 	export default {
 	export default {
 		data() {
 		data() {
 			return {
 			return {
-				reports: []
+				reports: [],
+				modals: {
+					report: false
+				}
+			}
+		},
+		methods: {
+			init: function() {
+				this.socket.emit('apis.joinAdminRoom', 'reports', data => {});
+			},
+			toggleModal: function (report) {
+				this.modals.report = !this.modals.report;
+				if (this.modals.report) this.editing = report;
+			},
+			resolve: function (reportId) {
+				let _this = this;
+				this.socket.emit('reports.resolve', reportId, res => {
+					Toast.methods.addToast(res.message, 3000);
+					if (res.status === 'success' && this.modals.report) _this.toggleModal();
+				});
 			}
 			}
 		},
 		},
-		methods: {},
 		ready: function () {
 		ready: function () {
 			let _this = this;
 			let _this = this;
-			let socketInterval = setInterval(() => {
-				if (!!_this.$parent.$parent.socket) {
-					_this.socket = _this.$parent.$parent.socket;
-					_this.socket.emit('reports.index', res => {
-						_this.reports = res.data;
+			io.getSocket(socket => {
+				_this.socket = socket;
+				if (_this.socket.connected) _this.init();
+				_this.socket.emit('reports.index', res => {
+					_this.reports = res.data;
+				});
+				_this.socket.on('event:admin.report.resolved', reportId => {
+					_this.reports = _this.reports.filter(report => {
+						return report._id !== reportId;
 					});
 					});
-					clearInterval(socketInterval);
-				}
-			}, 100);
-		}
+				});
+				_this.socket.on('event:admin.report.created', report => {
+					_this.reports.push(report);
+				});
+				io.onConnect(() => {
+					_this.init();
+				});
+			});
+			if (this.$route.query.id) {
+				this.socket.emit('reports.findOne', this.$route.query.id, res => {
+					if (res.status === 'success') _this.toggleModal(res.data);
+					else Toast.methods.addToast('Report with that ID not found', 3000);
+				});
+			}
+		},
+		components: { IssuesModal }
 	}
 	}
 </script>
 </script>
 
 

+ 95 - 122
frontend/components/Admin/Songs.vue

@@ -1,53 +1,57 @@
 <template>
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>YouTube ID</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, song) in songs' track-by='$index'>
-						<td>
-							<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes.png'">
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song._id }}</td>
-						<td>{{ song.artists.join(', ') }}</td>
-						<td>{{ song.genres.join(', ') }}</td>
-						<td>{{ song.requestedBy }}</td>
-						<td>
-							<a class='button is-primary' @click='edit(song, index)'>Edit</a>
-							<a class='button is-danger' @click='remove(song._id, index)'>Remove</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
+		<br /><br />
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Thumbnail</td>
+					<td>Title</td>
+					<td>YouTube ID</td>
+					<td>Artists</td>
+					<td>Genres</td>
+					<td>Requested By</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
+					<td>
+						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
+					</td>
+					<td>
+						<strong>{{ song.title }}</strong>
+					</td>
+					<td>{{ song.songId }}</td>
+					<td>{{ song.artists.join(', ') }}</td>
+					<td>{{ song.genres.join(', ') }}</td>
+					<td>{{ song.requestedBy }}</td>
+					<td>
+						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
+						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 	</div>
-	<edit-song v-show='isEditActive'></edit-song>
+	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 </template>
 
 
 <script>
 <script>
 	import { Toast } from 'vue-roaster';
 	import { Toast } from 'vue-roaster';
 
 
 	import EditSong from '../Modals/EditSong.vue';
 	import EditSong from '../Modals/EditSong.vue';
+	import io from '../../io';
 
 
 	export default {
 	export default {
 		components: { EditSong },
 		components: { EditSong },
 		data() {
 		data() {
 			return {
 			return {
+				position: 1,
+				maxPosition: 1,
 				songs: [],
 				songs: [],
-				isEditActive: false,
+				searchQuery: '',
+				modals: { editSong: false },
 				editing: {
 				editing: {
 					index: 0,
 					index: 0,
 					song: {}
 					song: {}
@@ -55,113 +59,80 @@
 				video: {
 				video: {
 					player: null,
 					player: null,
 					paused: false,
 					paused: false,
-					settings: function (type) {
-						switch(type) {
-							case 'stop':
-								this.player.stopVideo();
-								this.paused = true;
-								break;
-							case 'pause':
-								this.player.pauseVideo();
-								this.paused = true;
-								break;
-							case 'play':
-								this.player.playVideo();
-								this.paused = false;
-								break;
-							case 'skipToLast10Secs':
-								this.player.seekTo(this.player.getDuration() - 10);
-								break;
-						}
-					}
+					playerReady: false
 				}
 				}
 			}
 			}
 		},
 		},
+		computed: {
+			filteredSongs: function () {
+				return this.$eval('songs | filterBy searchQuery');
+			}
+		},
+		watch: {
+			'modals.editSong': function (value) {
+				if (!value) this.$broadcast('stopVideo');
+			}
+		},
 		methods: {
 		methods: {
-			changeVolume: function() {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
 			toggleModal: function () {
 			toggleModal: function () {
-				this.isEditActive = !this.isEditActive;
-				this.video.settings('stop');
-			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
-				}
-			},
-			removeTag: function (type, index) {
-				if (type == 'genres') this.editing.song.genres.splice(index, 1);
-				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
+				this.modals.editSong = !this.modals.editSong;
 			},
 			},
 			edit: function (song, index) {
 			edit: function (song, index) {
-				this.editing = { index, song };
-				this.video.player.loadVideoById(song._id);
-				this.isEditActive = true;
+				this.$broadcast('editSong', song, index, 'songs');
 			},
 			},
-			save: function (song) {
+			remove: function (id) {
+				this.socket.emit('songs.remove', id, res => {
+					if (res.status == 'success') Toast.methods.addToast(res.message, 4000);
+					else Toast.methods.addToast(res.message, 8000);
+				});
+			},
+			getSet: function () {
 				let _this = this;
 				let _this = this;
-				this.socket.emit('songs.update', song._id, song, function (res) {
-					if (res.status == 'success' || res.status == 'error') Toast.methods.addToast(res.message, 2000);
-					_this.toggleModal();
+				_this.socket.emit('songs.getSet', _this.position, data => {
+					data.forEach(song => {
+						_this.songs.push(song);
+					});
+					_this.position = _this.position + 1;
+					if (_this.maxPosition > _this.position - 1) _this.getSet();
 				});
 				});
 			},
 			},
-			remove: function (id, index) {
-				this.songs.splice(index, 1);
-				this.socket.emit('songs.remove', id, res => {
-					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
+			init: function () {
+				let _this = this;
+				_this.songs = [];
+				_this.socket.emit('songs.length', length => {
+					_this.maxPosition = Math.round(length / 15);
+					_this.getSet();
 				});
 				});
+				_this.socket.emit('apis.joinAdminRoom', 'songs', () => {});
 			}
 			}
 		},
 		},
 		ready: function () {
 		ready: function () {
 			let _this = this;
 			let _this = this;
 			io.getSocket((socket) => {
 			io.getSocket((socket) => {
 				_this.socket = socket;
 				_this.socket = socket;
-				_this.socket.emit('songs.index', data => {
-					_this.songs = data;
-				});
-			});
-
-			this.video.player = new YT.Player('player', {
-				height: 315,
-				width: 560,
-				videoId: this.editing.song._id,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
-				events: {
-					'onReady': () => {
-						let volume = parseInt(localStorage.getItem("volume"));
-						volume = (typeof volume === "number") ? volume : 20;
-						_this.video.player.setVolume(volume);
-						if (volume > 0) _this.video.player.unMute();
-					},
-					'onStateChange': event => {
-						if (event.data == 1) {
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) this.stopVideo();
+				if (_this.socket.connected) {
+					_this.init();
+					_this.socket.on('event:admin.song.added', song => {
+						_this.songs.push(song);
+					});
+					_this.socket.on('event:admin.song.removed', songId => {
+						_this.songs = _this.songs.filter(function(song) {
+							return song._id !== songId;
+						});
+					});
+					_this.socket.on('event:admin.song.updated', updatedSong => {
+						for (let i = 0; i < _this.songs.length; i++) {
+							let song = _this.songs[i];
+							if (song._id === updatedSong._id) {
+								_this.songs.$set(i, updatedSong);
+							}
 						}
 						}
-					}
+					});
 				}
 				}
+				io.onConnect(() => {
+					_this.init();
+				});
 			});
 			});
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
 		}
 		}
 	}
 	}
 </script>
 </script>
@@ -176,4 +147,6 @@
 	}
 	}
 
 
 	td { vertical-align: middle; }
 	td { vertical-align: middle; }
+
+	.is-primary:focus { background-color: #029ce3 !important; }
 </style>
 </style>

+ 152 - 88
frontend/components/Admin/Stations.vue

@@ -1,132 +1,163 @@
 <template>
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>ID</td>
-						<td>Type</td>
-						<td>Display Name</td>
-						<td>Description</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, station) in stations' track-by='$index'>
-						<td>
-							<span>{{ station._id }}</span>
-						</td>
-						<td>
-							<span>{{ station.type }}</span>
-						</td>
-						<td>
-							<span>{{ station.description }}</span>
-						</td>
-						<td>
-							<span>{{ station.description }}</span>
-						</td>
-						<td>
-							<a class='button is-danger' @click='removeStation(index)'>Remove</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>ID</td>
+					<td>Name</td>
+					<td>Type</td>
+					<td>Display Name</td>
+					<td>Description</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, station) in stations' track-by='$index'>
+					<td>
+						<span>{{station._id}}</span>
+					</td>
+					<td>
+						<span>{{station.name}}</span>
+					</td>
+					<td>
+						<span>{{station.type}}</span>
+					</td>
+					<td>
+						<span>{{station.displayName}}</span>
+					</td>
+					<td>
+						<span>{{station.description}}</span>
+					</td>
+					<td>
+						<a class='button is-info' @click='editStation(station)'>Edit</a>
+						<a class='button is-danger' @click='removeStation(index)' href='#'>Remove</a>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 	</div>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<div class='card is-fullwidth'>
-				<header class='card-header'>
-					<p class='card-header-title'>Create official station</p>
-				</header>
-				<div class='card-content'>
-					<div class='content'>
-						<div class='control is-horizontal'>
-							<div class='control is-grouped'>
-								<p class='control is-expanded'>
-									<input class='input' type='text' placeholder='Unique Identifier' v-model='newStation._id'>
-								</p>
-								<p class='control is-expanded'>
-									<input class='input' type='text' placeholder='Display Name' v-model='newStation.displayName'>
-								</p>
-							</div>
+	<div class='container'>
+		<div class='card is-fullwidth'>
+			<header class='card-header'>
+				<p class='card-header-title'>Create official station</p>
+			</header>
+			<div class='card-content'>
+				<div class='content'>
+					<div class='control is-horizontal'>
+						<div class='control is-grouped'>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Name' v-model='newStation.name'>
+							</p>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Display Name' v-model='newStation.displayName'>
+							</p>
+						</div>
+					</div>
+					<label class='label'>Description</label>
+					<p class='control is-expanded'>
+						<input class='input' type='text' placeholder='Short description' v-model='newStation.description'>
+					</p>
+					<div class="control is-grouped genre-wrapper">
+						<div class="sector">
+							<p class='control has-addons'>
+								<input class='input' id='new-genre' type='text' placeholder='Genre' v-on:keyup.enter='addGenre()'>
+								<a class='button is-info' href='#' @click='addGenre()'>Add genre</a>
+							</p>
+							<span class='tag is-info' v-for='(index, genre) in newStation.genres' track-by='$index'>
+								{{ genre }}
+								<button class='delete is-info' @click='removeGenre(index)'></button>
+							</span>
+						</div>
+						<div class="sector">
+							<p class='control has-addons'>
+								<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre' v-on:keyup.enter='addBlacklistedGenre()'>
+								<a class='button is-info' href='#' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
+							</p>
+							<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
+								{{ genre }}
+								<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
+							</span>
 						</div>
 						</div>
-						<label class='label'>Description</label>
-						<p class='control is-expanded'>
-							<input class='input' type='text' placeholder='Short description' v-model='newStation.description'>
-						</p>
-						<label class='label'>Genres</label>
-						<p class='control has-addons'>
-							<input class='input' id='new-genre' type='text' placeholder='Genre'>
-							<a class='button is-info' @click='addGenre()'>Add genre</a>
-						</p>
-						<span class='tag is-info' v-for='(index, genre) in newStation.genres' track-by='$index'>
-							{{ genre }}
-							<button class='delete is-info' @click='removeGenre(index)'></button>
-						</span>
-						<label class='label'>Blacklisted Genres</label>
-						<p class='control has-addons'>
-							<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre'>
-							<a class='button is-info' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
-						</p>
-						<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
-							{{ genre }}
-							<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
-						</span>
 					</div>
 					</div>
 				</div>
 				</div>
-				<footer class='card-footer'>
-					<a class='card-footer-item' @click='createStation()'>Create</a>
-				</footer>
 			</div>
 			</div>
+			<footer class='card-footer'>
+				<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
+			</footer>
 		</div>
 		</div>
 	</div>
 	</div>
+
+	<edit-station v-show='modals.editStation'></edit-station>
 </template>
 </template>
 
 
 <script>
 <script>
 	import { Toast } from 'vue-roaster';
 	import { Toast } from 'vue-roaster';
 	import io from '../../io';
 	import io from '../../io';
 
 
+	import EditStation from '../Modals/EditStation.vue';
+
 	export default {
 	export default {
+		components: { EditStation },
 		data() {
 		data() {
 			return {
 			return {
 				stations: [],
 				stations: [],
 				newStation: {
 				newStation: {
 					genres: [],
 					genres: [],
 					blacklistedGenres: []
 					blacklistedGenres: []
-				}
+				},
+				modals: { editStation: false }
 			}
 			}
 		},
 		},
 		methods: {
 		methods: {
+			toggleModal: function () {
+				this.modals.editStation	= !this.modals.editStation;
+			},
 			createStation: function () {
 			createStation: function () {
 				let _this = this;
 				let _this = this;
-				let { newStation: { _id, displayName, description, genres, blacklistedGenres } } = this;
+				let {newStation: {name, displayName, description, genres, blacklistedGenres}} = this;
 
 
-				if (_id == undefined) return Toast.methods.addToast('Field (YouTube ID) cannot be empty', 3000);
+				if (name == undefined) return Toast.methods.addToast('Field (Name) cannot be empty', 3000);
 				if (displayName == undefined) return Toast.methods.addToast('Field (Display Name) cannot be empty', 3000);
 				if (displayName == undefined) return Toast.methods.addToast('Field (Display Name) cannot be empty', 3000);
 				if (description == undefined) return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
 				if (description == undefined) return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
 
 
 				_this.socket.emit('stations.create', {
 				_this.socket.emit('stations.create', {
-					_id,
+					name,
 					type: 'official',
 					type: 'official',
 					displayName,
 					displayName,
 					description,
 					description,
 					genres,
 					genres,
 					blacklistedGenres,
 					blacklistedGenres,
 				}, result => {
 				}, result => {
-					console.log(result);
+					Toast.methods.addToast(result.message, 3000);
+					if (result.status == 'success') this.newStation = {
+						genres: [],
+						blacklistedGenres: []
+					}
 				});
 				});
 			},
 			},
 			removeStation: function (index) {
 			removeStation: function (index) {
 				this.socket.emit('stations.remove', this.stations[index]._id, res => {
 				this.socket.emit('stations.remove', this.stations[index]._id, res => {
-					if (res.status == 'success') this.stations.splice(index, 1); Toast.methods.addToast(res.message, 3000);
+					Toast.methods.addToast(res.message, 3000);
+				});
+			},
+			editStation: function (station) {
+				this.$broadcast('editStation', {
+					_id: station._id,
+					name: station.name,
+					type: station.type,
+					partyMode: station.partyMode,
+					description: station.description,
+					privacy: station.privacy,
+					displayName: station.displayName
 				});
 				});
 			},
 			},
 			addGenre: function () {
 			addGenre: function () {
 				let genre = $('#new-genre').val().toLowerCase().trim();
 				let genre = $('#new-genre').val().toLowerCase().trim();
 				if (this.newStation.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
 				if (this.newStation.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-
-				if (genre) this.newStation.genres.push(genre);
+				if (genre) {
+					this.newStation.genres.push(genre);
+					$('#new-genre').val('');
+				}
 				else Toast.methods.addToast('Genre cannot be empty', 3000);
 				else Toast.methods.addToast('Genre cannot be empty', 3000);
 			},
 			},
 			removeGenre: function (index) { this.newStation.genres.splice(index, 1); },
 			removeGenre: function (index) { this.newStation.genres.splice(index, 1); },
@@ -134,17 +165,36 @@
 				let genre = $('#new-blacklisted-genre').val().toLowerCase().trim();
 				let genre = $('#new-blacklisted-genre').val().toLowerCase().trim();
 				if (this.newStation.blacklistedGenres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
 				if (this.newStation.blacklistedGenres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
 
 
-				if (genre) this.newStation.blacklistedGenres.push(genre);
+				if (genre) {
+					this.newStation.blacklistedGenres.push(genre);
+					$('#new-blacklisted-genre').val('');
+				}
 				else Toast.methods.addToast('Genre cannot be empty', 3000);
 				else Toast.methods.addToast('Genre cannot be empty', 3000);
 			},
 			},
-			removeBlacklistedGenre: function (index) { this.newStation.blacklistedGenres.splice(index, 1); }
+			removeBlacklistedGenre: function (index) { this.newStation.blacklistedGenres.splice(index, 1); },
+			init: function () {
+				let _this = this;
+				_this.socket.emit('stations.index', data => {
+					_this.stations = data.stations;
+				});
+				_this.socket.emit('apis.joinAdminRoom', 'stations', data => {});
+			}
 		},
 		},
 		ready: function () {
 		ready: function () {
 			let _this = this;
 			let _this = this;
 			io.getSocket((socket) => {
 			io.getSocket((socket) => {
 				_this.socket = socket;
 				_this.socket = socket;
-				_this.socket.emit('stations.index', data => {
-						_this.stations = data.stations;
+				if (_this.socket.connected) _this.init();
+				_this.socket.on('event:admin.station.added', station => {
+					_this.stations.push(station);
+				});
+				_this.socket.on('event:admin.station.removed', stationId => {
+					_this.stations = _this.stations.filter(station => {
+						return station._id !== stationId;
+					});
+				});
+				io.onConnect(() => {
+					_this.init();
 				});
 				});
 			});
 			});
 		}
 		}
@@ -152,11 +202,25 @@
 </script>
 </script>
 
 
 <style lang='scss' scoped>
 <style lang='scss' scoped>
-	.tag:not(:last-child) { margin-right: 5px; }
+	.tag {
+		margin-top: 5px;
+		&:not(:last-child) {
+			margin-right: 5px;
+		}
+	}
 
 
 	td {
 	td {
 		word-wrap: break-word;
 		word-wrap: break-word;
 		max-width: 10vw;
 		max-width: 10vw;
 		vertical-align: middle;
 		vertical-align: middle;
 	}
 	}
+
+	.is-info:focus { background-color: #0398db; }
+
+	.genre-wrapper {
+		display: flex;
+    	justify-content: space-around;
+	}
+
+	.card-footer-item { color: #029ce3; }
 </style>
 </style>

+ 300 - 0
frontend/components/Admin/Statistics.vue

@@ -0,0 +1,300 @@
+<template>
+	<div class='container'>
+		<div class="columns">
+			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
+				<header class='card-header'>
+					<p class='card-header-title'>
+						Average Logs
+					</p>
+				</header>
+				<div class='card-content'>
+					<div class='content'>
+						<table class="table">
+							<thead>
+								<tr>
+									<th> </th>
+									<th>Success</th>
+									<th>Error</th>
+									<th>Info</th>
+								</tr>
+							</thead>
+							<tbody>
+								<tr>
+									<th><strong>Average per second</strong></th>
+									<th v-bind:title="logs.second.success">{{round(logs.second.success)}}</th>
+									<th v-bind:title="logs.second.error">{{round(logs.second.error)}}</th>
+									<th v-bind:title="logs.second.info">{{round(logs.second.info)}}</th>
+								</tr>
+								<tr>
+									<th><strong>Average per minute</strong></th>
+									<th v-bind:title="logs.minute.success">{{round(logs.minute.success)}}</th>
+									<th v-bind:title="logs.minute.error">{{round(logs.minute.error)}}</th>
+									<th v-bind:title="logs.minute.info">{{round(logs.minute.info)}}</th>
+								</tr>
+								<tr>
+									<th><strong>Average per hour</strong></th>
+									<th v-bind:title="logs.hour.success">{{round(logs.hour.success)}}</th>
+									<th v-bind:title="logs.hour.error">{{round(logs.hour.error)}}</th>
+									<th v-bind:title="logs.hour.info">{{round(logs.hour.info)}}</th>
+								</tr>
+								<tr>
+									<th><strong>Average per day</strong></th>
+									<th v-bind:title="logs.day.success">{{round(logs.day.success)}}</th>
+									<th v-bind:title="logs.day.error">{{round(logs.day.error)}}</th>
+									<th v-bind:title="logs.day.info">{{round(logs.day.info)}}</th>
+								</tr>
+							</tbody>
+						</table>
+					</div>
+				</div>
+			</div>
+		</div>
+		<br>
+		<div class="columns">
+			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
+				<div class='card-content'>
+					<div class='content'>
+						<canvas id="minuteChart" height="400"></canvas>
+					</div>
+				</div>
+			</div>
+		</div>
+		<br>
+		<div class="columns">
+			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
+				<div class='card-content'>
+					<div class='content'>
+						<canvas id="hourChart" height="400"></canvas>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+	import EditUser from '../Modals/EditUser.vue';
+	import io from '../../io';
+	import Chart from 'chart.js'
+
+	export default {
+		components: {},
+		data() {
+			return {
+				successUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
+				errorUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
+				infoUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
+				successUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
+				errorUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
+				infoUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
+				minuteChart: null,
+				hourChart: null,
+				logs: {
+					second: {
+						success: 0,
+						error: 0,
+						info: 0
+					},
+					minute: {
+						success: 0,
+						error: 0,
+						info: 0
+					},
+					hour: {
+						success: 0,
+						error: 0,
+						info: 0
+					},
+					day: {
+						success: 0,
+						error: 0,
+						info: 0
+					}
+				}
+			}
+		},
+		methods: {
+			init: function () {
+				this.socket.emit('apis.joinAdminRoom', 'statistics', () => {});
+				this.socket.on('event:admin.statistics.success.units.minute', units => {
+					this.successUnitsPerMinute = units;
+					this.minuteChart.data.datasets[0].data = units;
+					this.minuteChart.update();
+				});
+				this.socket.on('event:admin.statistics.error.units.minute', units => {
+					this.errorUnitsPerMinute = units;
+					this.minuteChart.data.datasets[1].data = units;
+					this.minuteChart.update();
+				});
+				this.socket.on('event:admin.statistics.info.units.minute', units => {
+					this.infoUnitsPerMinute = units;
+					this.minuteChart.data.datasets[2].data = units;
+					this.minuteChart.update();
+				});
+				this.socket.on('event:admin.statistics.success.units.hour', units => {
+					this.successUnitsPerHour = units;
+					this.hourChart.data.datasets[0].data = units;
+					this.hourChart.update();
+				});
+				this.socket.on('event:admin.statistics.error.units.hour', units => {
+					this.errorUnitsPerHour = units;
+					this.hourChart.data.datasets[1].data = units;
+					this.hourChart.update();
+				});
+				this.socket.on('event:admin.statistics.info.units.hour', units => {
+					this.infoUnitsPerHour = units;
+					this.hourChart.data.datasets[2].data = units;
+					this.hourChart.update();
+				});
+				this.socket.on('event:admin.statistics.logs', logs => {
+					this.logs = logs;
+				});
+			},
+			round: function(number) {
+				return Math.round(number);
+			}
+		},
+		ready: function () {
+			let _this = this;
+			var minuteCtx = document.getElementById("minuteChart");
+			var hourCtx = document.getElementById("hourChart");
+
+			_this.minuteChart = new Chart(minuteCtx, {
+				type: 'line',
+				data: {
+					labels: ["-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1"],
+					datasets: [
+						{
+							label: 'Success',
+							data: [0,0,0,0,0,0,0,0,0,0],
+							backgroundColor: [
+								'rgba(75, 192, 192, 0.2)'
+							],
+							borderColor: [
+								'rgba(75, 192, 192, 1)'
+							],
+							borderWidth: 1
+						},
+						{
+							label: 'Error',
+							data: [0,0,0,0,0,0,0,0,0,0],
+							backgroundColor: [
+								'rgba(255, 99, 132, 0.2)'
+							],
+							borderColor: [
+								'rgba(255,99,132,1)'
+							],
+							borderWidth: 1
+						},
+						{
+							label: 'Info',
+							data: [0,0,0,0,0,0,0,0,0,0],
+							backgroundColor: [
+								'rgba(54, 162, 235, 0.2)'
+							],
+							borderColor: [
+								'rgba(54, 162, 235, 1)'
+							],
+							borderWidth: 1
+						}
+					]
+				},
+				options: {
+					title: {
+						display: true,
+						text: 'Logs per minute'
+					},
+					scales: {
+						yAxes: [{
+							ticks: {
+								beginAtZero: true,
+								stepSize: 1
+							}
+						}]
+					},
+					responsive: true,
+					maintainAspectRatio: false
+				}
+			});
+
+			_this.hourChart = new Chart(hourCtx, {
+				type: 'line',
+				data: {
+					labels: ["-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1"],
+					datasets: [
+						{
+							label: 'Success',
+							data: [0,0,0,0,0,0,0,0,0,0],
+							backgroundColor: [
+								'rgba(75, 192, 192, 0.2)'
+							],
+							borderColor: [
+								'rgba(75, 192, 192, 1)'
+							],
+							borderWidth: 1
+						},
+						{
+							label: 'Error',
+							data: [0,0,0,0,0,0,0,0,0,0],
+							backgroundColor: [
+								'rgba(255, 99, 132, 0.2)'
+							],
+							borderColor: [
+								'rgba(255,99,132,1)'
+							],
+							borderWidth: 1
+						},
+						{
+							label: 'Info',
+							data: [0,0,0,0,0,0,0,0,0,0],
+							backgroundColor: [
+								'rgba(54, 162, 235, 0.2)'
+							],
+							borderColor: [
+								'rgba(54, 162, 235, 1)'
+							],
+							borderWidth: 1
+						}
+					]
+				},
+				options: {
+					title: {
+						display: true,
+						text: 'Logs per hour'
+					},
+					scales: {
+						yAxes: [{
+							ticks: {
+								beginAtZero: true,
+								stepSize: 1
+							}
+						}]
+					},
+					responsive: true,
+					maintainAspectRatio: false
+				}
+			});
+
+
+			io.getSocket(socket => {
+				_this.socket = socket;
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => _this.init());
+			});
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	body { font-family: 'Roboto', sans-serif; }
+
+	.user-avatar {
+		display: block;
+		max-width: 50px;
+		margin: 0 auto;
+	}
+
+	td { vertical-align: middle; }
+
+	.is-primary:focus { background-color: #029ce3 !important; }
+</style>

+ 100 - 0
frontend/components/Admin/Users.vue

@@ -0,0 +1,100 @@
+<template>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+			<tr>
+				<td>Profile Picture</td>
+				<td>User ID</td>
+				<td>GitHub ID</td>
+				<td>Password</td>
+				<td>Username</td>
+				<td>Role</td>
+				<td>Email Address</td>
+				<td>Email Verified</td>
+				<td>Likes</td>
+				<td>Dislikes</td>
+				<td>Songs Requested</td>
+				<td>Options</td>
+			</tr>
+			</thead>
+			<tbody>
+			<tr v-for='(index, user) in users' track-by='$index'>
+				<td>
+					<img class='user-avatar' src='/assets/notes-transparent.png'>
+				</td>
+				<td>{{ user._id }}</td>
+				<td v-if='user.services.github'>{{ user.services.github.id }}</td>
+				<td v-else>Not Linked</td>
+				<td v-if='user.hasPassword'>Yes</td>
+				<td v-else>Not Linked</td>
+				<td>{{ user.username }}</td>
+				<td>{{ user.role }}</td>
+				<td>{{ user.email.address }}</td>
+				<td>{{ user.email.verified }}</td>
+				<td>{{ user.liked.length }}</td>
+				<td>{{ user.disliked.length }}</td>
+				<td>{{ user.songsRequested }}</td>
+				<td>
+					<button class='button is-primary' @click='edit(user)'>Edit</button>
+				</td>
+			</tr>
+			</tbody>
+		</table>
+	</div>
+	<edit-user v-show='modals.editUser'></edit-user>
+</template>
+
+<script>
+	import EditUser from '../Modals/EditUser.vue';
+	import io from '../../io';
+
+	export default {
+		components: { EditUser },
+		data() {
+			return {
+				users: [],
+				modals: { editUser: false }
+			}
+		},
+		methods: {
+			toggleModal: function () {
+				this.modals.editUser = !this.modals.editUser;
+			},
+			edit: function (user) {
+				this.$broadcast('editUser', user);
+			},
+			init: function () {
+				let _this = this;
+				_this.socket.emit('users.index', result => {
+					if (result.status === 'success') _this.users = result.data;
+				});
+				_this.socket.emit('apis.joinAdminRoom', 'users', () => {});
+				_this.socket.on('event:user.username.changed', username => {
+					_this.$parent.$parent.username = username;
+				});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			io.getSocket(socket => {
+				_this.socket = socket;
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => _this.init());
+			});
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	body { font-family: 'Roboto', sans-serif; }
+
+	.user-avatar {
+		display: block;
+		max-width: 50px;
+		margin: 0 auto;
+	}
+
+	td { vertical-align: middle; }
+
+	.is-primary:focus { background-color: #029ce3 !important; }
+</style>

+ 23 - 12
frontend/components/MainFooter.vue

@@ -1,13 +1,22 @@
 <template>
 <template>
-	<footer class="footer">
-		<div class="container">
-			<div class="content has-text-centered">
+	<footer class='footer'>
+		<div class='container'>
+			<div class='content has-text-centered'>
 				<p>
 				<p>
-					© Copyright Musare 2015 - {{ new Date().getFullYear() }}
+					© Copyright Musare 2015 - 2017
 				</p>
 				</p>
 				<p>
 				<p>
-					<a class="icon" href="https://github.com/musare/musarenode">
-						<i class="fa fa-github"></i>
+					<a class='icon' href='https://github.com/Musare/MusareNode' target='_blank' title='GitHub Repository'>
+						<img src='/assets/social/github.svg'/>
+					</a>
+					<a class='icon' href='https://twitter.com/MusareApp' target='_blank' title='Twitter Account'>
+						<img src='/assets/social/twitter.svg'/>
+					</a>
+					<a class='icon' href='https://www.facebook.com/MusareMusic/' target='_blank' title='Facebook Page'>
+						<img src='/assets/social/facebook.svg'/>
+					</a>
+					<a class='icon' href='https://discord.gg/Y5NxYGP' target='_blank' title='Discord Server'>
+						<img src='/assets/social/discord.svg'/>
 					</a>
 					</a>
 				</p>
 				</p>
 			</div>
 			</div>
@@ -15,12 +24,14 @@
 	</footer>
 	</footer>
 </template>
 </template>
 
 
-<style lang="scss" scoped>
-	.content a:not(.button) {
-		border: 0;
-	}
+<style lang='scss' scoped>
+	.content a:not(.button) { border: 0; }
 
 
-	.icon:visited {
-		color: #4a4a4a !important;
+	.content {
+		display: flex;
+		align-items: center;
+		flex-direction: column;
 	}
 	}
+
+	.icon:visited { color: #4a4a4a !important; }
 </style>
 </style>

+ 9 - 3
frontend/components/MainHeader.vue

@@ -14,9 +14,15 @@
 
 
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
 			<a class="nav-item is-tab admin" href="#" v-link="{ path: '/admin' }" v-if="$parent.$parent.role === 'admin'">
 			<a class="nav-item is-tab admin" href="#" v-link="{ path: '/admin' }" v-if="$parent.$parent.role === 'admin'">
-				Admin
+				<strong>Admin</strong>
 			</a>
 			</a>
-			<a class="nav-item is-tab" href="#">
+			<!--a class="nav-item is-tab" href="#">
+				About
+			</a-->
+			<a class="nav-item is-tab" href="#" v-link="{ path: '/team' }">
+				Team
+			</a>
+			<a class="nav-item is-tab" href="#" v-link="{ path: '/about' }">
 				About
 				About
 			</a>
 			</a>
 			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
 			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
@@ -100,7 +106,7 @@
 			}
 			}
 		}
 		}
 		.admin {
 		.admin {
-			color: $purple;
+			color: #424242;
 		}
 		}
 	}
 	}
 	.grouped {
 	.grouped {

+ 124 - 0
frontend/components/Modals/AddSongToPlaylist.vue

@@ -0,0 +1,124 @@
+<template>
+	<modal title='Add Song To Playlist'>
+		<div slot='body'>
+			<h4 class="songTitle">{{ $parent.currentSong.title }}</h4>
+			<h5 class="songArtist">{{ $parent.currentSong.artists }}</h5>
+			<aside class="menu">
+				<p class="menu-label">
+					Playlists
+				</p>
+				<ul class="menu-list">
+					<li v-for='playlist in playlistsArr'>
+						<div class='playlist'>
+							<span class='icon is-small' @click='removeSongFromPlaylist(playlist._id)' v-if='playlists[playlist._id].hasSong'>
+								<i class="material-icons">playlist_add_check</i>
+							</span>
+							<span class='icon' @click='addSongToPlaylist(playlist._id)' v-else>
+								<i class="material-icons">playlist_add</i>
+							</span>
+							{{ playlist.displayName }}
+						</div>
+					</li>
+				</ul>
+				</aside>
+		</div>
+	</modal>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
+	import io from '../../io';
+	import auth from '../../auth';
+
+	export default {
+		data() {
+			return {
+				playlists: {},
+				playlistsArr: [],
+				songId: null,
+				song: null
+			}
+		},
+		methods: {
+			addSongToPlaylist: function (playlistId) {
+				let _this = this;
+				this.socket.emit('playlists.addSongToPlaylist', this.$parent.currentSong.songId, playlistId, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						_this.playlists[playlistId].songs.push(_this.song);
+					}
+					_this.recalculatePlaylists();
+					//this.$parent.modals.addSongToPlaylist = false;
+				});
+			},
+			removeSongFromPlaylist: function (playlistId) {
+				let _this = this;
+				this.socket.emit('playlists.removeSongFromPlaylist', _this.songId, playlistId, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						_this.playlists[playlistId].songs.forEach((song, index) => {
+							if (song.songId === _this.songId) _this.playlists[playlistId].songs.splice(index, 1);
+						});
+					}
+					_this.recalculatePlaylists();
+					//this.$parent.modals.addSongToPlaylist = false;
+				});
+			},
+			recalculatePlaylists: function() {
+				let _this = this;
+				_this.playlistsArr = Object.values(_this.playlists).map((playlist) => {
+					let hasSong = false;
+					for (let i = 0; i < playlist.songs.length; i++) {
+						if (playlist.songs[i].songId === _this.songId) {
+							hasSong = true;
+						}
+					}
+					playlist.hasSong = hasSong;
+					_this.playlists[playlist._id] = playlist;
+					return playlist;
+				});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			this.songId = this.$parent.currentSong.songId;
+			this.song = this.$parent.currentSong;
+			io.getSocket((socket) => {
+				_this.socket = socket;
+				_this.socket.emit('playlists.indexForUser', res => {
+					if (res.status === 'success') {
+						res.data.forEach((playlist) => {
+							_this.playlists[playlist._id] = playlist;
+						});
+						_this.recalculatePlaylists();
+					}
+				});
+			});
+		},
+		events: {
+			closeModal: function () {
+				this.$parent.modals.addSongToPlaylist = !this.$parent.modals.addSongToPlaylist;
+			}
+		},
+		components: { Modal }
+	}
+</script>
+
+<style type='scss' scoped>
+	.icon.is-small {
+		margin-right: 10px !important;
+	}
+	.songTitle {
+		font-size: 22px;
+		padding: 0 10px;
+	}
+	.songArtist {
+		font-size: 19px;
+		font-weight: 200;
+		padding: 0 10px;
+	}
+	.menu-label {
+		font-size: 16px;
+	}
+</style>

+ 96 - 51
frontend/components/Modals/AddSongToQueue.vue

@@ -1,77 +1,109 @@
 <template>
 <template>
-	<div class="modal is-active">
-		<div class="modal-background"></div>
-		<div class="modal-card">
-			<header class="modal-card-head">
-				<p class="modal-card-title">Add Songs to Station</p>
-				<button class="delete" @click="$parent.toggleModal('addSongToQueue')" ></button>
-			</header>
-			<section class="modal-card-body">
-				<div class="control is-grouped">
-					<p class="control is-expanded">
-						<input class="input" type="text" placeholder="YouTube Query" v-model="querySearch">
-					</p>
-					<p class="control">
-						<a class="button is-info" @click="submitQuery()">
-							Search
+	<modal title='Add Song To Queue'>
+		<div slot='body'>
+			<aside class='menu' v-if='$parent.$parent.loggedIn && $parent.type === "community"'>
+				<ul class='menu-list'>
+					<li v-for='playlist in playlists' track-by='$index'>
+						<a href='#' target='_blank' @click='$parent.editPlaylist(playlist._id)'>{{ playlist.displayName }}</a>
+						<div class='controls'>
+							<a href='#' @click='selectPlaylist(playlist._id)' v-if="!isPlaylistSelected(playlist._id)"><i class='material-icons'>panorama_fish_eye</i></a>
+							<a href='#' @click='unSelectPlaylist()' v-if="isPlaylistSelected(playlist._id)"><i class='material-icons'>lens</i></a>
+						</div>
+					</li>
+				</ul>
+				<br />
+			</aside>
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input class="input" type="text" placeholder="YouTube Query" v-model='querySearch' autofocus @keyup.enter='submitQuery()'>
+				</p>
+				<p class="control">
+					<a class="button is-info" @click="submitQuery()" href='#'>
+						Search
+					</a>
+				</p>
+			</div>
+			<table class="table">
+				<tbody>
+				<tr v-for="result in queryResults">
+					<td>
+						<img :src="result.thumbnail" />
+					</td>
+					<td>{{ result.title }}</td>
+					<td>
+						<a class="button is-success" @click="addSongToQueue(result.id)" href='#'>
+							Add
 						</a>
 						</a>
-					</p>
-				</div>
-				<table class="table">
-					<tbody>
-						<tr v-for="result in queryResults">
-							<td>
-								<img :src="result.thumbnail" />
-							</td>
-							<td>{{ result.title }}</td>
-							<td>
-								<a class="button is-success" @click="addSongToQueue(result.id)">
-									Add
-								</a>
-							</td>
-						</tr>
-					</tbody>
-				</table>
-			</section>
+					</td>
+				</tr>
+				</tbody>
+			</table>
 		</div>
 		</div>
-	</div>
+	</modal>
 </template>
 </template>
 
 
 <script>
 <script>
 	import { Toast } from 'vue-roaster';
 	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
 	import io from '../../io';
 	import io from '../../io';
+	import auth from '../../auth';
 
 
 	export default {
 	export default {
 		data() {
 		data() {
 			return {
 			return {
 				querySearch: '',
 				querySearch: '',
-				queryResults: []
+				queryResults: [],
+				playlists: [],
+				privatePlaylistQueueSelected: null
 			}
 			}
 		},
 		},
 		methods: {
 		methods: {
+			isPlaylistSelected: function(playlistId) {
+				return this.privatePlaylistQueueSelected === playlistId;
+			},
+			selectPlaylist: function (playlistId) {
+				let _this = this;
+				if (_this.$parent.type === 'community') {
+					_this.privatePlaylistQueueSelected = playlistId;
+					_this.$parent.privatePlaylistQueueSelected = playlistId;
+					_this.$parent.addFirstPrivatePlaylistSongToQueue();
+				}
+			},
+			unSelectPlaylist: function () {
+				let _this = this;
+				if (_this.$parent.type === 'community') {
+					_this.privatePlaylistQueueSelected = null;
+					_this.$parent.privatePlaylistQueueSelected = null;
+				}
+			},
 			addSongToQueue: function (songId) {
 			addSongToQueue: function (songId) {
 				let _this = this;
 				let _this = this;
 				if (_this.$parent.type === 'community') {
 				if (_this.$parent.type === 'community') {
-					_this.socket.emit('stations.addToQueue', _this.$parent.stationId, songId, data => {
-						if (data.status !== 'success') {
-							Toast.methods.addToast(`Error: ${data.message}`, 8000);
-						} else {
-							Toast.methods.addToast(`${data.message}`, 4000);
-						}
+					_this.socket.emit('stations.addToQueue', _this.$parent.station._id, songId, data => {
+						if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
+						else Toast.methods.addToast(`${data.message}`, 4000);
 					});
 					});
 				} else {
 				} else {
 					_this.socket.emit('queueSongs.add', songId, data => {
 					_this.socket.emit('queueSongs.add', songId, data => {
-						if (data.status !== 'success') {
-							Toast.methods.addToast(`Error: ${data.message}`, 8000);
-						} else {
-							Toast.methods.addToast(`${data.message}`, 4000);
-						}
+						if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
+						else Toast.methods.addToast(`${data.message}`, 4000);
 					});
 					});
 				}
 				}
 			},
 			},
 			submitQuery: function () {
 			submitQuery: function () {
 				let _this = this;
 				let _this = this;
-				_this.socket.emit('apis.searchYoutube', _this.querySearch, results => {
+				let query = _this.querySearch;
+				if (query.indexOf('&index=') !== -1) {
+					query = query.split('&index=');
+					query.pop();
+					query = query.join('');
+				}
+				if (query.indexOf('&list=') !== -1) {
+					query = query.split('&list=');
+					query.pop();
+					query = query.join('');
+				}
+				_this.socket.emit('apis.searchYoutube', query, results => {
 					results = results.data;
 					results = results.data;
 					_this.queryResults = [];
 					_this.queryResults = [];
 					for (let i = 0; i < results.items.length; i++) {
 					for (let i = 0; i < results.items.length; i++) {
@@ -89,12 +121,25 @@
 			let _this = this;
 			let _this = this;
 			io.getSocket((socket) => {
 			io.getSocket((socket) => {
 				_this.socket = socket;
 				_this.socket = socket;
+				_this.socket.emit('playlists.indexForUser', res => {
+					if (res.status === 'success') _this.playlists = res.data;
+				});
+				_this.privatePlaylistQueueSelected = _this.$parent.privatePlaylistQueueSelected;
 			});
 			});
 		},
 		},
 		events: {
 		events: {
-			closeModal: function() {
-				this.$parent.toggleModal('addSongToQueue')
+			closeModal: function () {
+				this.$parent.modals.addSongToQueue = !this.$parent.modals.addSongToQueue;
 			}
 			}
-		}
+		},
+		components: { Modal }
+	}
+</script>
+
+<style type='scss' scoped>
+	tr td {
+		vertical-align: middle;
+
+		img { width: 55px; }
 	}
 	}
-</script>
+</style>

+ 49 - 36
frontend/components/Modals/CreateCommunityStation.vue

@@ -1,42 +1,38 @@
 <template>
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Create community station</p>
-				<button class='delete' @click='toggleModal()'></button>
-			</header>
-			<section class='modal-card-body'>
-				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class='label'>Unique ID (lowercase, a-z, used in the url)</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Name...' v-model='newCommunity._id'>
-				</p>
-				<label class='label'>Display Name</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Display name...' v-model='newCommunity.displayName'>
-				</p>
-				<label class='label'>Description</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Description...' v-model='newCommunity.description'>
-				</p>
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-primary' @click='submitModal()'>Create</a>
-			</footer>
+	<modal title='Create Community Station'>
+		<div slot='body'>
+			<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
+			<label class='label'>Name (unique lowercase station id)</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='Name...' v-model='newCommunity.name' autofocus>
+			</p>
+			<label class='label'>Display Name</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='Display name...' v-model='newCommunity.displayName'>
+			</p>
+			<label class='label'>Description</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='Description...' v-model='newCommunity.description' @keyup.enter="submitModal()">
+			</p>
 		</div>
 		</div>
-	</div>
+		<div slot='footer'>
+			<a class='button is-primary' @click='submitModal()'>Create</a>
+		</div>
+	</modal>
 </template>
 </template>
 
 
 <script>
 <script>
 	import { Toast } from 'vue-roaster';
 	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
 	import io from '../../io';
 	import io from '../../io';
+	import validation from '../../validation';
 
 
 	export default {
 	export default {
+		components: { Modal },
 		data() {
 		data() {
 			return {
 			return {
 				newCommunity: {
 				newCommunity: {
-					_id: '',
+					name: '',
 					displayName: '',
 					displayName: '',
 					description: ''
 					description: ''
 				}
 				}
@@ -50,18 +46,35 @@
 		},
 		},
 		methods: {
 		methods: {
 			toggleModal: function () {
 			toggleModal: function () {
-				this.$dispatch('toggleModal', 'createCommunityStation');
+				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
 			},
 			},
 			submitModal: function () {
 			submitModal: function () {
-				let _this = this;
-				if (_this.newCommunity._id == '') return Toast.methods.addToast('ID cannot be a blank field', 3000);
-				if (_this.newCommunity.displayName == '') return Toast.methods.addToast('Display Name cannot be a blank field', 3000);
-				if (_this.newCommunity.description == '') return Toast.methods.addToast('Description cannot be a blank field', 3000);
+				const name = this.newCommunity.name;
+				const displayName = this.newCommunity.displayName;
+				const description = this.newCommunity.description;
+				if (!name || !displayName || !description) return Toast.methods.addToast('Please fill in all fields', 8000);
+
+				if (!validation.isLength(name, 2, 16)) return Toast.methods.addToast('Name must have between 2 and 16 characters.', 8000);
+				if (!validation.regex.az09_.test(name)) return Toast.methods.addToast('Invalid name format. Allowed characters: a-z, 0-9 and _.', 8000);
+
+
+				if (!validation.isLength(displayName, 2, 32)) return Toast.methods.addToast('Display name must have between 2 and 32 characters.', 8000);
+				if (!validation.regex.azAZ09_.test(displayName)) return Toast.methods.addToast('Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.', 8000);
+
+
+				if (!validation.isLength(description, 2, 200)) return Toast.methods.addToast('Description must have between 2 and 200 characters.', 8000);
+				let characters = description.split("");
+				characters = characters.filter(function(character) {
+					return character.charCodeAt(0) === 21328;
+				});
+				if (characters.length !== 0) return Toast.methods.addToast('Invalid description format. Swastika\'s are not allowed.', 8000);
+
+
 				this.socket.emit('stations.create', {
 				this.socket.emit('stations.create', {
-					_id: _this.newCommunity._id,
+					name: name,
 					type: 'community',
 					type: 'community',
-					displayName: _this.newCommunity.displayName,
-					description: _this.newCommunity.description
+					displayName: displayName,
+					description: description
 				}, res => {
 				}, res => {
 					if (res.status === 'success') Toast.methods.addToast(`You have added the station successfully`, 4000);
 					if (res.status === 'success') Toast.methods.addToast(`You have added the station successfully`, 4000);
 					else Toast.methods.addToast(res.message, 4000);
 					else Toast.methods.addToast(res.message, 4000);
@@ -71,7 +84,7 @@
 		},
 		},
 		events: {
 		events: {
 			closeModal: function() {
 			closeModal: function() {
-				this.$dispatch('toggleModal', 'createCommunityStation');
+				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
 			}
 			}
 		}
 		}
 	}
 	}

+ 236 - 0
frontend/components/Modals/EditNews.vue

@@ -0,0 +1,236 @@
+<template>
+	<modal title='Edit News'>
+		<div slot='body'>
+			<label class='label'>Title</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='News Title' v-model='$parent.editing.title' autofocus>
+			</p>
+			<label class='label'>Description</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='News Description' v-model='$parent.editing.description'>
+			</p>
+			<div class="columns">
+				<div class="column">
+					<label class='label'>Bugs</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-bugs' type='text' placeholder='Bug' v-on:keyup.enter='addChange("bugs")'>
+						<a class='button is-info' href='#' @click='addChange("bugs")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, bug) in $parent.editing.bugs' track-by='$index'>
+						{{ bug }}
+						<button class='delete is-info' @click='removeChange("bugs", index)'></button>
+					</span>
+				</div>
+				<div class="column">
+					<label class='label'>Features</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-features' type='text' placeholder='Feature' v-on:keyup.enter='addChange("features")'>
+						<a class='button is-info' href='#' @click='addChange("features")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, feature) in $parent.editing.features' track-by='$index'>
+						{{ feature }}
+						<button class='delete is-info' @click='removeChange("features", index)'></button>
+					</span>
+				</div>
+			</div>
+
+			<div class="columns">
+				<div class="column">
+					<label class='label'>Improvements</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-improvements' type='text' placeholder='Improvement' v-on:keyup.enter='addChange("improvements")'>
+						<a class='button is-info' href='#' @click='addChange("improvements")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, improvement) in $parent.editing.improvements' track-by='$index'>
+						{{ improvement }}
+						<button class='delete is-info' @click='removeChange("improvements", index)'></button>
+					</span>
+				</div>
+				<div class="column">
+					<label class='label'>Upcoming</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-upcoming' type='text' placeholder='Upcoming' v-on:keyup.enter='addChange("upcoming")'>
+						<a class='button is-info' href='#' @click='addChange("upcoming")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, upcoming) in $parent.editing.upcoming' track-by='$index'>
+						{{ upcoming }}
+						<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
+					</span>
+				</div>
+			</div>
+		</div>
+		<div slot='footer'>
+			<button class='button is-success' @click='$parent.updateNews(false)'>
+				<i class='material-icons save-changes'>done</i>
+				<span>&nbsp;Save</span>
+			</button>
+			<button class='button is-success' @click='$parent.updateNews(true)'>
+				<i class='material-icons save-changes'>done</i>
+				<span>&nbsp;Save and close</span>
+			</button>
+			<button class='button is-danger' @click='$parent.toggleModal()'>
+				<span>&nbsp;Close</span>
+			</button>
+		</div>
+	</modal>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+
+	import Modal from './Modal.vue';
+
+	export default {
+		components: { Modal },
+		methods: {
+			addChange: function (type) {
+				let change = $(`#edit-${type}`).val().trim();
+
+				if (this.$parent.editing[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
+
+				if (change) this.$parent.editing[type].push(change);
+				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
+			},
+			removeChange: function (type, index) {
+				this.$parent.editing[type].splice(index, 1);
+			},
+		},
+		events: {
+			closeModal: function() {
+				this.$parent.toggleModal();
+			}
+		}
+	}
+</script>
+
+<style type='scss' scoped>
+	input[type=range] {
+		-webkit-appearance: none;
+		width: 100%;
+		margin: 7.3px 0;
+	}
+
+	input[type=range]:focus {
+		outline: none;
+	}
+
+	input[type=range]::-webkit-slider-runnable-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: #c2c0c2;
+		border-radius: 0;
+		border: 0;
+	}
+
+	input[type=range]::-webkit-slider-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 19px;
+		width: 19px;
+		border-radius: 15px;
+		background: #03a9f4;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: -6.5px;
+	}
+
+	input[type=range]::-moz-range-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: #c2c0c2;
+		border-radius: 0;
+		border: 0;
+	}
+
+	input[type=range]::-moz-range-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 19px;
+		width: 19px;
+		border-radius: 15px;
+		background: #03a9f4;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: -6.5px;
+	}
+
+	input[type=range]::-ms-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: #c2c0c2;
+		border-radius: 1.3px;
+	}
+
+	input[type=range]::-ms-fill-lower {
+		background: #c2c0c2;
+		border: 0;
+		border-radius: 0;
+		box-shadow: 0;
+	}
+
+	input[type=range]::-ms-fill-upper {
+		background: #c2c0c2;
+		border: 0;
+		border-radius: 0;
+		box-shadow: 0;
+	}
+
+	input[type=range]::-ms-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 15px;
+		width: 15px;
+		border-radius: 15px;
+		background: #03a9f4;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: 1.5px;
+	}
+
+	.controls {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+	}
+
+	.artist-genres {
+		display: flex;
+    	justify-content: space-between;
+	}
+
+	#volumeSlider { margin-bottom: 15px; }
+
+	.has-text-centered { padding: 10px; }
+
+	.thumbnail-preview {
+		display: flex;
+		margin: 0 auto 25px auto;
+		max-width: 200px;
+		width: 100%;
+	}
+
+	.modal-card-body, .modal-card-foot { border-top: 0; }
+
+	.label, .checkbox, h5 {
+		font-weight: normal;
+	}
+
+	.video-container {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		padding: 10px;
+
+		iframe { pointer-events: none; }
+	}
+
+	.save-changes { color: #fff; }
+
+	.tag:not(:last-child) { margin-right: 5px; }
+</style>

+ 300 - 58
frontend/components/Modals/EditSong.vue

@@ -1,117 +1,343 @@
 <template>
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<section class='modal-card-body'>
-
+	<div>
+		<modal title='Edit Song'>
+			<div slot='body'>
 				<h5 class='has-text-centered'>Video Preview</h5>
 				<h5 class='has-text-centered'>Video Preview</h5>
 				<div class='video-container'>
 				<div class='video-container'>
 					<div id='player'></div>
 					<div id='player'></div>
 					<div class="controls">
 					<div class="controls">
-						<form action="#" class="column is-7-desktop is-4-mobile">
+						<form action="#">
 							<p style="margin-top: 0; position: relative;">
 							<p style="margin-top: 0; position: relative;">
-								<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="$parent.changeVolume()" v-on:input="$parent.changeVolume()">
+								<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
 							</p>
 							</p>
 						</form>
 						</form>
 						<p class='control has-addons'>
 						<p class='control has-addons'>
-							<a class='button'>
-								<i class='material-icons' @click='$parent.video.settings("pause")' v-if='!$parent.video.paused'>pause</i>
-								<i class='material-icons' @click='$parent.video.settings("play")' v-else>play_arrow</i>
-							</a>
-							<a class='button' @click='$parent.video.settings("stop")'>
+							<button class='button' @click='settings("pause")' v-if='!video.paused'>
+								<i class='material-icons'>pause</i>
+							</button>
+							<button class='button' @click='settings("play")' v-if='video.paused'>
+								<i class='material-icons'>play_arrow</i>
+							</button>
+							<button class='button' @click='settings("stop")'>
 								<i class='material-icons'>stop</i>
 								<i class='material-icons'>stop</i>
-							</a>
-							<a class='button' @click='$parent.video.settings("skipToLast10Secs")'>
+							</button>
+							<button class='button' @click='settings("skipToLast10Secs")'>
 								<i class='material-icons'>fast_forward</i>
 								<i class='material-icons'>fast_forward</i>
-							</a>
+							</button>
 						</p>
 						</p>
 					</div>
 					</div>
 				</div>
 				</div>
 				<h5 class='has-text-centered'>Thumbnail Preview</h5>
 				<h5 class='has-text-centered'>Thumbnail Preview</h5>
-				<img class='thumbnail-preview' :src='$parent.editing.song.thumbnail' onerror="this.src='/assets/notes.png'">
+				<img class='thumbnail-preview' :src='editing.song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
 
 
-				<label class='label'>Thumbnail URL</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song.thumbnail'>
-				</p>
+				<div class="control is-horizontal">
+					<div class="control-label">
+						<label class="label">Thumbnail URL</label>
+					</div>
+					<div class="control">
+						<input class='input' type='text' v-model='editing.song.thumbnail'>
+					</div>
+				</div>
 
 
-				<h5 class='has-text-centered'>Edit Info</h5>
+				<h5 class='has-text-centered'>Edit Information</h5>
 
 
 				<p class='control'>
 				<p class='control'>
 					<label class='checkbox'>
 					<label class='checkbox'>
-						<input type='checkbox' v-model='$parent.editing.song.explicit'>
+						<input type='checkbox' v-model='editing.song.explicit'>
 						Explicit
 						Explicit
 					</label>
 					</label>
 				</p>
 				</p>
-				<label class='label'>Song ID</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song._id'>
-				</p>
-				<label class='label'>Song Title</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song.title'>
-				</p>
+				<label class='label'>Song ID & Title</label>
+				<div class="control is-horizontal">
+					<div class="control is-grouped">
+						<p class='control is-expanded'>
+							<input class='input' type='text' v-model='editing.song.songId'>
+						</p>
+						<p class='control is-expanded'>
+							<input class='input' type='text' v-model='editing.song.title' autofocus>
+						</p>
+					</div>
+				</div>
+				<label class='label'>Artists & Genres</label>
 				<div class='control is-horizontal'>
 				<div class='control is-horizontal'>
-					<div class='control is-grouped'>
+					<div class='control is-grouped artist-genres'>
 						<div>
 						<div>
 							<p class='control has-addons'>
 							<p class='control has-addons'>
 								<input class='input' id='new-artist' type='text' placeholder='Artist'>
 								<input class='input' id='new-artist' type='text' placeholder='Artist'>
-								<a class='button is-info' @click='$parent.addTag("artists")'>Add Artist</a>
+								<button class='button is-info' @click='addTag("artists")'>Add Artist</button>
 							</p>
 							</p>
-							<span class='tag is-info' v-for='(index, artist) in $parent.editing.song.artists' track-by='$index'>
+							<span class='tag is-info' v-for='(index, artist) in editing.song.artists' track-by='$index'>
 								{{ artist }}
 								{{ artist }}
-								<button class='delete is-info' @click='$parent.$parent.removeTag("artists", index)'></button>
+								<button class='delete is-info' @click='removeTag("artists", index)'></button>
 							</span>
 							</span>
 						</div>
 						</div>
 						<div>
 						<div>
 							<p class='control has-addons'>
 							<p class='control has-addons'>
 								<input class='input' id='new-genre' type='text' placeholder='Genre'>
 								<input class='input' id='new-genre' type='text' placeholder='Genre'>
-								<a class='button is-info' @click='$parent.addTag("genres")'>Add Genre</a>
+								<button class='button is-info' @click='addTag("genres")'>Add Genre</button>
 							</p>
 							</p>
-							<span class='tag is-info' v-for='(index, genre) in $parent.editing.song.genres' track-by='$index'>
+							<span class='tag is-info' v-for='(index, genre) in editing.song.genres' track-by='$index'>
 								{{ genre }}
 								{{ genre }}
-								<button class='delete is-info' @click='$parent.$parent.removeTag("genres", index)'></button>
+								<button class='delete is-info' @click='removeTag("genres", index)'></button>
 							</span>
 							</span>
 						</div>
 						</div>
 					</div>
 					</div>
 				</div>
 				</div>
 				<label class='label'>Song Duration</label>
 				<label class='label'>Song Duration</label>
 				<p class='control'>
 				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song.duration'>
+					<input class='input' type='text' v-model='editing.song.duration'>
 				</p>
 				</p>
 				<label class='label'>Skip Duration</label>
 				<label class='label'>Skip Duration</label>
 				<p class='control'>
 				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song.skipDuration'>
+					<input class='input' type='text' v-model='editing.song.skipDuration'>
 				</p>
 				</p>
-
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-success' @click='$parent.save($parent.editing.song)'>
+				<article class="message">
+					<div class="message-body">
+						<span class="reports-length">
+							{{ reports.length }}
+							<span v-if="reports.length > 1 || reports.length <= 0">&nbsp;Reports</span>
+							<span v-else>&nbsp;Report</span>
+						</span>
+						<div v-for='report in reports'>
+							<a :href='`/admin/reports?id=${report}`' class='report-link'>Report - {{ report }}</a>
+						</div>
+					</div>
+				</article>
+				<hr />
+				<h5 class='has-text-centered'>Spotify Information</h5>
+				<label class='label'>Song title</label>
+				<p class='control'>
+					<input class='input' type='text' v-model='spotify.title'>
+				</p>
+				<label class='label'>Song artist (1 artist full name)</label>
+				<p class='control'>
+					<input class='input' type='text' v-model='spotify.artist'>
+				</p>
+				<button class='button is-success' @click='getSpotifySongs()'>
+					Get Spotify songs
+				</button>
+				<hr />
+				<article class="media" v-for='song in spotify.songs'>
+					<figure class="media-left">
+						<p class="image is-64x64">
+							<img :src="song.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+						</p>
+					</figure>
+					<div class="media-content">
+						<div class="content">
+							<p>
+								<strong>{{song.title}}</strong>
+								<br />
+								<small>Artists: {{song.artists}}</small>, <small>Duration: {{song.duration}}</small>, <small>Explicit: {{song.explicit}}</small>
+								<br />
+								<small>Thumbnail: {{song.thumbnail}}</small>
+							</p>
+						</div>
+					</div>
+				</article>
+			</div>
+			<div slot='footer'>
+				<button class='button is-success' @click='save(editing.song, false)'>
 					<i class='material-icons save-changes'>done</i>
 					<i class='material-icons save-changes'>done</i>
 					<span>&nbsp;Save</span>
 					<span>&nbsp;Save</span>
-				</a>
-				<a class='button is-danger' @click='$parent.toggleModal()'>
-					<span>&nbsp;Cancel</span>
-				</a>
-			</footer>
-		</div>
+				</button>
+				<button class='button is-success' @click='save(editing.song, true)'>
+					<i class='material-icons save-changes'>done</i>
+					<span>&nbsp;Save and close</span>
+				</button>
+				<button class='button is-danger' @click='$parent.toggleModal()'>
+					<span>&nbsp;Close</span>
+				</button>
+			</div>
+		</modal>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <script>
+	import io from '../../io';
+	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
+
 	export default {
 	export default {
+		components: { Modal },
+		data() {
+			return {
+				editing: {
+					index: 0,
+					song: {},
+					type: ''
+				},
+				reports: 0,
+				video: {
+					player: null,
+					paused: false,
+					playerReady: false
+				},
+				spotify: {
+					title: '',
+					artist: '',
+					songs: []
+				}
+			}
+		},
 		methods: {
 		methods: {
-			toggleModal: function () {
-				this.$dispatch('toggleModal', 'login');
+			save: function (song, close) {
+				let _this = this;
+				this.socket.emit(`${_this.editing.type}.update`, song._id, song, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						_this.$parent.songs.forEach(lSong => {
+							if (song._id === lSong._id) {
+								for (let n in song) {
+									lSong[n] = song[n];
+								}
+							}
+						});
+					}
+					if (close) _this.$parent.toggleModal();
+				});
+			},
+			settings: function (type) {
+				let _this = this;
+				switch(type) {
+					case 'stop':
+						_this.video.player.stopVideo();
+						_this.video.paused = true;
+						break;
+					case 'pause':
+						_this.video.player.pauseVideo();
+						_this.video.paused = true;
+						break;
+					case 'play':
+						_this.video.player.playVideo();
+						_this.video.paused = false;
+						break;
+					case 'skipToLast10Secs':
+						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
+						break;
+				}
+			},
+			changeVolume: function () {
+				let local = this;
+				let volume = $("#volumeSlider").val();
+				localStorage.setItem("volume", volume);
+				local.video.player.setVolume(volume);
+				if (volume > 0) local.video.player.unMute();
+			},
+			addTag: function (type) {
+				if (type == 'genres') {
+					let genre = $('#new-genre').val().toLowerCase().trim();
+					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
+					if (genre) {
+						this.editing.song.genres.push(genre);
+						$('#new-genre').val('');
+					} else Toast.methods.addToast('Genre cannot be empty', 3000);
+				} else if (type == 'artists') {
+					let artist = $('#new-artist').val();
+					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
+					if ($('#new-artist').val() !== '') {
+						this.editing.song.artists.push(artist);
+						$('#new-artist').val('');
+					} else Toast.methods.addToast('Artist cannot be empty', 3000);
+				}
+			},
+			removeTag: function (type, index) {
+				if (type == 'genres') this.editing.song.genres.splice(index, 1);
+				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
 			},
 			},
-			submitModal: function () {
-				this.$dispatch('login');
-				this.toggleModal();
+			getSpotifySongs: function() {
+				this.socket.emit('apis.getSpotifySongs', this.spotify.title, this.spotify.artist, (res) => {
+					if (res.status === 'success') {
+						Toast.methods.addToast(`Succesfully got ${res.songs.length} song${(res.songs.length !== 1) ? 's' : ''}.`, 3000);
+						this.spotify.songs = res.songs;
+					} else Toast.methods.addToast(`Failed to get songs. ${res.message}`, 3000);
+				});
 			}
 			}
 		},
 		},
+		ready: function () {
+
+			let _this = this;
+
+			io.getSocket(socket => {
+				_this.socket = socket;
+			});
+
+			setInterval(() => {
+				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
+					_this.video.paused = false;
+					_this.video.player.stopVideo();
+				}
+			}, 200);
+
+			this.video.player = new YT.Player('player', {
+				height: 315,
+				width: 560,
+				videoId: this.editing.song.songId,
+				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
+				startSeconds: _this.editing.song.skipDuration,
+				events: {
+					'onReady': () => {
+						let volume = parseInt(localStorage.getItem("volume"));
+						volume = (typeof volume === "number") ? volume : 20;
+						_this.video.player.seekTo(_this.editing.song.skipDuration);
+						_this.video.player.setVolume(volume);
+						if (volume > 0) _this.video.player.unMute();
+						_this.playerReady = true;
+					},
+					'onStateChange': event => {
+						if (event.data === 1) {
+							_this.video.paused = false;
+							let youtubeDuration = _this.video.player.getDuration();
+							youtubeDuration -= _this.editing.song.skipDuration;
+							if (_this.editing.song.duration > youtubeDuration) {
+								this.video.player.stopVideo();
+								_this.video.paused = true;
+								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
+							} else if (_this.editing.song.duration <= 0) {
+								this.video.player.stopVideo();
+								_this.video.paused = true;
+								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
+							}
+
+							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
+								_this.video.player.seekTo(10);
+							}
+						} else if (event.data === 2) {
+							this.video.paused = true;
+						}
+					}
+				}
+			});
+
+			let volume = parseInt(localStorage.getItem("volume"));
+			volume = (typeof volume === "number") ? volume : 20;
+			$("#volumeSlider").val(volume);
+
+		},
 		events: {
 		events: {
-			closeModal: function() {
-				this.$parent.toggleModal()
+			closeModal: function () {
+				this.$parent.modals.editSong = false;
+				this.video.player.stopVideo();
+			},
+			editSong: function (song, index, type) {
+				let _this = this;
+				this.video.player.loadVideoById(song.songId, this.editing.song.skipDuration);
+				let newSong = {};
+				for (let n in song) {
+					newSong[n] = song[n];
+				}
+				this.editing = {
+					index,
+					song: newSong,
+					type
+				};
+				_this.socket.emit('reports.getReportsForSong', song.songId, res => {
+					if (res.status === 'success') _this.reports = res.data;
+				});
+				this.$parent.toggleModal();
+			},
+			stopVideo: function () {
+				this.video.player.stopVideo();
 			}
 			}
 		}
 		}
 	}
 	}
@@ -213,13 +439,18 @@
 		align-items: center;
 		align-items: center;
 	}
 	}
 
 
+	.artist-genres {
+		display: flex;
+    	justify-content: space-between;
+	}
+
 	#volumeSlider { margin-bottom: 15px; }
 	#volumeSlider { margin-bottom: 15px; }
 
 
 	.has-text-centered { padding: 10px; }
 	.has-text-centered { padding: 10px; }
 
 
 	.thumbnail-preview {
 	.thumbnail-preview {
 		display: flex;
 		display: flex;
-		margin: 0 auto;
+		margin: 0 auto 25px auto;
 		max-width: 200px;
 		max-width: 200px;
 		width: 100%;
 		width: 100%;
 	}
 	}
@@ -242,4 +473,15 @@
 	.save-changes { color: #fff; }
 	.save-changes { color: #fff; }
 
 
 	.tag:not(:last-child) { margin-right: 5px; }
 	.tag:not(:last-child) { margin-right: 5px; }
-</style>
+
+	.reports-length {
+		color: #03A9F4;
+		font-weight: bold;
+		display: flex;
+		justify-content: center;
+	}
+
+	.report-link {
+		color: #000;
+	}
+</style>

Some files were not shown because too many files changed in this diff