Browse Source

Merge branch 'polishing'

Kristian Vos 3 years ago
parent
commit
f96a0990ff
100 changed files with 24215 additions and 15388 deletions
  1. 10 7
      .env.example
  2. 42 0
      .github/workflows/build-eslint.yml
  3. 2 0
      .gitignore
  4. 8 4
      .travis.yml
  5. 35 0
      .wiki/Backend_Commands.md
  6. 103 0
      .wiki/Configuration.md
  7. 120 0
      .wiki/Installation.md
  8. 20 0
      .wiki/Technical_Overview.md
  9. 29 0
      .wiki/Utility_Script.md
  10. 59 0
      .wiki/Value_Formats.md
  11. 69 291
      README.md
  12. 1 0
      backend/.eslintignore
  13. 45 0
      backend/.eslintrc
  14. 1 0
      backend/.prettierignore
  15. 9 0
      backend/.prettierrc
  16. 0 13
      backend/.snyk
  17. 2 6
      backend/Dockerfile
  18. 63 42
      backend/classes/Timer.class.js
  19. 67 34
      backend/config/template.json
  20. 637 178
      backend/core.js
  21. 340 352
      backend/index.js
  22. 5 0
      backend/loadEnvVariables.js
  23. 213 98
      backend/logic/actions/activities.js
  24. 226 205
      backend/logic/actions/apis.js
  25. 102 0
      backend/logic/actions/dataRequests.js
  26. 49 55
      backend/logic/actions/hooks/adminRequired.js
  27. 6 6
      backend/logic/actions/hooks/index.js
  28. 39 46
      backend/logic/actions/hooks/loginRequired.js
  29. 67 68
      backend/logic/actions/hooks/ownerRequired.js
  30. 23 13
      backend/logic/actions/index.js
  31. 259 235
      backend/logic/actions/news.js
  32. 1970 1158
      backend/logic/actions/playlists.js
  33. 212 163
      backend/logic/actions/punishments.js
  34. 0 394
      backend/logic/actions/queueSongs.js
  35. 500 354
      backend/logic/actions/reports.js
  36. 1469 1029
      backend/logic/actions/songs.js
  37. 3945 2354
      backend/logic/actions/stations.js
  38. 2567 2073
      backend/logic/actions/users.js
  39. 89 90
      backend/logic/actions/utils.js
  40. 466 78
      backend/logic/activities.js
  41. 283 43
      backend/logic/api.js
  42. 517 538
      backend/logic/app.js
  43. 279 260
      backend/logic/cache/index.js
  44. 4 8
      backend/logic/cache/schemas/officialPlaylist.js
  45. 3 7
      backend/logic/cache/schemas/playlist.js
  46. 7 5
      backend/logic/cache/schemas/punishment.js
  47. 4 0
      backend/logic/cache/schemas/recentActivity.js
  48. 6 10
      backend/logic/cache/schemas/session.js
  49. 1 5
      backend/logic/cache/schemas/song.js
  50. 3 7
      backend/logic/cache/schemas/station.js
  51. 306 362
      backend/logic/db/index.js
  52. 54 13
      backend/logic/db/schemas/activity.js
  53. 7 0
      backend/logic/db/schemas/dataRequest.js
  54. 5 7
      backend/logic/db/schemas/news.js
  55. 20 3
      backend/logic/db/schemas/playlist.js
  56. 4 3
      backend/logic/db/schemas/punishment.js
  57. 3 2
      backend/logic/db/schemas/queueSong.js
  58. 16 8
      backend/logic/db/schemas/report.js
  59. 16 14
      backend/logic/db/schemas/song.js
  60. 27 21
      backend/logic/db/schemas/station.js
  61. 20 8
      backend/logic/db/schemas/user.js
  62. 0 113
      backend/logic/discord.js
  63. 0 382
      backend/logic/io.js
  64. 88 48
      backend/logic/mail/index.js
  65. 34 0
      backend/logic/mail/schemas/dataRequest.js
  66. 14 22
      backend/logic/mail/schemas/passwordRequest.js
  67. 14 22
      backend/logic/mail/schemas/resetPasswordRequest.js
  68. 18 27
      backend/logic/mail/schemas/verifyEmail.js
  69. 136 0
      backend/logic/migration/index.js
  70. 176 0
      backend/logic/migration/migrations/migration1.js
  71. 62 0
      backend/logic/migration/migrations/migration10.js
  72. 59 0
      backend/logic/migration/migrations/migration11.js
  73. 67 0
      backend/logic/migration/migrations/migration12.js
  74. 60 0
      backend/logic/migration/migrations/migration13.js
  75. 65 0
      backend/logic/migration/migrations/migration14.js
  76. 46 0
      backend/logic/migration/migrations/migration15.js
  77. 91 0
      backend/logic/migration/migrations/migration2.js
  78. 75 0
      backend/logic/migration/migrations/migration3.js
  79. 99 0
      backend/logic/migration/migrations/migration4.js
  80. 152 0
      backend/logic/migration/migrations/migration5.js
  81. 45 0
      backend/logic/migration/migrations/migration6.js
  82. 45 0
      backend/logic/migration/migrations/migration7.js
  83. 214 0
      backend/logic/migration/migrations/migration8.js
  84. 85 0
      backend/logic/migration/migrations/migration9.js
  85. 276 246
      backend/logic/notifications.js
  86. 1146 279
      backend/logic/playlists.js
  87. 279 288
      backend/logic/punishments.js
  88. 1183 257
      backend/logic/songs.js
  89. 0 116
      backend/logic/spotify.js
  90. 1670 1169
      backend/logic/stations.js
  91. 468 322
      backend/logic/tasks.js
  92. 347 766
      backend/logic/utils.js
  93. 771 0
      backend/logic/ws.js
  94. 430 0
      backend/logic/youtube.js
  95. 432 474
      backend/package-lock.json
  96. 30 22
      backend/package.json
  97. 15 16
      docker-compose.yml
  98. 49 139
      fallback.html
  99. 1 1
      frontend/.babelrc
  100. 19 9
      frontend/.eslintrc

+ 10 - 7
.env.example

@@ -1,18 +1,21 @@
-REDIS_PASSWORD=PASSWORD
+COMPOSE_PROJECT_NAME=musare
 
 
+BACKEND_HOST=127.0.0.1
 BACKEND_PORT=8080
 BACKEND_PORT=8080
+
+FRONTEND_HOST=127.0.0.1
 FRONTEND_PORT=80
 FRONTEND_PORT=80
+FRONTEND_MODE=dev
 
 
+MONGO_HOST=127.0.0.1
 MONGO_PORT=27017
 MONGO_PORT=27017
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_USER_USERNAME=musare
 MONGO_USER_USERNAME=musare
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
 
 
-MONGOCLIENT_PORT=3000
-
+REDIS_HOST=127.0.0.1
 REDIS_PORT=6379
 REDIS_PORT=6379
+REDIS_PASSWORD=PASSWORD
 
 
-COMPOSE_PROJECT_NAME=musare
-
-FRONTEND_MODE=dev
-SNYK_TOKEN=
+BACKUP_LOCATION=
+BACKUP_NAME=

+ 42 - 0
.github/workflows/build-eslint.yml

@@ -0,0 +1,42 @@
+name: Musare Build and ESLint
+
+on:
+    push:
+        branches: [ polishing ]
+    pull_request:
+        branches: [ polishing ]
+    workflow_dispatch:
+
+env:
+    COMPOSE_PROJECT_NAME: musare
+    BACKEND_HOST: 127.0.0.1
+    BACKEND_PORT: 8080
+    FRONTEND_HOST: 127.0.0.1
+    FRONTEND_PORT: 80
+    FRONTEND_MODE: dev
+    MONGO_HOST: 127.0.0.1
+    MONGO_PORT: 27017
+    MONGO_ROOT_PASSWORD: PASSWORD_HERE
+    MONGO_USER_USERNAME: musare
+    MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
+    REDIS_HOST: 127.0.0.1
+    REDIS_PORT: 6379
+    REDIS_PASSWORD: PASSWORD
+
+jobs:
+    build-eslint:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/checkout@v2
+            - name: Build Musare
+              run: |
+                  cp backend/config/template.json backend/config/default.json
+                  cp frontend/dist/config/template.json frontend/dist/config/default.json
+                  docker-compose build
+                  docker-compose pull
+            - name: Start Musare
+              run: docker-compose up -d
+            - name: ESlint Backend
+              run: docker-compose exec -T backend /bin/bash -c "npx eslint app/logic"
+            - name: ESLint Frontend
+              run: docker-compose exec -T frontend /bin/bash -c "cd app && npm run lint"

+ 2 - 0
.gitignore

@@ -12,6 +12,7 @@ startMongo.cmd
 .db
 .db
 .redis
 .redis
 *.rdb
 *.rdb
+backups/
 
 
 npm-debug.log
 npm-debug.log
 lerna-debug.log
 lerna-debug.log
@@ -30,6 +31,7 @@ frontend/dist/config/default.json
 
 
 npm
 npm
 node_modules
 node_modules
+package-lock.json
 
 
 # Logs
 # Logs
 log/
 log/

+ 8 - 4
.travis.yml

@@ -8,17 +8,20 @@ services:
 
 
 env:
 env:
   global:
   global:
-    - REDIS_PASSWORD=PASSWORD
+    - COMPOSE_PROJECT_NAME=musare
+    - BACKEND_HOST=127.0.0.1
     - BACKEND_PORT=8080
     - BACKEND_PORT=8080
+    - FRONTEND_HOST=127.0.0.1
     - FRONTEND_PORT=80
     - FRONTEND_PORT=80
+    - FRONTEND_MODE=dev
+    - MONGO_HOST=127.0.0.1
     - MONGO_PORT=27017
     - MONGO_PORT=27017
     - MONGO_ROOT_PASSWORD=PASSWORD_HERE
     - MONGO_ROOT_PASSWORD=PASSWORD_HERE
     - MONGO_USER_USERNAME=musare
     - MONGO_USER_USERNAME=musare
     - MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
     - MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
-    - MONGOCLIENT_PORT=3000
+    - REDIS_HOST=127.0.0.1
     - REDIS_PORT=6379
     - REDIS_PORT=6379
-    - COMPOSE_PROJECT_NAME=musare
-    - FRONTEND_MODE=prod
+    - REDIS_PASSWORD=PASSWORD
 
 
 before_install:
 before_install:
   # create config files from template
   # create config files from template
@@ -38,3 +41,4 @@ jobs:
         - docker-compose up -d redis # start redis
         - docker-compose up -d redis # start redis
         - docker-compose build backend # build backend
         - docker-compose build backend # build backend
         - docker-compose up -d backend # start backend
         - docker-compose up -d backend # start backend
+        # - docker-compose exec backend /bin/bash -c "npx eslint app/logic" # using eslint to check for formatting/linting issues

+ 35 - 0
.wiki/Backend_Commands.md

@@ -0,0 +1,35 @@
+# Backend Commands
+Backend commands are inputted via STDIN or if using the Utility Script by using `./musare.sh attach backend`.
+
+## Commands
+| Command | Parameters | Description |
+| --- | --- | --- |
+| `rs` | | Restart backend. |
+| `status` | | Returns all modules and a sample of information including, state, jobs queued, running and paused, concurrency (amount of jobs that can run simultaneously), and (startup) stage. |
+| `queued` | `module` | Returns all jobs queued for specified module. |
+| `running` | `module` | Returns all jobs running for specified module. |
+| `paused` | `module` | Returns all jobs paused for specified module. |
+| `jobinfo` | `UUID` | Returns a detailed overview of a specified job. |
+| `runjob` | `module job_name json_encoded_payload` | Run a specified job in a specified module including a JSON encoded payload, and return response. |
+| `eval` | `some_javascript` | Execute JavaScript within the index.js context and return response. |
+| `lockdown` | | Lockdown backend. |
+| `stats` | `module` | Returns job statistics for a specified module. |
+
+## Modules
+When specifying a module please use all lowercase. The available modules are as follows:
+
+- Cache
+- DB
+- Mail
+- Activities
+- API
+- App
+- WS
+- Notifications
+- Playlists
+- Punishments
+- Songs
+- Stations
+- Tasks
+- Utils
+- YouTube

+ 103 - 0
.wiki/Configuration.md

@@ -0,0 +1,103 @@
+# Configuration
+
+## Backend
+Location: `backend/config/default.json`
+
+| Property | Description |
+| --- | --- |
+| `mode` | Should be either `development` or `production`. |
+| `migration` | Should be set to `true` if you need to update MongoDB documents to a newer version after an update. Should be false at all other times. |
+| `secret` | Set to something unique and secure - used by express's session module. |
+| `domain` | Should be the url where the site will be accessible from, usually `http://localhost` for non-Docker. |
+| `serverDomain` | Should be the url where the backend will be accessible from, usually `http://localhost/backend` for docker or `http://localhost:8080` for non-Docker. |
+| `serverPort` | Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker. |
+| `registrationDisabled` | If set to true, users can't register accounts. |
+| `hideAutomaticallyRequestedSongs` | If `true` any automatically requested songs will be hidden. |
+| `hideAnonymousSongs` | If `true` any anonymously requested songs will be hidden. |
+| `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. |
+| `apis.youtube.key` | YouTube Data API v3 key, obtained from [here](https://developers.google.com/youtube/v3/getting-started). |
+| `apis.youtube.rateLimit` | Minimum interval between YouTube API requests in milliseconds. |
+| `apis.youtube.requestTimeout` | YouTube API requests timeout in milliseconds. |
+| `apis.youtube.retryAmount` | The amount of retries to perform of a failed YouTube API request. |
+| `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained from [here](https://www.google.com/recaptcha/admin). |
+| `apis.recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
+| `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
+| `apis.github.secret` | GitHub OAuth Application secret, obtained with client. |
+| `apis.github.redirect_uri` | The authorization callback url is the backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. |
+| `apis.discogs.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
+| `apis.discogs.secret` | Discogs Application secret, obtained with client. |
+| `apis.discogs.enabled` | Whether to enable Discogs API usage. |
+| `cors.origin` | Array of allowed request origin urls, for example `http://localhost`. |
+| `smtp.host` | SMTP Host |
+| `smtp.port` | SMTP Port |
+| `smtp.auth.user` | SMTP Username |
+| `smtp.auth.pass` | SMTP Password |
+| `smtp.secure` | Whether SMTP is secured. |
+| `smtp.enabled` | Whether SMTP and sending emails is enabled. |
+| `redis.url` | Should be left as default for Docker installations, else changed to `redis://localhost:6379/0`. |
+| `redis.password` | Redis password. |
+| `mongo.url` | For Docker replace temporary MongoDB musare user password with one specified in `.env`, and for non-Docker replace `@musare:27017` with `@localhost:27017`. |
+| `cookie.domain` | The ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
+| `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
+| `cookie.SIDname` | Name of the cookie stored for sessions. |
+| `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
+| `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
+| `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`. 
+| `debug.traceUnhandledPromises` | Enables the trace-unhandled package, which provides detailed information when a promise is unhandled. |
+| `debug.captureJobs` | Array of jobs to capture for `debug.stationIssue`. |
+| `defaultLogging.hideType` | Filters out specified message types from log, for example `INFO`, `SUCCESS`, `ERROR` and `STATION_ISSUE`. |
+| `defaultLogging.blacklistedTerms` | Filters out messages containing specified terms from log, for example `success`. |
+| `customLoggingPerModule.[module].hideType` | Where `[module]` is a module name specify hideType as you would `defaultLogging.hideType` to overwrite default. |
+| `customLoggingPerModule.[module].blacklistedTerms` | Where `[module]` is a module name specify blacklistedTerms as you would `defaultLogging.blacklistedTerms` to overwrite default. |
+| `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
+
+## Frontend
+Location: `frontend/dist/config/default.json`
+
+| Property | Description |
+| --- | --- |
+| `mode` | Should be either `development` or `production`. |
+| `backend.apiDomain` | Should be the url where the backend will be accessible from, usually `http://localhost/backend` for docker or `http://localhost:8080` for non-Docker. |
+| `backend.websocketsDomain` | Should be the same as the `apiDomain`, except using the `ws://` protocol instead of `http://` and with `/ws` at the end. |
+| `devServer.webSocketURL` | Should be the webpack-dev-server websocket URL, usually `ws://localhost/ws`. |
+| `devServer.port` | Should be the port where webpack-dev-server will be accessible from, should always be port `81` for Docker since nginx listens on port 80, and is recommended to be port `80` for non-Docker. |
+| `frontendDomain` | Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker. |
+| `recaptcha.key` | ReCaptcha Site v3 key, obtained from [here](https://www.google.com/recaptcha/admin). |
+| `recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
+| `cookie.domain` | Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
+| `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
+| `cookie.SIDname` | Name of the cookie stored for sessions. |
+| `siteSettings.logo_white` | Path to the white logo image, by default it is `/assets/white_wordmark.png`. |
+| `siteSettings.logo_blue` | Path to the blue logo image, by default it is `/assets/blue_wordmark.png`. |
+| `siteSettings.sitename` | Should be the name of the site. |
+| `siteSettings.github` | URL of GitHub repository, defaults to `https://github.com/Musare/MusareNode`. |
+| `messages.accountRemoval` | Message to return to users on account removal. |
+| `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
+| `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
+| `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
+
+## Docker Environment
+Location: `.env`
+
+In the table below the container host refers to the IP address that the docker container listens on, setting this to `127.0.0.1` for example will only expose the configured port to localhost, whereas setting to `0.0.0.0` will expose the port on all interfaces.
+
+The container port refers to the external docker container port, used to access services within the container. Changing this does not require any changes to configuration within container. For example setting the `MONGO_PORT` to `21018` will allow you to access the mongo service through that port, even though the application within the container is listening on `21017`.
+
+| Property | Description |
+| --- | --- |
+| `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
+| `BACKEND_HOST` | Backend container host. |
+| `BACKEND_PORT` | Backend container port. |
+| `FRONTEND_HOST` | Frontend container host. |
+| `FRONTEND_PORT` | Frontend container port. |
+| `FRONTEND_MODE` | Should be either `dev` or `prod`. |
+| `MONGO_HOST` | Mongo container host. |
+| `MONGO_PORT` | Mongo container port. |
+| `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
+| `MONGO_USER_USERNAME` | Application username for MongoDB. |
+| `MONGO_USER_PASSWORD` | Application password for MongoDB. |
+| `REDIS_HOST` | Redis container host. |
+| `REDIS_PORT` | Redis container port. |
+| `REDIS_PASSWORD` | Redis password. |
+| `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
+| `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |

+ 120 - 0
.wiki/Installation.md

@@ -0,0 +1,120 @@
+# Installation
+Musare can be installed with Docker (recommended) or without, guides for both installations can be found below.
+
+## Docker
+
+### Dependencies
+- [Git](https://github.com/git-guides/install-git)
+- [Docker](https://docs.docker.com/get-docker/)
+- [docker-compose](https://docs.docker.com/compose/install/)
+
+### Instructions
+1. `git clone https://github.com/Musare/MusareNode.git`
+2. `cd MusareNode`
+3. `cp backend/config/template.json backend/config/default.json` and configure as per [Configuration](./Configuration.md#Backend)
+4. `cp frontend/dist/config/template.json frontend/dist/config/default.json` and configure as per [Configuration](./Configuration.md#Frontend)
+5. `cp .env.example .env` and configure as per [Configuration](./Configuration.md#Docker-Environment).
+6. `./musare.sh build`
+7. `./musare.sh start`
+8. **(optional)** Register a new user on the website and grant the admin role by running `./musare.sh admin add USERNAME`.
+
+### Fixing the "couldn't connect to docker daemon" error
+
+**Windows Only**
+
+Some people have had issues while trying to execute the `docker-compose` command.
+To fix this, you will have to run `docker-machine env default`.
+This command will print various variables.
+At the bottom, it will say something similar to `@FOR /f "tokens=*" %i IN ('docker-machine env default') DO @%i`.
+Run this command in your shell. You will have to do this command for every shell you want to run `docker-compose` in (every session).
+
+---
+
+## Non-Docker
+
+### Dependencies
+- [Git](https://github.com/git-guides/install-git)
+- [Redis](http://redis.io/download)
+- [MongoDB](https://www.mongodb.com/try/download/community)
+- [NodeJS](https://nodejs.org/en/download/)
+    - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
+    - [nodemon](https://github.com/remy/nodemon#installation)
+    - [node-gyp](https://github.com/nodejs/node-gyp#installation)
+    - [webpack](https://webpack.js.org/guides/installation/#global-installation)
+
+### Instructions
+1. `git clone https://github.com/Musare/MusareNode.git`
+2. `cd MusareNode`
+3. [Setup MongoDB](#Setting-up-MongoDB)
+4. [Setup Redis](#Setting-up-Redis)
+5. `cp backend/config/template.json backend/config/default.json` and configure as per [Configuration](./Configuration.md#Backend)
+6. `cp frontend/dist/config/template.json frontend/dist/config/default.json` and configure as per [Configuration](./Configuration.md#Frontend)
+7. Start services
+    - **Linux**
+        1. Execute `systemctl start redis mongod`
+        2. Execute `cd frontend && npm run dev` and `cd backend && npm run dev` separately.
+    - **Windows**
+        - **Automatic** 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. Execute `cd frontend && npm run dev` and `cd backend && npm run dev` separately.
+8. **(optional)** Register a new user on the website and grant the admin role by running the following in the mongodb shell.
+    ```bash
+    use musare
+    db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
+    db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
+    ```
+
+### Setting up MongoDB
+- **Windows Only**
+    1. In the root directory, create a folder called `.database`
+    2. Create a file called `startMongo.cmd` in the root directory with the contents:
+
+        `"C:\Program Files\MongoDB\Server\4.0\bin\mongod.exe" --dbpath "C:\Path\To\MusareNode\.database"`
+
+        Make sure to adjust your paths accordingly.
+    3. Start the database by executing the script `startMongo.cmd` you just made
+- Set up the MongoDB database itself
+    1. Start MongoDB
+        - **Linux** Execute `systemctl start mongod`
+        - **Windows** Execute the `startMongo.cmd` script you just made
+    2. Connect to Mongo from a command prompt
+
+        `mongo admin`
+    3. Create an admin user
+
+        `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+    4. Connect to the Musare database
+
+        `use musare`
+    5. Create the "musare" user
+
+        `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+    6. Exit
+
+        `exit`
+    7. Add the authentication
+        - **Linux**
+            1. Add `auth=true` to `/etc/mongod.conf`
+            2. Restart MongoDB `systemctl restart mongod`
+        - **Windows**
+            1. In `startMongo.cmd` add `--auth` at the end of the first line
+            2. Restart MongoDB
+
+### Setting up Redis
+- **Windows**
+    1. In the folder where you installed Redis, edit the `redis.windows.conf` file
+        1. In there, look for the property `notify-keyspace-events`.
+        2. Make sure that property is uncommented and has the value `Ex`.
+            
+            It should look like `notify-keyspace-events Ex` when done.
+    2. Create a file called `startRedis.cmd` in the main folder with the contents:
+
+        `"C:\Path\To\Redis\redis-server.exe" "C:\Path\To\Redis\redis.windows.conf" "--requirepass" "PASSWORD"`
+
+        And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+- **Linux**
+    1. In `/etc/redis/redis.conf`
+        1. Uncomment `notify-keyspace-events` and set its value to `Ex`.
+        2. Uncomment `requirepass foobared` and replace foobared with your Redis password.
+    2. Restart Redis `systemctl restart redis`

+ 20 - 0
.wiki/Technical_Overview.md

@@ -0,0 +1,20 @@
+# Technical Overview
+
+## Our Stack
+
+- NodeJS
+- MongoDB
+- Redis
+- Nginx (not required)
+- VueJS
+
+### Frontend
+
+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 served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
+
+### Backend
+
+The backend is a scalable NodeJS / Redis / MongoDB app. User sessions are stored 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, which can become the primary node if the current primary node goes down.
+
+We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+

+ 29 - 0
.wiki/Utility_Script.md

@@ -0,0 +1,29 @@
+# Utility Script
+The utility script is a tool that allows for the simple management of a Musare Docker instance.
+
+Please follow the [Docker Installation Guide](./Installation.md#Docker) before using this script.
+
+## Usage
+Linux (Bash):
+```bash
+./musare.sh command [parameters]
+```
+
+## Commands
+| Command | Parameters | Description |
+| --- | --- | --- |
+| `start` | `[frontend backend redis mongo]` | Start service(s). |
+| `stop` | `[frontend backend redis mongo]` | Stop service(s). |
+| `restart` | `[frontend backend redis mongo]` | Restart service(s). |
+| `logs` | `[frontend backend redis mongo]` | View logs for service(s). |
+| `update` | `[auto]` | Update Musare. When auto is specified the update will be cancelled if there are any changes requiring manual intervention, allowing you to run this unattended. |
+| `attach` | `<backend,mongo>` | Attach to backend server or mongodb shell. |
+| `build` | `[frontend backend]` | Build service(s). |
+| `eslint` | `[frontend backend] [fix]` | Run eslint on frontend and/or backend. Specify fix to auto fix issues where possible. |
+| `backup` | | Backup database data to file. Configured in .env file. |
+| `restore` | `[file]` | Restore database from file. If file is not specified you will be prompted. |
+| `reset` | `[frontend backend redis mongo]` | Reset all data for service(s). |
+| `admin` | `<add,remove> [username]` | Assign/unassign admin role to/from user. If the username is not specified you will be prompted. |
+
+### Services
+There are currently 4 services; frontend, backend, redis and mongo. Where services is a parameter you can specify any of these, or multiple seperated by spaces, for example `./musare.sh restart frontend backend` to restart the frontend and backend. If no services are specified all will be selected.

+ 59 - 0
.wiki/Value_Formats.md

@@ -0,0 +1,59 @@
+# Value Formats
+
+Every input needs validation, below is the required formatting of each value.
+
+- **User**
+    - Username
+        - Description: Any letter from a-z in any case, numbers, underscores and dashes. Must contain at least 1 letter or number.
+        - Length: From 2 to 32 characters.
+        - Regex: ```/^[A-Za-z0-9_]+$/```
+    - Name
+        - Description: Any letter from any language in any case, numbers, underscores, dashes, periods, apostrophes and spaces. Must contain at least 1 letter or number.
+        - Length: From 2 to 64 characters.
+        - Regex: ```/^[\p{L}0-9 .'_-]+$/u```
+    - Email
+        - Description: Standard email address.
+        - Length: From 3 to 254 characters.
+        - Regex: ```/^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/```
+    - Password
+        - Description: Must include at least one lowercase letter, one uppercase letter, one number and one special character.
+        - Length: From 6 to 200 characters.
+        - Regex: ```/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~])[A-Za-z\d!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/```
+    - Ban Reason
+        - Description: Any ASCII character.
+        - Length: From 1 to 64 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+- **Station**
+    - Name
+        - Description: Any letter from a-z lowercase, numbers and underscores.
+        - Length: From 2 to 16 characters.
+        - Regex: ```/^[a-z0-9_]+$/```
+    - Display Name
+        - Description: Any ASCII character.
+        - Length: From 2 to 32 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+    - Description
+        - Description: Any character.
+        - Length: From 2 to 200 characters.
+- **Playlist**
+    - Display Name
+        - Description: Any ASCII character.
+        - Length: From 1 to 32 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+- **Song**
+    - Title
+        - Description: Any ASCII character.
+        - Length: From 1 to 32 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+    - Artists
+        - Description: Any character and not NONE.
+        - Length: From 1 to 64 characters.
+        - Quantity: Min 1, max 10.
+    - Genres
+        - Description: Any ASCII character.
+        - Length: From 1 to 32 characters.
+        - Quantity: Min 1, max 16.
+        - Regex: ```/^[\x00-\x7F]+$/```
+    - Thumbnail
+        - Description: Valid url. If site is secure only https prepended urls are valid.
+        - Length: From 1 to 256 characters.

+ 69 - 291
README.md

@@ -1,294 +1,72 @@
-# Musare is no longer being maintained
-
-# MusareNode
-
-Based off of the original [Musare](https://github.com/Musare/MusareMeteor), which utilized Meteor.
-
-MusareNode now uses NodeJS, Express, SocketIO and VueJS - among other technologies. We have also implemented the ability to host Musare in [Docker Containers](https://www.docker.com/).
-
-The master branch is available at [musare.com](https://musare.com)
-You can also find the staging branch at [musare.dev](https://musare.dev)
-
-<br />
-
-## Our Stack
-
-- NodeJS
-- MongoDB
-- Redis
-- Nginx (not required)
-- VueJS
-
-### **Frontend**
-
-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 served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
-
-### **Backend**
-
-The backend is a scalable NodeJS / Redis / MongoDB app. User sessions are stored 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, which can become the primary node if the current primary node goes down.
-
-We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
-
-<br />
-
-## Getting Started & Configuration
-
-
-1. `git clone https://github.com/Musare/MusareNode.git`
-
-2. `cd MusareNode`
-
-3. `cp backend/config/template.json backend/config/default.json`
-
-    | Property | Description |
-    | - | - |
-    | `mode` | Should be either `development` or `production`. No more explanation needed. |
-    | `secret` | Whatever you want - used by express's session module. |
-    | `domain` | Should be the url where the site will be accessible from,usually `http://localhost` for non-Docker. |
-    | `serverDomain` | Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker. |
-    | `serverPort` | Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker. |
-    | `isDocker` | Self-explanatory. Are you using Docker? |
-    | `serverPort` | Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker. |
-    | `apis.youtube.key`            | Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key. |
-    | `apis.recaptcha.secret`       | Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin). |
-    | `apis.github` | Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. 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`. |
-    | `apis.discord.token` | Token for the Discord bot. |
-    | `apis.discord.loggingServer`  | Server ID of the Discord logging server. |
-    | `apis.discord.loggingChannel` | ID of the channel to be used in the Discord logging server. |
-    | `apis.mailgun` | Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it. |
-    | `apis.spotify` | Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it. |
-    | `apis.discogs` | Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it. |
-    | `redis.url` | Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker. |
-    | `redis.password` | Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker. |
-    | `mongo.url` | Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`. |
-    | `cookie.domain` | Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
-    | `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
-
-4. `cp frontend/build/config/template.json frontend/build/config/default.json`
-
-    | Property | Description |
-    | - | - |
-    | `serverDomain` | Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker. |
-    | `frontendDomain` | Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker. |
-    | `frontendPort` | Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker. |
-    | `recaptcha.key` | Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin). |
-    | `cookie.domain` | Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
-    | `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
-    | `siteSettings.logo` | Path to the logo image, by default it is `/assets/wordmark.png`. |
-    | `siteSettings.siteName` | Should be the name of the site. |
-    | `siteSettings.socialLinks` | `github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed. |
-
-5. Simply `cp .env.example .env` to setup your environment variables.
-
-6. To setup [snyk](https://snyk.io/) (which is what we use for our precommit git-hooks), you will need to:
-
-    - Setup an account
-    - Go to [settings](https://app.snyk.io/account)
-    - Copy the API token and set it as your `SNYK_TOKEN` environment variable.
-    
-    We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
-
-<br />
-
-## Installation
-
-After initial configuration, there are two different options to use for your local development environment.
-
-1) [**Docker**](#docker)
-2) [Standard Setup](#standard-setup)
-
-We **highly recommend using Docker** - both for stability and speed of setup. We also use Docker on our production servers.
-
-<br />
-
-### **Docker**
-
-___
-
-1. Configure the `.env` file to match your settings in `backend/config/default.json`.  
-
-    | Property | Description |
-    | - | - |
-    | Ports | Will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. |
-    | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
-    | `FRONTEND_MODE` | Should be either `dev` or `prod` (self-explanatory). |
-    | `MONGO_ROOT_PASSWORD` | Password of the root/admin user of MongoDB |
-    | `MONGO_USER_USERNAME` | Password for the "musare" user (what the backend uses) of MongoDB |
-
-
-2. Install [Docker for Desktop](https://www.docker.com/products/docker-desktop)
-
-3. Build the backend and frontend Docker images (from the root folder)
-
-    `docker-compose build`
-
-4. Start the MongoDB database (in detached mode), which will generate the correct MongoDB users based on the `.env` file.
-
-    `docker-compose up -d mongo`
-
-5. If you want to use linting extensions in IDEs, then you must attach the IDE to the docker containers. This is entirely [possible with VS Code](https://code.visualstudio.com/docs/remote/containers).
-
-<br />
-
-### **Standard Setup**
-
-___
-
-#### Installation
-
-1. Install [Redis](http://redis.io/download) and [MongoDB](https://www.mongodb.com/download-center#community)
-
-2. Install [NodeJS](https://nodejs.org/en/download/)
-
-    1. Install nodemon globally
-
-        `npm install -g nodemon`
-
-    2. Install node-gyp globally (first check out <https://github.com/nodejs/node-gyp#installation)>
-
-        `npm install -g node-gyp`.
-
-3. Install webpack globally
-
-    `npm install -g webpack`
-
-
-#### Setting up MongoDB
-
-1. In the root directory, create a folder called `.database`
-
-2. Create a file called `startMongo.cmd` in the root directory 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. Set up the MongoDB database itself
-
-    1. Start the database by executing the script `startMongo.cmd` you just made
-
-    2. Connect to Mongo from a command prompt
-
-        `mongo admin`
-
-    3. Create an admin user
-
-        `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
-
-    4. Connect to the Musare database
-
-        `use musare`
-
-    5. Create the "musare" user
-
-        `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
-
-    6. Exit
-
-        `exit`
-
-    7. Add the authentication
-
-        In `startMongo.cmd` add `--auth` at the end of the first line
-
-#### Setting up Redis
-
-1. In the folder where you installed Redis, edit the `redis.windows.conf` file
-     
-    1) In there, look for the property `notify-keyspace-events`.
-    2) Make sure that property is uncommented and has the value `Ex`.
-        
-        It should look like `notify-keyspace-events Ex` when done.
-
-2. Create a file called `startRedis.cmd` in the main folder with the contents:
-
-    `"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"`
-
-    And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
-
-<br />
-
-## Everyday usage
-
-<br />
-
-### **Docker**
-
-___
-
-1. Start the MongoDB database in the background.
-
-    `docker-compose up -d mongo`
-
-2. Start redis and the mongo client in the background, as we usually don't need to monitor these for errors.
-
-    `docker-compose up -d mongoclient redis`
-
-3. Start the backend and frontend in the foreground, so we can watch for errors during development.
-
-    `docker-compose up backend frontend`
-
-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 when you make changes.
-    
-    You should be able to access Musare in your local browser at `http://localhost:8080/`.
-
-<br />
-
-### **Standard Setup**
-
-___
-
-##### 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. Execute `cd frontend && npm dev` and `cd backend && npm dev` separately.
-
-<br />
-
-## Extra
-
-Below is a list of helpful tips / solutions we've collected while developing MusareNode.
-
-### Fixing the "couldn't connect to docker daemon" error
-
-Some people have had issues while trying to execute the `docker-compose` command.
-To fix this, you will have to run `docker-machine env default`.
-This command will print various variables.
-At the bottom, it will say something similar to `@FOR /f "tokens=*" %i IN ('docker-machine env default') DO @%i`.
-Run this command in your shell. You will have to do this command for every shell you want to run `docker-compose` in (every session).
-
-### Calling Toasts
-
-You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
-
-```js
-import Toast from "vue-roaster";
-new Toast({ content: "Hi!", persistant: true });
-```
-
-### Set user role
-
-When setting up you will need to grant yourself the admin role, using the following commands:
-
-```bash
-docker-compose exec mongo mongo admin
-
-use musare
-db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
-db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
-```
-
-<br />
+![Musare](frontend/dist/assets/blue_wordmark.png)
+
+# Musare
+
+Musare is an open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.
+
+---
+
+## Documentation
+- [Installation](./.wiki/Installation.md)
+- [Configuration](./.wiki/Configuration.md)
+- [Utility Script](./.wiki/Utility_Script.md)
+- [Backend Commands](./.wiki/Backend_Commands.md)
+- [Technical Overview](./.wiki/Technical_Overview.md)
+- [Value Formats](./.wiki/Value_Formats.md)
+
+---
+
+## Features
+- **Playlists**
+    - User created playlists
+    - Automatically generated playlists for genres
+    - Privacy configuration
+    - Liked and Disliked songs playlists per user
+    - Bulk import songs from YouTube playlist
+    - Add songs from verified catalogue or YouTube
+    - Ability to download in JSON format
+- **Stations**
+    - Playlist mode to listen to selected playlists
+    - Party mode to allow other users to add songs to queue
+    - Ability to blacklist playlists to prevent songs within from playing
+    - Themes
+    - Privacy configuration
+    - Favoriting
+    - Official stations controlled by admins (playlist mode only)
+    - User created and controlled stations
+    - Pause playback just in local session
+    - Station-wide pausing by admins or owners
+    - Vote to skip songs
+    - Force skipping song by admins or owners
+    - Add songs to queue from verified catalogue or YouTube (party mode only)
+- **Song Management**
+    - Verify songs to allow them to be searched for and played in official stations
+    - Hide songs to remove from unverified catalogue
+    - Import Album (WIP) to import songs in bulk
+    - Discogs integration to import metadata
+    - Ability for users to report issues with songs and admins to resolve
+    - Configurable skip duration and song duration to cut intros and outros
+    - Request songs from YouTube in official stations or admin area
+    - Any song added to playlists or stations will be automatically requested
+- **Users**
+    - Activity logs
+    - Profile page showing public playlists and activity logs
+    - Text or gravatar profile pictures
+    - Email or Github login/registration
+    - Preferences to tailor site usage
+    - Password reset
+    - Data deletion management
+    - ActivityWatch integration
+- **Punishments**
+    - Ban users
+    - Ban IPs
+- **News**
+    - Admins can add/edit/remove news items
+    - Markdown editor
+- **Dark Mode**
+
+---
 
 
 ## Contact
 ## Contact
 
 
-Get in touch with us via email at [core@musare.com](mailto:core@musare.com) or join our [Discord Guild](https://discord.gg/Y5NxYGP).
-
-You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Twitter](https://twitter.com/MusareApp).
+Get in touch with us via email at [core@musare.com](mailto:core@musare.com).

+ 1 - 0
backend/.eslintignore

@@ -0,0 +1 @@
+node_modules

+ 45 - 0
backend/.eslintrc

@@ -0,0 +1,45 @@
+{
+	"env": {
+		"browser": false,
+        "es2021": true,
+        "node": true
+	},
+	"parserOptions": {
+		"ecmaVersion": 2021,
+		"sourceType": "module"
+	},
+	"extends": [
+		"eslint:recommended",
+		"airbnb-base",
+		"prettier",
+		"plugin:jsdoc/recommended"
+    ],
+    "plugins": [ "prettier", "jsdoc" ],
+	"rules": {
+		"no-console": 0,
+		"no-control-regex": 0,
+		"no-var": 2,
+		"no-underscore-dangle": 0,
+		"radix": 0,
+		"no-multi-assign": 0,
+		"no-shadow": 0,
+		"no-new": 0,
+        "import/no-unresolved": 0,
+		"prettier/prettier": ["error"], // end of copied frontend rules
+		"max-classes-per-file": 0,
+		"max-len": ["error", { "code": 140, "ignoreComments": true, "ignoreUrls": true, "ignoreTemplateLiterals": true }],
+		"no-param-reassign": 0,
+		"implicit-arrow-linebreak": 0,
+		"import/extensions": 0,
+		"class-methods-use-this": 0,
+		"require-jsdoc": [2, {
+			"require": {
+				"FunctionDeclaration": true,
+				"MethodDefinition": true,
+				"ClassDeclaration": false,
+				"ArrowFunctionExpression": false,
+				"FunctionExpression": false
+			}
+		}]
+    }
+}

+ 1 - 0
backend/.prettierignore

@@ -0,0 +1 @@
+node_modules/

+ 9 - 0
backend/.prettierrc

@@ -0,0 +1,9 @@
+{
+    "singleQuote": false,
+    "tabWidth": 4,
+    "useTabs": true,
+    "trailingComma": "none",
+    "arrowParens": "avoid",
+    "endOfLine":"auto",
+    "printWidth": 120
+}

+ 0 - 13
backend/.snyk

@@ -1,13 +0,0 @@
-# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
-version: v1.14.1
-ignore: {}
-# patches apply the minimum changes required to fix a vulnerability
-patch:
-  SNYK-JS-HTTPSPROXYAGENT-469131:
-    - mailgun-js > proxy-agent > https-proxy-agent:
-        patched: '2019-10-03T21:28:13.725Z'
-    - mailgun-js > proxy-agent > pac-proxy-agent > https-proxy-agent:
-        patched: '2019-10-03T21:28:13.725Z'
-  SNYK-JS-LODASH-567746:
-    - mailgun-js > async > lodash:
-        patched: '2020-04-30T21:29:23.212Z'

+ 2 - 6
backend/Dockerfile

@@ -1,16 +1,12 @@
-FROM node
-
-RUN apt-get update
+FROM node:15
 
 
 RUN npm install -g nodemon
 RUN npm install -g nodemon
-RUN npm install -g snyk
 
 
 RUN mkdir -p /opt
 RUN mkdir -p /opt
 WORKDIR /opt
 WORKDIR /opt
 ADD package.json /opt/package.json
 ADD package.json /opt/package.json
+ADD package-lock.json /opt/package-lock.json
 
 
 RUN npm install
 RUN npm install
 
 
-EXPOSE 80
-
 CMD npm run docker:dev
 CMD npm run docker:dev

+ 63 - 42
backend/classes/Timer.class.js

@@ -1,48 +1,69 @@
-module.exports = class Timer {
-    constructor(callback, delay, paused) {
-        this.callback = callback;
-        this.timerId = undefined;
-        this.start = undefined;
-        this.paused = paused;
-        this.remaining = delay;
-        this.timeWhenPaused = 0;
-        this.timePaused = Date.now();
+export default class Timer {
+	// eslint-disable-next-line require-jsdoc
+	constructor(callback, delay, paused) {
+		this.callback = callback;
+		this.timerId = undefined;
+		this.start = undefined;
+		this.paused = paused;
+		this.remaining = delay;
+		this.timeWhenPaused = 0;
+		this.timePaused = Date.now();
 
 
-        if (!paused) {
-            this.resume();
-        }
-    }
+		if (!paused) {
+			this.resume();
+		}
+	}
 
 
-    pause() {
-        clearTimeout(this.timerId);
-        this.remaining -= Date.now() - this.start;
-        this.timePaused = Date.now();
-        this.paused = true;
-    }
+	/**
+	 * Pauses the timer
+	 *
+	 */
+	pause() {
+		clearTimeout(this.timerId);
+		this.remaining -= Date.now() - this.start;
+		this.timePaused = Date.now();
+		this.paused = true;
+	}
 
 
-    ifNotPaused() {
-        if (!this.paused) {
-            this.resume();
-        }
-    }
+	/**
+	 * Ensures the timer's resume function is called if it is paused
+	 *
+	 */
+	ifNotPaused() {
+		if (!this.paused) {
+			this.resume();
+		}
+	}
 
 
-    resume() {
-        this.start = Date.now();
-        clearTimeout(this.timerId);
-        this.timerId = setTimeout(this.callback, this.remaining);
-        this.timeWhenPaused = Date.now() - this.timePaused;
-        this.paused = false;
-    }
+	/**
+	 * Resumes the timer
+	 *
+	 */
+	resume() {
+		this.start = Date.now();
+		clearTimeout(this.timerId);
+		this.timerId = setTimeout(this.callback, this.remaining);
+		this.timeWhenPaused = Date.now() - this.timePaused;
+		this.paused = false;
+	}
 
 
-    resetTimeWhenPaused() {
-        this.timeWhenPaused = 0;
-    }
+	/**
+	 * Resets the time when paused
+	 *
+	 */
+	resetTimeWhenPaused() {
+		this.timeWhenPaused = 0;
+	}
 
 
-    getTimePaused() {
-        if (!this.paused) {
-            return this.timeWhenPaused;
-        } else {
-            return Date.now() - this.timePaused;
-        }
-    }
-};
+	/**
+	 * Gets the amount of time the timer has been paused
+	 *
+	 * @returns {Date} - the amount of time the timer has been paused
+	 */
+	getTimePaused() {
+		if (!this.paused) {
+			return this.timeWhenPaused;
+		}
+		return Date.now() - this.timePaused;
+	}
+}

+ 67 - 34
backend/config/template.json

@@ -1,39 +1,31 @@
 {
 {
 	"mode": "development",
 	"mode": "development",
+	"migration": false,
 	"secret": "default",
 	"secret": "default",
 	"domain": "http://localhost",
 	"domain": "http://localhost",
 	"frontendPort": 80,
 	"frontendPort": 80,
-	"serverDomain": "http://localhost:8080",
-  	"serverPort": 8080,
-	"isDocker": true,
-	"fancyConsole": true,
+	"serverDomain": "http://localhost/backend",
+	"serverPort": 8080,
+	"registrationDisabled": true,
+	"hideAutomaticallyRequestedSongs": false,
+    "hideAnonymousSongs": false,
+	"sendDataRequestEmails": true,
 	"apis": {
 	"apis": {
 		"youtube": {
 		"youtube": {
-			"key": ""
+			"key": "",
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2
 		},
 		},
 		"recaptcha": {
 		"recaptcha": {
-			"secret": ""
+			"secret": "",
+			"enabled": false
 		},
 		},
 		"github": {
 		"github": {
 			"client": "",
 			"client": "",
 			"secret": "",
 			"secret": "",
 			"redirect_uri": ""
 			"redirect_uri": ""
 		},
 		},
-		"discord": {
-			"token": "",
-			"loggingChannel": "",
-			"loggingServer": ""
-		},
-		"mailgun": {
-			"key": "",
-			"domain": "",
-		  	"enabled": false
-		},
-		"spotify": {
-			"client": "",
-			"secret": "",
-			"enabled": false
-		},
 		"discogs": {
 		"discogs": {
 			"client": "",
 			"client": "",
 			"secret": "",
 			"secret": "",
@@ -42,21 +34,62 @@
 	},
 	},
 	"cors": {
 	"cors": {
 		"origin": [
 		"origin": [
-			"http://localhost",
-			"http://192.168.99.100",
-			"http://dev.musare.com"
+			"http://localhost"
 		]
 		]
 	},
 	},
-  	"redis": {
-	  	"url": "redis://redis:6379/0",
-	    "password": "PASSWORD"
+	"smtp": {
+		"host": "smtp.mailgun.org",
+		"port": 587,
+		"auth": {
+			"user": "",
+			"pass": ""
+		},
+		"secure": false,
+		"enabled": false
 	},
 	},
-  	"mongo": {
-	  	"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
+	"redis": {
+		"url": "redis://redis:6379/0",
+		"password": "PASSWORD"
 	},
 	},
-  	"cookie": {
-	  	"domain": "localhost",
+	"mongo": {
+		"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
+	},
+	"cookie": {
+		"domain": "localhost",
 		"secure": false,
 		"secure": false,
-		"SIDname": "SID"  
-	}
-}
+		"SIDname": "SID"
+	},
+	"skipConfigVersionCheck": false,
+	"skipDbDocumentsVersionCheck": false,
+	"debug": {
+		"stationIssue": false,
+		"traceUnhandledPromises": false,
+		"captureJobs": []
+	},
+	"defaultLogging": {
+		"hideType": [
+			"INFO"
+		],
+		"blacklistedTerms": []
+	},
+	"customLoggingPerModule": {
+		// "cache": {
+		//     "hideType": [
+		//     ],
+		//     "blacklistedTerms": []
+		// },
+		"migration": {
+			"hideType": [],
+			"blacklistedTerms": [
+				"Ran job",
+				"Running job",
+				"Queuing job",
+				"Pausing job",
+				"is queued",
+				"is re-queued",
+				"Requeing"
+			]
+		}
+	},
+	"configVersion": 6
+}

+ 637 - 178
backend/core.js

@@ -1,186 +1,645 @@
-const async = require("async");
+import config from "config";
 
 
 class DeferredPromise {
 class DeferredPromise {
-    constructor() {
-        this.promise = new Promise((resolve, reject) => {
-            this.reject = reject;
-            this.resolve = resolve;
-        });
-    }
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.promise = new Promise((resolve, reject) => {
+			this.reject = reject;
+			this.resolve = resolve;
+		});
+	}
 }
 }
 
 
-class MovingAverageCalculator {
-    constructor() {
-        this.count = 0;
-        this._mean = 0;
-    }
-
-    update(newValue) {
-        this.count++;
-        const differential = (newValue - this._mean) / this.count;
-        this._mean += differential;
-    }
-
-    get mean() {
-        this.validate();
-        return this._mean;
-    }
-
-    validate() {
-        if (this.count === 0) throw new Error("Mean is undefined");
-    }
+class QueueTask {
+	// eslint-disable-next-line require-jsdoc
+	constructor(job, options, priority) {
+		this.job = job;
+		this.options = options;
+		this.priority = priority;
+		this.job.setTask(this);
+	}
 }
 }
 
 
-class CoreClass {
-    constructor(name) {
-        this.name = name;
-        this.status = "UNINITIALIZED";
-        // this.log("Core constructor");
-        this.jobQueue = async.priorityQueue(
-            (job, callback) => this._runJob(job, callback),
-            10 // How many jobs can run concurrently
-        );
-        this.jobQueue.pause();
-        this.runningJobs = [];
-        this.priorities = {};
-        this.stage = 0;
-        this.jobStatistics = {};
-
-        this.registerJobs();
-    }
-
-    setStatus(status) {
-        this.status = status;
-        this.log("INFO", `Status changed to: ${status}`);
-        if (this.status === "READY") this.jobQueue.resume();
-        else if (this.status === "FAIL" || this.status === "LOCKDOWN")
-            this.jobQueue.pause();
-    }
-
-    getStatus() {
-        return this.status;
-    }
-
-    setStage(stage) {
-        this.stage = stage;
-    }
-
-    getStage() {
-        return this.stage;
-    }
-
-    _initialize() {
-        this.setStatus("INITIALIZING");
-        this.initialize()
-            .then(() => {
-                this.setStatus("READY");
-                this.moduleManager.onInitialize(this);
-            })
-            .catch((err) => {
-                console.error(err);
-                this.setStatus("FAILED");
-                this.moduleManager.onFail(this);
-            });
-    }
-
-    log() {
-        let _arguments = Array.from(arguments);
-        const type = _arguments[0];
-        _arguments.splice(0, 1);
-        const start = `|${this.name.toUpperCase()}|`;
-        const numberOfTabsNeeded = 4 - Math.ceil(start.length / 8);
-        _arguments.unshift(`${start}${Array(numberOfTabsNeeded).join("\t")}`);
-
-        if (type === "INFO") {
-            _arguments[0] = _arguments[0] + "\x1b[36m";
-            _arguments.push("\x1b[0m");
-            console.log.apply(null, _arguments);
-        } else if (type === "ERROR") {
-            _arguments[0] = _arguments[0] + "\x1b[31m";
-            _arguments.push("\x1b[0m");
-            console.error.apply(null, _arguments);
-        }
-    }
-
-    registerJobs() {
-        let props = [];
-        let obj = this;
-        do {
-            props = props.concat(Object.getOwnPropertyNames(obj));
-        } while ((obj = Object.getPrototypeOf(obj)));
-
-        const jobNames = props
-            .sort()
-            .filter(
-                (prop) =>
-                    typeof this[prop] == "function" &&
-                    prop === prop.toUpperCase()
-            );
-
-        jobNames.forEach((jobName) => {
-            this.jobStatistics[jobName] = {
-                successful: 0,
-                failed: 0,
-                total: 0,
-                averageTiming: new MovingAverageCalculator(),
-            };
-        });
-    }
-
-    runJob(name, payload, options = {}) {
-        let deferredPromise = new DeferredPromise();
-        const job = { name, payload, onFinish: deferredPromise };
-
-        if (options.bypassQueue) {
-            this._runJob(job, () => {});
-        } else {
-            const priority = this.priorities[name] ? this.priorities[name] : 10;
-            this.jobQueue.push(job, priority);
-        }
-
-        return deferredPromise.promise;
-    }
-
-    setModuleManager(moduleManager) {
-        this.moduleManager = moduleManager;
-    }
-
-    _runJob(job, cb) {
-        this.log("INFO", `Running job ${job.name}`);
-        const startTime = Date.now();
-        this.runningJobs.push(job);
-        const newThis = Object.assign(
-            Object.create(Object.getPrototypeOf(this)),
-            this
-        );
-        newThis.runJob = (...args) => {
-            if (args.length === 2) args.push({});
-            args[2].bypassQueue = true;
-            return this.runJob.apply(this, args);
-        };
-        this[job.name]
-            .apply(newThis, [job.payload])
-            .then((response) => {
-                this.log("INFO", `Ran job ${job.name} successfully`);
-                this.jobStatistics[job.name].successful++;
-                job.onFinish.resolve(response);
-            })
-            .catch((error) => {
-                this.log("INFO", `Running job ${job.name} failed`);
-                this.jobStatistics[job.name].failed++;
-                job.onFinish.reject(error);
-            })
-            .finally(() => {
-                const endTime = Date.now();
-                const executionTime = endTime - startTime;
-                this.jobStatistics[job.name].total++;
-                this.jobStatistics[job.name].averageTiming.update(
-                    executionTime
-                );
-                this.runningJobs.splice(this.runningJobs.indexOf(job), 1);
-                cb();
-            });
-    }
+class Queue {
+	// eslint-disable-next-line require-jsdoc
+	constructor(handleTaskFunction, concurrency) {
+		this.handleTaskFunction = handleTaskFunction;
+		this.concurrency = concurrency;
+		this.queue = [];
+		this.runningTasks = [];
+		this.pausedTasks = [];
+		this.paused = false;
+	}
+
+	/**
+	 * Pauses the queue, meaning no new jobs can be started. Jobs can still be added to the queue, and already running tasks won't be paused.
+	 */
+	pause() {
+		this.paused = true;
+	}
+
+	/**
+	 * Resumes the queue.
+	 */
+	resume() {
+		this.paused = false;
+		setTimeout(() => {
+			this._handleQueue();
+		}, 0);
+	}
+
+	/**
+	 * Returns the amount of jobs in the queue.
+	 *
+	 * @returns {number} - amount of jobs in queue
+	 */
+	lengthQueue() {
+		return this.queue.length;
+	}
+
+	/**
+	 * Returns the amount of running jobs.
+	 *
+	 * @returns {number} - amount of running jobs
+	 */
+	lengthRunning() {
+		return this.runningTasks.length;
+	}
+
+	/**
+	 * Returns the amount of running jobs.
+	 *
+	 * @returns {number} - amount of running jobs
+	 */
+	lengthPaused() {
+		return this.pausedTasks.length;
+	}
+
+	/**
+	 * Adds a job to the queue, with a given priority.
+	 *
+	 * @param {object} job - the job that is to be added
+	 * @param {object} options - custom options e.g. isQuiet. Optional.
+	 * @param {number} priority - the priority of the to be added job
+	 */
+	push(job, options, priority) {
+		this.queue.push(new QueueTask(job, options, priority));
+		setTimeout(() => {
+			this._handleQueue();
+		}, 0);
+	}
+
+	/**
+	 * Removes a job currently running from the queue.
+	 *
+	 * @param {object} job - the job to be removed
+	 */
+	removeRunningJob(job) {
+		this.runningTasks.remove(this.runningTasks.find(task => task.job.toString() === job.toString()));
+	}
+
+	/**
+	 * Pauses a job currently running from the queue.
+	 *
+	 * @param {object} job - the job to be pauses
+	 */
+	pauseRunningJob(job) {
+		const task = this.runningTasks.find(task => task.job.toString() === job.toString());
+		if (!task) {
+			console.log(
+				`Attempted to pause job ${job.name} (${job.toString()}), but couldn't find it in running tasks.`
+			);
+			return;
+		}
+		this.runningTasks.remove(task);
+		this.pausedTasks.push(task);
+	}
+
+	/**
+	 * Resumes a job currently paused, adding the job back to the front of the queue
+	 *
+	 * @param {object} job - the job to be pauses
+	 */
+	resumeRunningJob(job) {
+		const task = this.pausedTasks.find(task => task.job.toString() === job.toString());
+		if (!task) {
+			console.log(
+				`Attempted to resume job ${job.name} (${job.toString()}), but couldn't find it in paused tasks.`
+			);
+			return;
+		}
+		this.pausedTasks.remove(task);
+		this.queue.unshift(task);
+		setTimeout(() => {
+			this._handleQueue();
+		}, 0);
+	}
+
+	/**
+	 * Check if there's room for a job to be processed, and if there is, run it.
+	 */
+	_handleQueue() {
+		if (this.queue.length > 0) {
+			const task = this.queue.reduce((a, b) => (a.priority < b.priority ? a : b));
+			if (task) {
+				if ((!this.paused && this.runningTasks.length < this.concurrency) || task.priority === -1) {
+					this.queue.remove(task);
+					this.runningTasks.push(task);
+					this._handleTask(task);
+					setTimeout(() => {
+						this._handleQueue();
+					}, 0);
+				}
+			}
+		}
+	}
+
+	/**
+	 * Handles a task, calling the handleTaskFunction provided in the constructor
+	 *
+	 * @param {object} task - the task to be handled
+	 */
+	_handleTask(task) {
+		this.handleTaskFunction(task.job, task.options).finally(() => {
+			this.runningTasks.remove(task);
+			this._handleQueue();
+		});
+	}
 }
 }
 
 
-module.exports = CoreClass;
+class Job {
+	// eslint-disable-next-line require-jsdoc
+	constructor(name, payload, onFinish, module, parentJob) {
+		this.name = name;
+		this.payload = payload;
+		this.response = null;
+		this.responseType = null;
+		this.onFinish = onFinish;
+		this.module = module;
+		this.parentJob = parentJob;
+		this.childJobs = [];
+		/* eslint-disable no-bitwise, eqeqeq */
+		this.uniqueId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
+			const r = (Math.random() * 16) | 0;
+			const v = c == "x" ? r : (r & 0x3) | 0x8;
+			return v.toString(16);
+		});
+		this.status = "INITIALIZED";
+		this.task = null;
+	}
+
+	/**
+	 * Adds a child job to this job
+	 *
+	 * @param {object} childJob - the child job
+	 */
+	addChildJob(childJob) {
+		this.childJobs.push(childJob);
+	}
+
+	/**
+	 * Sets the job status
+	 *
+	 * @param {string} status - the new status
+	 */
+	setStatus(status) {
+		this.status = status;
+	}
+
+	/**
+	 * Sets the task for a job
+	 *
+	 * @param {string} task - the job task
+	 */
+	setTask(task) {
+		this.task = task;
+	}
+
+	/**
+	 * Returns the UUID of the job, allowing you to compare jobs with toString
+	 *
+	 * @returns {string} - the job's UUID/uniqueId
+	 */
+	toString() {
+		return this.uniqueId;
+	}
+
+	/**
+	 * Sets the response that will be provided to the onFinish DeferredPromise resolve/reject function, as soon as the job is done if it has no parent, or when the parent job is resumed
+	 *
+	 * @param {object} response - the response
+	 */
+	setResponse(response) {
+		this.response = response;
+	}
+
+	/**
+	 * Sets the response type that is paired with the response. If it is RESOLVE/REJECT, then it will resolve/reject with the response. If it is RESOLVED/REJECTED, then it has already resolved/rejected with the response.
+	 *
+	 * @param {string} responseType - the response type, so RESOLVE/REJECT/RESOLVED/REJECTED
+	 */
+	setResponseType(responseType) {
+		this.responseType = responseType;
+	}
+
+	/**
+	 * Removes child jobs to prevent memory leak
+	 */
+	cleanup() {
+		this.childJobs = this.childJobs.map(() => null);
+	}
+
+	/**
+	 * Logs to the module of the job
+	 *
+	 * @param  {any} args - Anything to be added to the log e.g. log type, log message
+	 */
+	log(...args) {
+		args.splice(1, 0, this.name); // Adds the name of the job as the first argument (after INFO/SUCCESS/ERROR).
+		this.module.log(...args);
+	}
+}
+
+class MovingAverageCalculator {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.count = 0;
+		this._mean = 0;
+	}
+
+	/**
+	 * Updates the mean average
+	 *
+	 * @param {number} newValue - the new time it took to complete a job
+	 */
+	update(newValue) {
+		this.count += 1;
+		const differential = (newValue - this._mean) / this.count;
+		this._mean += differential;
+	}
+
+	/**
+	 * Returns the mean average
+	 *
+	 * @returns {number} - returns the mean average
+	 */
+	get mean() {
+		this.validate();
+		return this._mean;
+	}
+
+	/**
+	 * Checks that the mean is valid
+	 */
+	validate() {
+		if (this.count === 0) throw new Error("Mean is undefined");
+	}
+}
+
+export default class CoreClass {
+	/**
+	 *
+	 * @param {string} name - the name of the class
+	 * @param {object} options - optional options
+	 * @param {number} options.concurrency - how many jobs can run at the same time
+	 * @param {object} options.priorities - custom priorities for jobs
+	 */
+	constructor(name, options) {
+		this.name = name;
+		this.status = "UNINITIALIZED";
+		this.concurrency = options && options.concurrency ? options.concurrency : 10;
+		this.jobQueue = new Queue((job, options) => this._runJob(job, options), this.concurrency);
+		this.jobQueue.pause();
+		this.priorities = options && options.priorities ? options.priorities : {};
+		this.stage = 0;
+		this.jobStatistics = {};
+
+		this.logRules = config.get("customLoggingPerModule")[name]
+			? config.get("customLoggingPerModule")[name]
+			: config.get("defaultLogging");
+
+		this.registerJobs();
+	}
+
+	/**
+	 * Sets the status of a module
+	 *
+	 * @param {string} status - the new status of a module
+	 */
+	setStatus(status) {
+		this.status = status;
+		this.log("INFO", `Status changed to: ${status}`);
+		if (this.status === "READY") this.jobQueue.resume();
+		else if (this.status === "FAIL" || this.status === "LOCKDOWN") this.jobQueue.pause();
+	}
+
+	/**
+	 * Returns the status of a module
+	 *
+	 * @returns {string} - the status of a module
+	 */
+	getStatus() {
+		return this.status;
+	}
+
+	/**
+	 * Changes the current stage of a module
+	 *
+	 * @param {string} stage - the new stage of a module
+	 */
+	setStage(stage) {
+		this.stage = stage;
+	}
+
+	/**
+	 * Returns the current stage of a module
+	 *
+	 * @returns {string} - the current stage of a module
+	 */
+	getStage() {
+		return this.stage;
+	}
+
+	/**
+	 * Initialises a module and handles initialise successes and failures
+	 */
+	_initialize() {
+		this.setStatus("INITIALIZING");
+
+		this.initialize()
+			.then(() => {
+				this.setStatus("READY");
+				this.moduleManager.onInitialize(this);
+			})
+			.catch(err => {
+				console.error(err);
+				this.setStatus("FAILED");
+				this.moduleManager.onFail(this);
+			});
+	}
+
+	/**
+	 * Creates a new log message
+	 *
+	 * @param {...any} args - anything to be included in the log message, the first argument is the type of log
+	 */
+	log(...args) {
+		const _arguments = Array.from(args);
+		const type = _arguments[0];
+
+		if (config.debug && config.debug.stationIssue === true && type === "STATION_ISSUE") {
+			this.moduleManager.debugLogs.stationIssue.push(_arguments);
+			return;
+		}
+
+		if (this.logRules.hideType.indexOf(type) !== -1) return;
+
+		_arguments.splice(0, 1);
+		const start = `|${this.name.toUpperCase()}|`;
+		const numberOfSpacesNeeded = 20 - start.length;
+		_arguments.unshift(`${start}${Array(numberOfSpacesNeeded).join(" ")}`);
+
+		if (this.logRules.blacklistedTerms.some(blacklistedTerm => _arguments.join().indexOf(blacklistedTerm) !== -1))
+			return;
+
+		if (type === "INFO" || type === "SUCCESS") {
+			_arguments[0] += "\x1b[36m";
+			_arguments.push("\x1b[0m");
+			console.log.apply(null, _arguments);
+		} else if (type === "ERROR") {
+			_arguments[0] += "\x1b[31m";
+			_arguments.push("\x1b[0m");
+			console.error.apply(null, _arguments);
+		}
+	}
+
+	/**
+	 * Sets up each job with the statistics service (includes mean average for job completion)
+	 */
+	registerJobs() {
+		let props = [];
+		let obj = this;
+		do {
+			props = props.concat(Object.getOwnPropertyNames(obj));
+			// eslint-disable-next-line no-cond-assign
+		} while ((obj = Object.getPrototypeOf(obj)));
+
+		const jobNames = props.sort().filter(prop => typeof this[prop] === "function" && prop === prop.toUpperCase());
+
+		jobNames.forEach(jobName => {
+			this.jobStatistics[jobName] = {
+				successful: 0,
+				failed: 0,
+				total: 0,
+				averageTiming: new MovingAverageCalculator()
+			};
+		});
+	}
+
+	/**
+	 * Runs a job
+	 *
+	 * @param {string} name - the name of the job e.g. GET_PLAYLIST
+	 * @param {object} payload - any expected payload for the job itself
+	 * @param {object} parentJob - the parent job, if any
+	 * @param {number} priority - custom priority. Optional.
+	 * @param {object} options - custom options e.g. isQuiet. Optional.
+	 * @returns {Promise} - returns a promise
+	 */
+	runJob(name, payload, parentJob, priority, options) {
+		/** Allows for any combination of optional parameters (parentJob, priority, options) */
+
+		let _options;
+		let _priority;
+		let _parentJob;
+
+		if (parentJob) {
+			if (typeof parentJob === "object")
+				if (!parentJob.name) _options = parentJob;
+				else _parentJob = parentJob;
+			else if (typeof parentJob === "number") _priority = parentJob;
+		}
+
+		if (options) {
+			if (typeof options === "object")
+				if (options.name) _parentJob = options;
+				else _options = options;
+			if (typeof options === "number") _priority = options;
+		}
+
+		if (priority && typeof priority === "object") {
+			if (!priority.name) _options = priority;
+			else _parentJob = priority;
+		} else _priority = priority;
+
+		if (!_options) _options = { isQuiet: false };
+
+		const deferredPromise = new DeferredPromise();
+		const job = new Job(name, payload, deferredPromise, this, _parentJob);
+
+		this.log("INFO", `Queuing job ${name} (${job.toString()})`);
+
+		if (_parentJob) {
+			_parentJob.addChildJob(job);
+			if (_parentJob.status === "RUNNING") {
+				this.log(
+					"INFO",
+					`Pausing job ${_parentJob.name} (${_parentJob.toString()}) since a child job has to run first`
+				);
+				_parentJob.setStatus("WAITING_ON_CHILD_JOB");
+				_parentJob.module.jobQueue.pauseRunningJob(_parentJob);
+			} else {
+				this.log(
+					"INFO",
+					`Not pausing job ${_parentJob.name} (${_parentJob.toString()}) since it's already paused`
+				);
+			}
+		}
+
+		job.setStatus("QUEUED");
+
+		let calculatedPriority = null;
+		if (_priority) calculatedPriority = _priority;
+		else if (this.priorities[name]) calculatedPriority = this.priorities[name];
+		else if (_parentJob) calculatedPriority = _parentJob.task.priority;
+		else calculatedPriority = 10;
+
+		this.jobQueue.push(job, _options, calculatedPriority);
+
+		if (
+			config.debug &&
+			config.debug.stationIssue === true &&
+			config.debug.captureJobs &&
+			config.debug.captureJobs.indexOf(name) !== -1
+		) {
+			this.moduleManager.debugJobs.all.push({ job, _priority });
+		}
+
+		return deferredPromise.promise;
+	}
+
+	/**
+	 * UNKNOWN
+	 *
+	 * @param {object} moduleManager - UNKNOWN
+	 */
+	setModuleManager(moduleManager) {
+		this.moduleManager = moduleManager;
+	}
+
+	/**
+	 * Actually runs the job? UNKNOWN
+	 *
+	 * @param {object} job - object containing details of the job
+	 * @param {string} job.name - the name of the job e.g. GET_PLAYLIST
+	 * @param {string} job.payload - any expected payload for the job itself
+	 * @param {Promise} job.onFinish - deferred promise when the job is complete
+	 * @param {object} options - custom options e.g. isQuiet. Optional.
+	 * @returns {Promise} - returns a promise
+	 */
+	_runJob(job, options) {
+		if (!options.isQuiet) this.log("INFO", `Running job ${job.name} (${job.toString()})`);
+		return new Promise(resolve => {
+			const startTime = Date.now();
+
+			const previousStatus = job.status;
+			job.setStatus("RUNNING");
+			this.moduleManager.jobManager.addJob(job);
+
+			if (previousStatus === "QUEUED") {
+				if (!options.isQuiet) this.log("INFO", `Job ${job.name} (${job.toString()}) is queued, so calling it`);
+				this[job.name]
+					.apply(job, [job.payload])
+					.then(response => {
+						if (!options.isQuiet) this.log("INFO", `Ran job ${job.name} (${job.toString()}) successfully`);
+						job.setStatus("FINISHED");
+						job.setResponse(response);
+						this.jobStatistics[job.name].successful += 1;
+						job.setResponseType("RESOLVE");
+						if (
+							config.debug &&
+							config.debug.stationIssue === true &&
+							config.debug.captureJobs &&
+							config.debug.captureJobs.indexOf(job.name) !== -1
+						) {
+							this.moduleManager.debugJobs.completed.push({
+								status: "success",
+								job,
+								priority: job.task.priority,
+								response
+							});
+						}
+					})
+					.catch(error => {
+						this.log("INFO", `Running job ${job.name} (${job.toString()}) failed`);
+						job.setStatus("FINISHED");
+						job.setResponse(error);
+						job.setResponseType("REJECT");
+						this.jobStatistics[job.name].failed += 1;
+						if (
+							config.debug &&
+							config.debug.stationIssue === true &&
+							config.debug.captureJobs &&
+							config.debug.captureJobs.indexOf(job.name) !== -1
+						) {
+							this.moduleManager.debugJobs.completed.push({
+								status: "error",
+								job,
+								error
+							});
+						}
+					})
+					.finally(() => {
+						const endTime = Date.now();
+						const executionTime = endTime - startTime;
+						this.jobStatistics[job.name].total += 1;
+						this.jobStatistics[job.name].averageTiming.update(executionTime);
+						this.moduleManager.jobManager.removeJob(job);
+						job.cleanup();
+
+						if (!job.parentJob) {
+							if (job.responseType === "RESOLVE") {
+								job.onFinish.resolve(job.response);
+								job.responseType = "RESOLVED";
+							} else if (job.responseType === "REJECT") {
+								job.onFinish.reject(job.response);
+								job.responseType = "REJECTED";
+							}
+						} else if (
+							job.parentJob &&
+							job.parentJob.childJobs.find(childJob =>
+								childJob ? childJob.status !== "FINISHED" : true
+							) === undefined
+						) {
+							if (job.parentJob.status !== "WAITING_ON_CHILD_JOB") {
+								this.log(
+									"ERROR",
+									`Job ${
+										job.parentJob.name
+									} (${job.parentJob.toString()}) had a child job complete even though it is not waiting on a child job. This should never happen.`
+								);
+							} else {
+								job.parentJob.setStatus("REQUEUED");
+								job.parentJob.module.jobQueue.resumeRunningJob(job.parentJob);
+							}
+						}
+						resolve();
+					});
+			} else {
+				this.log(
+					"INFO",
+					`Job ${job.name} (${job.toString()}) is re-queued, so resolving/rejecting all child jobs.`
+				);
+				job.childJobs.forEach(childJob => {
+					if (childJob.responseType === "RESOLVE") {
+						childJob.onFinish.resolve(childJob.response);
+						childJob.responseType = "RESOLVED";
+					} else if (childJob.responseType === "REJECT") {
+						childJob.onFinish.reject(childJob.response);
+						childJob.responseType = "REJECTED";
+					}
+				});
+			}
+		});
+	}
+}

+ 340 - 352
backend/index.js

@@ -1,378 +1,366 @@
-"use strict";
+import "./loadEnvVariables.js";
 
 
-const util = require("util");
+import util from "util";
+import config from "config";
 
 
-process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
+const REQUIRED_CONFIG_VERSION = 6;
 
 
-const config = require("config");
+// eslint-disable-next-line
+Array.prototype.remove = function (item) {
+	this.splice(this.indexOf(item), 1);
+};
 
 
-process.on("uncaughtException", (err) => {
-    if (err.code === "ECONNREFUSED" || err.code === "UNCERTAIN_STATE") return;
-    console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
+process.on("uncaughtException", err => {
+	if (err.code === "ECONNREFUSED" || err.code === "UNCERTAIN_STATE") return;
+	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 });
 
 
-const blacklistedConsoleLogs = [
-    "Running job IO",
-    "Ran job IO successfully",
-    "Running job HGET",
-    "Ran job HGET successfully",
-    "Running job HGETALL",
-    "Ran job HGETALL successfully",
-    "Running job GET_ERROR",
-    "Ran job GET_ERROR successfully",
-    "Running job GET_SCHEMA",
-    "Ran job GET_SCHEMA successfully",
-    "Running job SUB",
-    "Ran job SUB successfully",
-    "Running job GET_MODEL",
-    "Ran job GET_MODEL successfully",
-    "Running job HSET",
-    "Ran job HSET successfully",
-    "Running job CAN_USER_VIEW_STATION",
-    "Ran job CAN_USER_VIEW_STATION successfully",
-];
+const blacklistedConsoleLogs = [];
 
 
 const oldConsole = {};
 const oldConsole = {};
 oldConsole.log = console.log;
 oldConsole.log = console.log;
 
 
 console.log = (...args) => {
 console.log = (...args) => {
-    const string = util.format.apply(null, args);
-    let blacklisted = false;
-    blacklistedConsoleLogs.forEach((blacklistedConsoleLog) => {
-        if (string.indexOf(blacklistedConsoleLog) !== -1) blacklisted = true;
-    });
-    if (!blacklisted) oldConsole.log.apply(null, args);
+	const string = util.format.apply(null, args);
+	let blacklisted = false;
+	blacklistedConsoleLogs.forEach(blacklistedConsoleLog => {
+		if (string.indexOf(blacklistedConsoleLog) !== -1) blacklisted = true;
+	});
+	if (!blacklisted) oldConsole.log.apply(null, args);
 };
 };
 
 
-const fancyConsole = config.get("fancyConsole");
+if (
+	(!config.has("configVersion") || config.get("configVersion") !== REQUIRED_CONFIG_VERSION) &&
+	!(config.has("skipConfigVersionCheck") && config.get("skipConfigVersionCheck"))
+) {
+	console.log(
+		"CONFIG VERSION IS WRONG. PLEASE UPDATE YOUR CONFIG WITH THE HELP OF THE TEMPLATE FILE AND THE README FILE."
+	);
+	process.exit();
+}
 
 
-// class ModuleManager {
-// 	constructor() {
-// 		this.modules = {};
-// 		this.modulesInitialized = 0;
-// 		this.totalModules = 0;
-// 		this.modulesLeft = [];
-// 		this.i = 0;
-// 		this.lockdown = false;
-// 		this.fancyConsole = fancyConsole;
-// 	}
+if (config.debug && config.debug.traceUnhandledPromises === true) {
+	console.log("Enabled trace-unhandled/register");
+	import("trace-unhandled/register");
+}
 
 
-// 	addModule(moduleName) {
-// 		console.log("add module", moduleName);
-// 		const moduleClass = new require(`./logic/${moduleName}`);
-// 		this.modules[moduleName] = new moduleClass(moduleName, this);
-// 		this.totalModules++;
-// 		this.modulesLeft.push(moduleName);
-// 	}
-
-// 	initialize() {
-// 		if (!this.modules["logger"]) return console.error("There is no logger module");
-// 		this.logger = this.modules["logger"];
-// 		if (this.fancyConsole) {
-// 			this.replaceConsoleWithLogger();
-// 			this.logger.reservedLines = Object.keys(this.modules).length + 5;
-// 		}
-
-// 		for (let moduleName in this.modules) {
-// 			let module = this.modules[moduleName];
-// 			if (this.lockdown) break;
-
-// 			module._onInitialize().then(() => {
-// 				this.moduleInitialized(moduleName);
-// 			});
-
-// 			let dependenciesInitializedPromises = [];
-
-// 			module.dependsOn.forEach(dependencyName => {
-// 				let dependency = this.modules[dependencyName];
-// 				dependenciesInitializedPromises.push(dependency._onInitialize());
-// 			});
-
-// 			module.lastTime = Date.now();
-
-// 			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
-// 				if (this.lockdown) return;
-// 				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
-// 				module._initialize();
-// 			});
-// 		}
-// 	}
-
-// 	async printStatus() {
-// 		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
-// 		if (!this.fancyConsole) return;
-
-// 		let colors = this.logger.colors;
-
-// 		const rows = process.stdout.rows;
-
-// 		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
-// 		process.stdout.clearScreenDown();
-
-// 		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
-
-// 		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
-
-// 		for (let moduleName in this.modules) {
-// 			let module = this.modules[moduleName];
-// 			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
-
-// 			let tabs = Array(tabsAmount).fill(`\t`).join("");
-
-// 			let timing = module.timeDifferences.map((timeDifference) => {
-// 				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
-// 			}).join(", ");
-
-// 			let stateColor;
-// 			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
-// 			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
-// 			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
-// 			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
-// 			else stateColor = colors.FgYellow;
-
-// 			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
-// 		}
-// 	}
-
-// 	moduleInitialized(moduleName) {
-// 		this.modulesInitialized++;
-// 		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
-
-// 		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
-
-// 		if (this.modulesLeft.length === 0) this.allModulesInitialized();
-// 	}
-
-// 	allModulesInitialized() {
-// 		this.logger.success("MODULE_MANAGER", "All modules have started!");
-// 		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
-// 	}
-
-// 	aModuleFailed(failedModule) {
-// 		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
-// 		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
-
-// 		this._lockdown();
-// 	}
-
-// 	replaceConsoleWithLogger() {
-// 		this.oldConsole = {
-// 			log: console.log,
-// 			debug: console.debug,
-// 			info: console.info,
-// 			warn: console.warn,
-// 			error: console.error
-// 		};
-// 		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-// 		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-// 		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-// 		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-// 		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
-// 	}
-
-// 	replaceLoggerWithConsole() {
-// 		console.log = this.oldConsole.log;
-// 		console.debug = this.oldConsole.debug;
-// 		console.info = this.oldConsole.info;
-// 		console.warn = this.oldConsole.warn;
-// 		console.error = this.oldConsole.error;
-// 	}
-
-// 	_lockdown() {
-// 		this.lockdown = true;
-
-// 		for (let moduleName in this.modules) {
-// 			let module = this.modules[moduleName];
-// 			if (module.lockdownImmune) continue;
-// 			module._lockdown();
-// 		}
-// 	}
-// }
-
-// const moduleManager = new ModuleManager();
-
-// module.exports = moduleManager;
-
-// moduleManager.addModule("cache");
-// moduleManager.addModule("db");
-// moduleManager.addModule("mail");
-// moduleManager.addModule("api");
-// moduleManager.addModule("app");
-// moduleManager.addModule("discord");
-// moduleManager.addModule("io");
-// moduleManager.addModule("logger");
-// moduleManager.addModule("notifications");
-// moduleManager.addModule("activities");
-// moduleManager.addModule("playlists");
-// moduleManager.addModule("punishments");
-// moduleManager.addModule("songs");
-// moduleManager.addModule("spotify");
-// moduleManager.addModule("stations");
-// moduleManager.addModule("tasks");
-// moduleManager.addModule("utils");
-
-// moduleManager.initialize();
-
-// process.stdin.on("data", function (data) {
-//     if(data.toString() === "lockdown\r\n"){
-//         console.log("Locking down.");
-//        	moduleManager._lockdown();
-//     }
-// });
-
-// if (fancyConsole) {
-// 	const rows = process.stdout.rows;
-
-// 	for(let i = 0; i < rows; i++) {
-// 		process.stdout.write("\n");
-// 	}
-// }
+class JobManager {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.runningJobs = {};
+	}
+
+	/**
+	 * Adds a job to the list of running jobs
+	 *
+	 * @param {object} job - the job object
+	 */
+	addJob(job) {
+		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
+		this.runningJobs[job.module.name][job.toString()] = job;
+	}
+
+	/**
+	 * Removes a job from the list of running jobs (after it's completed)
+	 *
+	 * @param {object} job - the job object
+	 */
+	removeJob(job) {
+		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
+		delete this.runningJobs[job.module.name][job.toString()];
+	}
+
+	/**
+	 * Returns detail about a job via a identifier
+	 *
+	 * @param {string} uuid - the job identifier
+	 * @returns {object} - the job object
+	 */
+	getJob(uuid) {
+		let job = null;
+		Object.keys(this.runningJobs).forEach(moduleName => {
+			if (this.runningJobs[moduleName][uuid]) job = this.runningJobs[moduleName][uuid];
+		});
+		return job;
+	}
+}
 
 
 class ModuleManager {
 class ModuleManager {
-    constructor() {
-        this.modules = {};
-        this.modulesNotInitialized = [];
-        this.i = 0;
-        this.lockdown = false;
-        this.fancyConsole = fancyConsole;
-    }
-
-    addModule(moduleName) {
-        console.log("add module", moduleName);
-        const module = require(`./logic/${moduleName}`);
-        this.modules[moduleName] = module;
-        this.modulesNotInitialized.push(module);
-    }
-
-    initialize() {
-        // if (!this.modules["logger"]) return console.error("There is no logger module");
-        // this.logger = this.modules["logger"];
-        // if (this.fancyConsole) {
-        // this.replaceConsoleWithLogger();
-        this.reservedLines = Object.keys(this.modules).length + 5;
-        // }
-
-        for (let moduleName in this.modules) {
-            let module = this.modules[moduleName];
-            module.setModuleManager(this);
-
-            if (this.lockdown) break;
-
-            module._initialize();
-
-            // let dependenciesInitializedPromises = [];
-
-            // module.dependsOn.forEach(dependencyName => {
-            // 	let dependency = this.modules[dependencyName];
-            // 	dependenciesInitializedPromises.push(dependency._onInitialize());
-            // });
-
-            // module.lastTime = Date.now();
-
-            // Promise.all(dependenciesInitializedPromises).then((res, res2) => {
-            // 	if (this.lockdown) return;
-            // 	this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
-            // 	module._initialize();
-            // });
-        }
-    }
-
-    onInitialize(module) {
-        if (this.modulesNotInitialized.indexOf(module) !== -1) {
-            this.modulesNotInitialized.splice(
-                this.modulesNotInitialized.indexOf(module),
-                1
-            );
-
-            console.log(
-                "MODULE_MANAGER",
-                `Initialized: ${Object.keys(this.modules).length -
-                    this.modulesNotInitialized.length}/${
-                    Object.keys(this.modules).length
-                }.`
-            );
-
-            if (this.modulesNotInitialized.length === 0)
-                this.onAllModulesInitialized();
-        }
-    }
-
-    onFail(module) {
-        if (this.modulesNotInitialized.indexOf(module) !== -1) {
-            console.log("A module failed to initialize!");
-        }
-    }
-
-    onAllModulesInitialized() {
-        console.log("All modules initialized!");
-        this.modules["discord"].runJob("SEND_ADMIN_ALERT_MESSAGE", {
-            message: "The backend server started successfully.",
-            color: "#00AA00",
-            type: "Startup",
-            critical: false,
-            extraFields: [],
-        });
-    }
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.modules = {};
+		this.modulesNotInitialized = [];
+		this.jobManager = new JobManager();
+		this.i = 0;
+		this.lockdown = false;
+		this.debugLogs = {
+			stationIssue: []
+		};
+		this.debugJobs = {
+			all: [],
+			completed: []
+		};
+		this.name = "MODULE_MANAGER";
+	}
+
+	/**
+	 * Adds a new module to the backend server/module manager
+	 *
+	 * @param {string} moduleName - the name of the module (also needs to be the same as the filename of a module located in the logic folder or "logic/moduleName/index.js")
+	 */
+	async addModule(moduleName) {
+		this.log("INFO", "Adding module", moduleName);
+
+		this.modules[moduleName] = import(`./logic/${moduleName}`);
+	}
+
+	/**
+	 * Initialises a new module to the backend server/module manager
+	 *
+	 */
+	async initialize() {
+		this.reservedLines = Object.keys(this.modules).length + 5;
+
+		await Promise.all(Object.values(this.modules)).then(modules => {
+			for (let module = 0; module < modules.length; module += 1) {
+				this.modules[modules[module].default.name] = modules[module].default;
+				this.modulesNotInitialized.push(modules[module].default);
+			}
+		}); // ensures all modules are imported, then converts promise to the default export of the import
+
+		Object.keys(this.modules).every(moduleKey => {
+			const module = this.modules[moduleKey];
+
+			module.setModuleManager(this);
+
+			if (this.lockdown) return false;
+
+			module._initialize();
+
+			return true;
+		});
+	}
+
+	/**
+	 * Called when a module is initialised
+	 *
+	 * @param {object} module - the module object/class
+	 */
+	onInitialize(module) {
+		if (this.modulesNotInitialized.indexOf(module) !== -1) {
+			this.modulesNotInitialized.splice(this.modulesNotInitialized.indexOf(module), 1);
+
+			this.log(
+				"INFO",
+				`Initialized: ${Object.keys(this.modules).length - this.modulesNotInitialized.length}/${
+					Object.keys(this.modules).length
+				}.`
+			);
+
+			if (this.modulesNotInitialized.length === 0) this.onAllModulesInitialized();
+		}
+	}
+
+	/**
+	 * Called when a module fails to initialise
+	 *
+	 * @param {object} module - the module object/class
+	 */
+	onFail(module) {
+		if (this.modulesNotInitialized.indexOf(module) !== -1) {
+			this.log("ERROR", "A module failed to initialize!");
+		}
+	}
+
+	/**
+	 * Called when every module has initialised
+	 *
+	 */
+	onAllModulesInitialized() {
+		this.log("INFO", "All modules initialized!");
+	}
+
+	/**
+	 * Creates a new log message
+	 *
+	 * @param {...any} args - anything to be included in the log message, the first argument is the type of log
+	 */
+	log(...args) {
+		const _arguments = Array.from(args);
+		const type = _arguments[0];
+
+		_arguments.splice(0, 1);
+		const start = `|${this.name.toUpperCase()}|`;
+		const numberOfSpacesNeeded = 20 - start.length;
+		_arguments.unshift(`${start}${Array(numberOfSpacesNeeded).join(" ")}`);
+
+		if (type === "INFO") {
+			_arguments[0] += "\x1b[36m";
+			_arguments.push("\x1b[0m");
+			console.log.apply(null, _arguments);
+		} else if (type === "ERROR") {
+			_arguments[0] += "\x1b[31m";
+			_arguments.push("\x1b[0m");
+			console.error.apply(null, _arguments);
+		}
+	}
+
+	/**
+	 * Locks down all modules
+	 */
+	_lockdown() {
+		this.lockdown = true;
+		Object.keys(this.modules).every(moduleKey => {
+			const module = this.modules[moduleKey];
+			module.setStatus("LOCKDOWN");
+			return true;
+		});
+	}
 }
 }
 
 
 const moduleManager = new ModuleManager();
 const moduleManager = new ModuleManager();
 
 
-moduleManager.addModule("cache");
-moduleManager.addModule("db");
-moduleManager.addModule("mail");
-moduleManager.addModule("activities");
-moduleManager.addModule("api");
-moduleManager.addModule("app");
-moduleManager.addModule("discord");
-moduleManager.addModule("io");
-moduleManager.addModule("notifications");
-moduleManager.addModule("playlists");
-moduleManager.addModule("punishments");
-moduleManager.addModule("songs");
-moduleManager.addModule("spotify");
-moduleManager.addModule("stations");
-moduleManager.addModule("tasks");
-moduleManager.addModule("utils");
+if (!config.get("migration")) {
+	moduleManager.addModule("cache");
+	moduleManager.addModule("db");
+	moduleManager.addModule("mail");
+	moduleManager.addModule("activities");
+	moduleManager.addModule("api");
+	moduleManager.addModule("app");
+	moduleManager.addModule("ws");
+	moduleManager.addModule("notifications");
+	moduleManager.addModule("playlists");
+	moduleManager.addModule("punishments");
+	moduleManager.addModule("songs");
+	moduleManager.addModule("stations");
+	moduleManager.addModule("tasks");
+	moduleManager.addModule("utils");
+	moduleManager.addModule("youtube");
+} else {
+	moduleManager.addModule("migration");
+}
 
 
 moduleManager.initialize();
 moduleManager.initialize();
 
 
-process.stdin.on("data", function(data) {
-    const command = data.toString().replace(/\r?\n|\r/g, "");
-    if (command === "lockdown") {
-        console.log("Locking down.");
-        moduleManager._lockdown();
-    }
-    if (command === "status") {
-        console.log("Status:");
-
-        for (let moduleName in moduleManager.modules) {
-            let module = moduleManager.modules[moduleName];
-            const tabsNeeded = 4 - Math.ceil((moduleName.length + 1) / 8);
-            console.log(
-                `${moduleName.toUpperCase()}${Array(tabsNeeded).join(
-                    "\t"
-                )}${module.getStatus()}. Jobs in queue: ${module.jobQueue.length()}. Jobs in progress: ${module.jobQueue.running()}. Concurrency: ${
-                    module.jobQueue.concurrency
-                }. Stage: ${module.getStage()}`
-            );
-        }
-        // moduleManager._lockdown();
-    }
-    if (command.startsWith("running")) {
-        const parts = command
-            .split(" ");
-
-        console.log(moduleManager.modules[parts[1]].runningJobs);
-    }
-    if (command.startsWith("stats")) {
-        const parts = command
-            .split(" ");
-
-        console.log(moduleManager.modules[parts[1]].jobStatistics);
-    }
-    if (command.startsWith("debug")) {
-        moduleManager.modules["utils"].runJob("DEBUG");
-    }
+/**
+ * Prints a job
+ *
+ * @param {object} job - the job
+ * @param {number} layer - the layer
+ */
+function printJob(job, layer) {
+	const tabs = Array(layer).join("\t");
+	if (job) {
+		console.log(`${tabs}${job.name} (${job.toString()}) ${job.status}`);
+		job.childJobs.forEach(childJob => {
+			printJob(childJob, layer + 1);
+		});
+	} else console.log(`${tabs}JOB WAS REMOVED`);
+}
+
+/**
+ * Prints a task
+ *
+ * @param {object} task - the task
+ * @param {number} layer - the layer
+ */
+function printTask(task, layer) {
+	const tabs = Array(layer).join("\t");
+	console.log(`${tabs}${task.job.name} (${task.job.toString()}) ${task.job.status} (priority: ${task.priority})`);
+	task.job.childJobs.forEach(childJob => {
+		printJob(childJob, layer + 1);
+	});
+}
+
+process.stdin.on("data", data => {
+	const command = data.toString().replace(/\r?\n|\r/g, "");
+	if (command === "lockdown") {
+		console.log("Locking down.");
+		moduleManager._lockdown();
+	}
+	if (command === "status") {
+		console.log("Status:");
+
+		Object.keys(moduleManager.modules).forEach(moduleName => {
+			const module = moduleManager.modules[moduleName];
+			const tabsNeeded = 4 - Math.ceil((moduleName.length + 1) / 8);
+			console.log(
+				`${moduleName.toUpperCase()}${Array(tabsNeeded).join(
+					"\t"
+				)}${module.getStatus()}. Jobs in queue: ${module.jobQueue.lengthQueue()}. Jobs in progress: ${module.jobQueue.lengthRunning()}. Jobs paused: ${module.jobQueue.lengthPaused()} Concurrency: ${
+					module.jobQueue.concurrency
+				}. Stage: ${module.getStage()}`
+			);
+		});
+	}
+	if (command.startsWith("running")) {
+		const parts = command.split(" ");
+
+		moduleManager.modules[parts[1]].jobQueue.runningTasks.forEach(task => {
+			printTask(task, 1);
+		});
+	}
+	if (command.startsWith("queued")) {
+		const parts = command.split(" ");
+
+		moduleManager.modules[parts[1]].jobQueue.queue.forEach(task => {
+			printTask(task, 1);
+		});
+	}
+	if (command.startsWith("paused")) {
+		const parts = command.split(" ");
+
+		moduleManager.modules[parts[1]].jobQueue.pausedTasks.forEach(task => {
+			printTask(task, 1);
+		});
+	}
+	if (command.startsWith("stats")) {
+		const parts = command.split(" ");
+
+		console.log(moduleManager.modules[parts[1]].jobStatistics);
+	}
+	if (command.startsWith("jobinfo")) {
+		const parts = command.split(" ");
+
+		const uuid = parts[1];
+		const jobFound = moduleManager.jobManager.getJob(uuid);
+
+		if (jobFound) {
+			let topParent = jobFound;
+			let levelsDeep = 0;
+			while (topParent.parentJob && topParent !== topParent.parentJob) {
+				topParent = jobFound.parentJob;
+				levelsDeep += 1;
+			}
+			console.log(
+				`Found job, displaying that job and the full tree from the top parent job. The job is ${levelsDeep} levels deep from the top parent.`
+			);
+			console.log(jobFound);
+			printJob(topParent, 1);
+		} else console.log("Could not find job in job manager.");
+	}
+	if (command.startsWith("runjob")) {
+		const parts = command.split(" ");
+		const module = parts[1];
+		const jobName = parts[2];
+		const payload = JSON.parse(parts[3]);
+
+		moduleManager.modules[module]
+			.runJob(jobName, payload)
+			.then(response => {
+				console.log("runjob success", response);
+			})
+			.catch(err => {
+				console.log("runjob error", err);
+			});
+	}
+	if (command.startsWith("eval")) {
+		const evalCommand = command.replace("eval ", "");
+		console.log(`Running eval command: ${evalCommand}`);
+		// eslint-disable-next-line no-eval
+		const response = eval(evalCommand);
+		console.log(`Eval response: `, response);
+	}
 });
 });
 
 
-module.exports = moduleManager;
+export default moduleManager;

+ 5 - 0
backend/loadEnvVariables.js

@@ -0,0 +1,5 @@
+import { dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+process.env.NODE_CONFIG_DIR = `${__dirname}/config`;

+ 213 - 98
backend/logic/actions/activities.js

@@ -1,99 +1,214 @@
-"use strict";
-
-const async = require("async");
-
-const hooks = require("./hooks");
-
-const db = require("../db");
-const utils = require("../utils");
-const activities = require("../activities");
-
-// const logger = moduleManager.modules["logger"];
-
-module.exports = {
-    /**
-     * Gets a set of activities
-     *
-     * @param session
-     * @param {String} userId - the user whose activities we are looking for
-     * @param {Integer} set - the set number to return
-     * @param cb
-     */
-    getSet: async (session, userId, set, cb) => {
-        const activityModel = await db.runJob("GET_MODEL", {
-            modelName: "activity",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    activityModel
-                        .find({ userId, hidden: false })
-                        .skip(15 * (set - 1))
-                        .limit(15)
-                        .sort("createdAt")
-                        .exec(next);
-                },
-            ],
-            async (err, activities) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "ACTIVITIES_GET_SET",
-                        `Failed to get set ${set} from activities. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-
-                console.log(
-                    "SUCCESS",
-                    "ACTIVITIES_GET_SET",
-                    `Set ${set} from activities obtained successfully.`
-                );
-                cb({ status: "success", data: activities });
-            }
-        );
-    },
-
-    /**
-     * Hides an activity for a user
-     *
-     * @param session
-     * @param {String} activityId - the activity which should be hidden
-     * @param cb
-     */
-    hideActivity: hooks.loginRequired(async (session, activityId, cb) => {
-        const activityModel = await db.runJob("GET_MODEL", {
-            modelName: "activity",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    activityModel.updateOne(
-                        { _id: activityId },
-                        { $set: { hidden: true } },
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "ACTIVITIES_HIDE_ACTIVITY",
-                        `Failed to hide activity ${activityId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-
-                console.log(
-                    "SUCCESS",
-                    "ACTIVITIES_HIDE_ACTIVITY",
-                    `Successfully hid activity ${activityId}.`
-                );
-                cb({ status: "success" });
-            }
-        );
-    }),
+import async from "async";
+
+import { isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const WSModule = moduleManager.modules.ws;
+const UtilsModule = moduleManager.modules.utils;
+
+CacheModule.runJob("SUB", {
+	channel: "activity.removeAllForUser",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("event:activity.removeAllForUser"))
+		);
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "activity.hide",
+	cb: res => {
+		const { activityId, userId } = res;
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("event:activity.hidden", { data: { activityId } }))
+		);
+	}
+});
+
+export default {
+	/**
+	 * Returns how many activities there are for a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the id of the user in question
+	 * @param {Function} cb - callback
+	 */
+	async length(session, userId, cb) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					activityModel.countDocuments({ userId, hidden: false }, next);
+				}
+			],
+			async (err, count) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_LENGTH",
+						`Failed to get length of activities for user ${userId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "ACTIVITIES_LENGTH", `Got length of activities for user ${userId} successfully.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully obtained length of activities.",
+					data: { length: count }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Gets a set of activities
+	 *
+	 * @param {object} session - user session
+	 * @param {string} userId - the user whose activities we are looking for
+	 * @param {number} set - the set number to return
+	 * @param {number} offset - how many activities to skip (keeps frontend and backend in sync)
+	 * @param {Function} cb - callback
+	 */
+	async getSet(session, userId, set, offset, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					// activities should only be viewed if public/owned by the user
+					if (session.userId !== userId) {
+						return userModel
+							.findById(userId)
+							.then(user => {
+								if (user) {
+									if (user.preferences.activityLogPublic) return next();
+									return next("User's activity log isn't public.");
+								}
+
+								return next("User does not exist.");
+							})
+							.catch(next);
+					}
+
+					return next();
+				},
+
+				next => {
+					activityModel
+						.find({ userId, hidden: false })
+						.skip(15 * (set - 1) + offset)
+						.limit(15)
+						.sort({ createdAt: -1 })
+						.exec(next);
+				}
+			],
+			async (err, activities) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ACTIVITIES_GET_SET", `Failed to get set ${set} from activities. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "ACTIVITIES_GET_SET", `Set ${set} from activities obtained successfully.`);
+				return cb({ status: "success", data: { activities } });
+			}
+		);
+	},
+
+	/**
+	 * Hides an activity for a user
+	 *
+	 * @param session
+	 * @param {string} activityId - the activity which should be hidden
+	 * @param cb
+	 */
+	hideActivity: isLoginRequired(async function hideActivity(session, activityId, cb) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					activityModel.updateOne({ _id: activityId }, { $set: { hidden: true } }, next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ACTIVITIES_HIDE_ACTIVITY", `Failed to hide activity ${activityId}. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "activity.hide",
+					value: {
+						userId: session.userId,
+						activityId
+					}
+				});
+
+				this.log("SUCCESS", "ACTIVITIES_HIDE_ACTIVITY", `Successfully hid activity ${activityId}.`);
+
+				return cb({ status: "success", message: "Successfully hid activity." });
+			}
+		);
+	}),
+
+	/**
+	 * Removes all activities logged for a logged-in user
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	removeAllForUser: isLoginRequired(async function removeAllForUser(session, cb) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					activityModel.deleteMany({ userId: session.userId }, next);
+				},
+
+				(res, next) => {
+					CacheModule.runJob("HDEL", { table: "recentActivities", key: session.userId }, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"ACTIVITIES_REMOVE_ALL_FOR_USER",
+						`Failed to delete activities for user ${session.userId}. "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "activity.removeAllForUser",
+					value: session.userId
+				});
+
+				this.log(
+					"SUCCESS",
+					"ACTIVITIES_REMOVE_ALL_FOR_USER",
+					`Successfully removed activities for user ${session.userId}.`
+				);
+
+				return cb({ status: "success", message: "Successfully removed your activity logs." });
+			}
+		);
+	})
 };
 };

+ 226 - 205
backend/logic/actions/apis.js

@@ -1,206 +1,227 @@
-"use strict";
-
-const request = require("request");
-const config = require("config");
-const async = require("async");
-
-const hooks = require("./hooks");
-// const moduleManager = require("../../index");
-
-const utils = require("../utils");
-// const logger = moduleManager.modules["logger"];
-
-module.exports = {
-    /**
-     * Fetches a list of songs from Youtubes API
-     *
-     * @param session
-     * @param query - the query we'll pass to youtubes api
-     * @param cb
-     * @return {{ status: String, data: Object }}
-     */
-    searchYoutube: (session, query, cb) => {
-        const params = [
-            "part=snippet",
-            `q=${encodeURIComponent(query)}`,
-            `key=${config.get("apis.youtube.key")}`,
-            "type=video",
-            "maxResults=15",
-        ].join("&");
-
-        async.waterfall(
-            [
-                (next) => {
-                    request(
-                        `https://www.googleapis.com/youtube/v3/search?${params}`,
-                        next
-                    );
-                },
-
-                (res, body, next) => {
-                    next(null, JSON.parse(body));
-                },
-            ],
-            async (err, data) => {
-                console.log(data.error);
-                if (err || data.error) {
-                    if (!err) err = data.error.message;
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "APIS_SEARCH_YOUTUBE",
-                        `Searching youtube failed with query "${query}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "APIS_SEARCH_YOUTUBE",
-                    `Searching YouTube successful with query "${query}".`
-                );
-                return cb({ status: "success", data });
-            }
-        );
-    },
-
-    /**
-     * Gets Spotify data
-     *
-     * @param session
-     * @param title - the title of the song
-     * @param artist - an artist for that song
-     * @param cb
-     */
-    getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    utils
-                        .runJob("GET_SONGS_FROM_SPOTIFY", { title, artist })
-                        .then((songs) => next(null, songs))
-                        .catch(next);
-                },
-            ],
-            (songs) => {
-                console.log(
-                    "SUCCESS",
-                    "APIS_GET_SPOTIFY_SONGS",
-                    `User "${session.userId}" got Spotify songs for title "${title}" successfully.`
-                );
-                cb({ status: "success", songs: songs });
-            }
-        );
-    }),
-
-    /**
-     * Gets Discogs data
-     *
-     * @param session
-     * @param query - the query
-     * @param cb
-     */
-    searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    const params = [
-                        `q=${encodeURIComponent(query)}`,
-                        `per_page=20`,
-                        `page=${page}`,
-                    ].join("&");
-
-                    const options = {
-                        url: `https://api.discogs.com/database/search?${params}`,
-                        headers: {
-                            "User-Agent": "Request",
-                            Authorization: `Discogs key=${config.get(
-                                "apis.discogs.client"
-                            )}, secret=${config.get("apis.discogs.secret")}`,
-                        },
-                    };
-
-                    request(options, (err, res, body) => {
-                        if (err) next(err);
-                        body = JSON.parse(body);
-                        next(null, body);
-                        if (body.error) next(body.error);
-                    });
-                },
-            ],
-            async (err, body) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "APIS_SEARCH_DISCOGS",
-                        `Searching discogs failed with query "${query}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "APIS_SEARCH_DISCOGS",
-                    `User "${session.userId}" searched Discogs succesfully for query "${query}".`
-                );
-                cb({
-                    status: "success",
-                    results: body.results,
-                    pages: body.pagination.pages,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Joins a room
-     *
-     * @param session
-     * @param page - the room to join
-     * @param cb
-     */
-    joinRoom: (session, page, cb) => {
-        if (page === "home") {
-            utils.runJob("SOCKET_JOIN_ROOM", {
-                socketId: session.socketId,
-                room: page,
-            });
-        }
-        cb({});
-    },
-
-    /**
-     * Joins an admin room
-     *
-     * @param session
-     * @param page - the admin room to join
-     * @param cb
-     */
-    joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-        if (
-            page === "queue" ||
-            page === "songs" ||
-            page === "stations" ||
-            page === "reports" ||
-            page === "news" ||
-            page === "users" ||
-            page === "statistics" ||
-            page === "punishments"
-        ) {
-            utils.runJob("SOCKET_JOIN_ROOM", {
-                socketId: session.socketId,
-                room: `admin.${page}`,
-            });
-        }
-        cb({});
-    }),
-
-    /**
-     * Returns current date
-     *
-     * @param session
-     * @param cb
-     */
-    ping: (session, cb) => {
-        cb({ date: Date.now() });
-    },
+import config from "config";
+import async from "async";
+import axios from "axios";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const YouTubeModule = moduleManager.modules.youtube;
+
+export default {
+	/**
+	 * Fetches a list of songs from Youtube's API
+	 *
+	 * @param {object} session - user session
+	 * @param {string} query - the query we'll pass to youtubes api
+	 * @param {Function} cb - callback
+	 * @returns {{status: string, data: object}} - returns an object
+	 */
+	searchYoutube(session, query, cb) {
+		return YouTubeModule.runJob("SEARCH", { query }, this)
+			.then(data => {
+				this.log("SUCCESS", "APIS_SEARCH_YOUTUBE", `Searching YouTube successful with query "${query}".`);
+				return cb({ status: "success", data });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	},
+
+	/**
+	 * Fetches a specific page of search results from Youtube's API
+	 *
+	 * @param {object} session - user session
+	 * @param {string} query - the query we'll pass to youtubes api
+	 * @param {string} pageToken - identifies a specific page in the result set that should be retrieved
+	 * @param {Function} cb - callback
+	 * @returns {{status: string, data: object}} - returns an object
+	 */
+	searchYoutubeForPage(session, query, pageToken, cb) {
+		return YouTubeModule.runJob("SEARCH", { query, pageToken }, this)
+			.then(data => {
+				this.log(
+					"SUCCESS",
+					"APIS_SEARCH_YOUTUBE_FOR_PAGE",
+					`Searching YouTube successful with query "${query}".`
+				);
+				return cb({ status: "success", data });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"APIS_SEARCH_YOUTUBE_FOR_PAGE",
+					`Searching youtube failed with query "${query}". "${err}"`
+				);
+				return cb({ status: "error", message: err });
+			});
+	},
+
+	/**
+	 * Gets Discogs data
+	 *
+	 * @param session
+	 * @param query - the query
+	 * @param {Function} cb
+	 */
+	searchDiscogs: isAdminRequired(function searchDiscogs(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					const options = {
+						params: { q: query, per_page: 20, page },
+						headers: {
+							"User-Agent": "Request",
+							Authorization: `Discogs key=${config.get("apis.discogs.client")}, secret=${config.get(
+								"apis.discogs.secret"
+							)}`
+						}
+					};
+
+					axios
+						.get("https://api.discogs.com/database/search", options)
+						.then(res => next(null, res.data))
+						.catch(err => next(err));
+				}
+			],
+			async (err, body) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"APIS_SEARCH_DISCOGS",
+						`Searching discogs failed with query "${query}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"APIS_SEARCH_DISCOGS",
+					`User "${session.userId}" searched Discogs succesfully for query "${query}".`
+				);
+				return cb({
+					status: "success",
+					data: {
+						results: body.results,
+						pages: body.pagination.pages
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Joins a room
+	 *
+	 * @param {object} session - user session
+	 * @param {string} room - the room to join
+	 * @param {Function} cb - callback
+	 */
+	joinRoom(session, room, cb) {
+		if (
+			room === "home" ||
+			room === "news" ||
+			room.startsWith("profile.") ||
+			room.startsWith("manage-station.") ||
+			room.startsWith("edit-song.") ||
+			room.startsWith("view-report.")
+		) {
+			WSModule.runJob("SOCKET_JOIN_ROOM", {
+				socketId: session.socketId,
+				room
+			})
+				.then(() => {})
+				.catch(err => {
+					this.log("ERROR", `Joining room failed: ${err.message}`);
+				});
+		}
+
+		cb({ status: "success", message: "Successfully joined room." });
+	},
+
+	/**
+	 * Leaves a room
+	 *
+	 * @param {object} session - user session
+	 * @param {string} room - the room to leave
+	 * @param {Function} cb - callback
+	 */
+	leaveRoom(session, room, cb) {
+		if (
+			room === "home" ||
+			room.startsWith("profile.") ||
+			room.startsWith("manage-station.") ||
+			room.startsWith("edit-song.") ||
+			room.startsWith("view-report.")
+		) {
+			WSModule.runJob("SOCKET_LEAVE_ROOM", {
+				socketId: session.socketId,
+				room
+			})
+				.then(() => {})
+				.catch(err => {
+					this.log("ERROR", `Leaving room failed: ${err.message}`);
+				});
+		}
+
+		cb({ status: "success", message: "Successfully left room." });
+	},
+
+	/**
+	 * Joins an admin room
+	 *
+	 * @param {object} session - user session
+	 * @param {string} page - the admin room to join
+	 * @param {Function} cb - callback
+	 */
+	joinAdminRoom: isAdminRequired((session, page, cb) => {
+		if (
+			page === "unverifiedSongs" ||
+			page === "songs" ||
+			page === "hiddenSongs" ||
+			page === "stations" ||
+			page === "reports" ||
+			page === "news" ||
+			page === "playlists" ||
+			page === "users" ||
+			page === "statistics" ||
+			page === "punishments"
+		) {
+			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
+				WSModule.runJob("SOCKET_JOIN_ROOM", {
+					socketId: session.socketId,
+					room: `admin.${page}`
+				});
+			});
+		}
+
+		cb({ status: "success", message: "Successfully joined admin room." });
+	}),
+
+	/**
+	 * Leaves all rooms
+	 *
+	 * @param {object} session - user session
+	 * @param {Function} cb - callback
+	 */
+	leaveRooms(session, cb) {
+		WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId });
+
+		cb({ status: "success", message: "Successfully left all rooms." });
+	},
+
+	/**
+	 * Returns current date
+	 *
+	 * @param {object} session - user session
+	 * @param {Function} cb - callback
+	 */
+	ping(session, cb) {
+		cb({ status: "success", message: "Successfully pinged.", data: { date: Date.now() } });
+	}
 };
 };

+ 102 - 0
backend/logic/actions/dataRequests.js

@@ -0,0 +1,102 @@
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+CacheModule.runJob("SUB", {
+	channel: "dataRequest.resolve",
+	cb: dataRequestId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.users",
+			args: ["event:admin.dataRequests.resolved", { data: { dataRequestId } }]
+		});
+	}
+});
+
+export default {
+	/**
+	 * Gets all unresolved data requests
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					dataRequestModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, requests) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "DATA_REQUESTS_INDEX", `Indexing data requests failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "DATA_REQUESTS_INDEX", `Indexing data requests successful.`, false);
+
+				return cb({ status: "success", data: { requests } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a data request
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} dataRequestId - the id of the data request to resolve
+	 * @param {Function} cb - gets called with the result
+	 */
+	resolve: isAdminRequired(async function update(session, dataRequestId, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!dataRequestId || typeof dataRequestId !== "string")
+						return next("Please provide a data request id.");
+					return next();
+				},
+
+				next => {
+					dataRequestModel.updateOne({ _id: dataRequestId }, { resolved: true }, { upsert: true }, err =>
+						next(err)
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"DATA_REQUESTS_RESOLVE",
+						`Resolving data request ${dataRequestId} failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "dataRequest.resolve", value: dataRequestId });
+
+				this.log(
+					"SUCCESS",
+					"DATA_REQUESTS_RESOLVE",
+					`Resolving data request "${dataRequestId}" successful for user ${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved data request."
+				});
+			}
+		);
+	})
+};

+ 49 - 55
backend/logic/actions/hooks/adminRequired.js

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

+ 6 - 6
backend/logic/actions/hooks/index.js

@@ -1,7 +1,7 @@
-'use strict';
+import loginRequired from "./loginRequired";
+import adminRequired from "./adminRequired";
+import ownerRequired from "./ownerRequired";
 
 
-module.exports = {
-	loginRequired: require('./loginRequired'),
-	adminRequired: require('./adminRequired'),
-	ownerRequired: require('./ownerRequired')
-};
+export const isLoginRequired = loginRequired;
+export const isAdminRequired = adminRequired;
+export const isOwnerRequired = ownerRequired;

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

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

+ 67 - 68
backend/logic/actions/hooks/ownerRequired.js

@@ -1,71 +1,70 @@
-const async = require("async");
+import async from "async";
 
 
-const moduleManager = require("../../../index");
+import moduleManager from "../../../index";
 
 
-const db = require("../../db");
-const cache = require("../../cache");
-const utils = require("../../utils");
-const stations = require("../../stations");
+const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+const StationsModule = moduleManager.modules.stations;
 
 
-module.exports = function(next) {
-    return async function(session, stationId) {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        let args = [];
-        for (let prop in arguments) args.push(arguments[prop]);
-        let cb = args[args.length - 1];
-        async.waterfall(
-            [
-                (next) => {
-                    cache
-                        .runJob("HGET", {
-                            table: "sessions",
-                            key: session.sessionId,
-                        })
-                        .then((session) => next(null, session))
-                        .catch(next);
-                },
-                (session, next) => {
-                    if (!session || !session.userId)
-                        return next("Login required.");
-                    this.session = session;
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-                (user, next) => {
-                    if (!user) return next("Login required.");
-                    if (user.role === "admin") return next(true);
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    if (
-                        station.type === "community" &&
-                        station.owner === session.userId
-                    )
-                        return next(true);
-                    next("Invalid permissions.");
-                },
-            ],
-            async (err) => {
-                if (err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "INFO",
-                        "OWNER_REQUIRED",
-                        `User failed to pass owner required check for station "${stationId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "INFO",
-                    "OWNER_REQUIRED",
-                    `User "${session.userId}" passed owner required check for station "${stationId}"`,
-                    false
-                );
-                next.apply(null, args);
-            }
-        );
-    };
-};
+export default destination =>
+	async function ownerRequired(session, stationId, ...args) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+				(session, next) => {
+					if (!session || !session.userId) return next("Login required.");
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+				(user, next) => {
+					if (!user) return next("Login required.");
+					if (user.role === "admin") return next(true);
+
+					if (!stationId) return next("Please provide a stationId.");
+
+					return StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type === "community" && station.owner === session.userId) return next(true);
+					return next("Invalid permissions.");
+				}
+			],
+			async err => {
+				if (err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"INFO",
+						"OWNER_REQUIRED",
+						`User failed to pass owner required check for station "${stationId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"INFO",
+					"OWNER_REQUIRED",
+					`User "${session.userId}" passed owner required check for station "${stationId}"`,
+					false
+				);
+
+				return destination.apply(this, [session, stationId].concat(args));
+			}
+		);
+	};

+ 23 - 13
backend/logic/actions/index.js

@@ -1,15 +1,25 @@
-"use strict";
+import apis from "./apis";
+import songs from "./songs";
+import stations from "./stations";
+import playlists from "./playlists";
+import users from "./users";
+import dataRequests from "./dataRequests";
+import activities from "./activities";
+import reports from "./reports";
+import news from "./news";
+import punishments from "./punishments";
+import utils from "./utils";
 
 
-module.exports = {
-    apis: require("./apis"),
-    songs: require("./songs"),
-    queueSongs: require("./queueSongs"),
-    stations: require("./stations"),
-    playlists: require("./playlists"),
-    users: require("./users"),
-    activities: require("./activities"),
-    reports: require("./reports"),
-    news: require("./news"),
-    punishments: require("./punishments"),
-    utils: require("./utils"),
+export default {
+	apis,
+	songs,
+	stations,
+	playlists,
+	users,
+	dataRequests,
+	activities,
+	reports,
+	news,
+	punishments,
+	utils
 };
 };

+ 259 - 235
backend/logic/actions/news.js

@@ -1,242 +1,266 @@
-"use strict";
-
-const async = require("async");
-
-const hooks = require("./hooks");
-const moduleManager = require("../../index");
-
-const db = require("../db");
-const cache = require("../cache");
-const utils = require("../utils");
-// const logger = require("logger");
-
-cache.runJob("SUB", {
-    channel: "news.create",
-    cb: (news) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: news.createdBy,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:admin.news.created", news);
-                });
-            },
-        });
-    },
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+CacheModule.runJob("SUB", {
+	channel: "news.create",
+	cb: news => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.news",
+			args: ["event:admin.news.created", { data: { news } }]
+		});
+
+		if (news.status === "published")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "news",
+				args: ["event:news.created", { data: { news } }]
+			});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "news.remove",
-    cb: (news) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: news.createdBy,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:admin.news.removed", news);
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "news.remove",
+	cb: newsId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.news",
+			args: ["event:admin.news.deleted", { data: { newsId } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "news",
+			args: ["event:news.deleted", { data: { newsId } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "news.update",
-    cb: (news) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: news.createdBy,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:admin.news.updated", news);
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "news.update",
+	cb: news => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.news",
+			args: ["event:admin.news.updated", { data: { news } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "news",
+			args: ["event:news.updated", { data: { news } }]
+		});
+	}
 });
 });
 
 
-module.exports = {
-    /**
-     * Gets all news items
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    index: async (session, cb) => {
-        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
-        async.waterfall(
-            [
-                (next) => {
-                    newsModel
-                        .find({})
-                        .sort({ createdAt: "desc" })
-                        .exec(next);
-                },
-            ],
-            async (err, news) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "NEWS_INDEX",
-                        `Indexing news failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "NEWS_INDEX",
-                    `Indexing news successful.`,
-                    false
-                );
-                return cb({ status: "success", data: news });
-            }
-        );
-    },
-
-    /**
-     * Creates a news item
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Object} data - the object of the news data
-     * @param {Function} cb - gets called with the result
-     */
-    create: hooks.adminRequired(async (session, data, cb) => {
-        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
-        async.waterfall(
-            [
-                (next) => {
-                    data.createdBy = session.userId;
-                    data.createdAt = Date.now();
-                    newsModel.create(data, next);
-                },
-            ],
-            async (err, news) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "NEWS_CREATE",
-                        `Creating news failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                cache.runJob("PUB", { channel: "news.create", value: news });
-                console.log(
-                    "SUCCESS",
-                    "NEWS_CREATE",
-                    `Creating news successful.`
-                );
-                return cb({
-                    status: "success",
-                    message: "Successfully created News",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Gets the latest news item
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    newest: async (session, cb) => {
-        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
-        async.waterfall(
-            [
-                (next) => {
-                    newsModel
-                        .findOne({})
-                        .sort({ createdAt: "desc" })
-                        .exec(next);
-                },
-            ],
-            async (err, news) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "NEWS_NEWEST",
-                        `Getting the latest news failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "NEWS_NEWEST",
-                    `Successfully got the latest news.`,
-                    false
-                );
-                return cb({ status: "success", data: news });
-            }
-        );
-    },
-
-    /**
-     * Removes a news item
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Object} news - the news object
-     * @param {Function} cb - gets called with the result
-     */
-    //TODO Pass in an id, not an object
-    //TODO Fix this
-    remove: hooks.adminRequired(async (session, news, cb) => {
-        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
-        newsModel.deleteOne({ _id: news._id }, async (err) => {
-            if (err) {
-                err = await utils.runJob("GET_ERROR", { error: err });
-                console.log(
-                    "ERROR",
-                    "NEWS_REMOVE",
-                    `Removing news "${news._id}" failed for user "${session.userId}". "${err}"`
-                );
-                return cb({ status: "failure", message: err });
-            } else {
-                cache.runJob("PUB", { channel: "news.remove", value: news });
-                console.log(
-                    "SUCCESS",
-                    "NEWS_REMOVE",
-                    `Removing news "${news._id}" successful by user "${session.userId}".`
-                );
-                return cb({
-                    status: "success",
-                    message: "Successfully removed News",
-                });
-            }
-        });
-    }),
-
-    /**
-     * Removes a news item
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} _id - the news id
-     * @param {Object} news - the news object
-     * @param {Function} cb - gets called with the result
-     */
-    //TODO Fix this
-    update: hooks.adminRequired(async (session, _id, news, cb) => {
-        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
-        newsModel.updateOne({ _id }, news, { upsert: true }, async (err) => {
-            if (err) {
-                err = await utils.runJob("GET_ERROR", { error: err });
-                console.log(
-                    "ERROR",
-                    "NEWS_UPDATE",
-                    `Updating news "${_id}" failed for user "${session.userId}". "${err}"`
-                );
-                return cb({ status: "failure", message: err });
-            } else {
-                cache.runJob("PUB", { channel: "news.update", value: news });
-                console.log(
-                    "SUCCESS",
-                    "NEWS_UPDATE",
-                    `Updating news "${_id}" successful for user "${session.userId}".`
-                );
-                return cb({
-                    status: "success",
-                    message: "Successfully updated News",
-                });
-            }
-        });
-    }),
+export default {
+	/**
+	 * Gets all news items that are published
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	async index(session, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+		async.waterfall(
+			[
+				next => {
+					newsModel.find({ status: "published" }).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_INDEX", `Indexing news failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "NEWS_INDEX", `Indexing news successful.`, false);
+
+				return cb({ status: "success", data: { news } });
+			}
+		);
+	},
+
+	/**
+	 * Gets a news item by id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} newsId - the news item id
+	 * @param {Function} cb - gets called with the result
+	 */
+	async getNewsFromId(session, newsId, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					newsModel.findById(newsId, next);
+				}
+			],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_NEWS_FROM_ID", `Getting news item ${newsId} failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "GET_NEWS_FROM_ID", `Got news item ${newsId} successfully.`, false);
+
+				return cb({ status: "success", data: { news } });
+			}
+		);
+	},
+	/**
+	 * Creates a news item
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} data - the object of the news data
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: isAdminRequired(async function create(session, data, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+		async.waterfall(
+			[
+				next => {
+					data.createdBy = session.userId;
+					data.createdAt = Date.now();
+					newsModel.create(data, next);
+				}
+			],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_CREATE", `Creating news failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "news.create", value: news });
+
+				this.log("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 the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	async newest(session, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+		async.waterfall([next => newsModel.findOne({}).sort({ createdAt: "desc" }).exec(next)], async (err, news) => {
+			if (err) {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			}
+
+			this.log("SUCCESS", "NEWS_NEWEST", `Successfully got the latest news.`, false);
+			return cb({ status: "success", data: { news } });
+		});
+	},
+
+	/**
+	 * Removes a news item
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} newsId - the id of the news item we want to remove
+	 * @param {Function} cb - gets called with the result
+	 */
+	remove: isAdminRequired(async function remove(session, newsId, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!newsId) return next("Please provide a news item id to update.");
+					return next();
+				},
+
+				next => {
+					newsModel.deleteOne({ _id: newsId }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"NEWS_REMOVE",
+						`Removing news "${newsId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "news.remove", value: newsId });
+
+				this.log("SUCCESS", "NEWS_REMOVE", `Removing news "${newsId}" successful by user "${session.userId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully removed News"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a news item
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} newsId - the id of the news item
+	 * @param {object} item - the news item object
+	 * @param {string} item.status - the status of the news e.g. published
+	 * @param {string} item.title - taken from a level-1 heading at the top of the markdown
+	 * @param {string} item.markdown - the markdown that forms the content of the news
+	 * @param {Function} cb - gets called with the result
+	 */
+	update: isAdminRequired(async function update(session, newsId, item, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!newsId) return next("Please provide a news item id to update.");
+					return next();
+				},
+
+				next => {
+					newsModel.updateOne({ _id: newsId }, item, { upsert: true }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"NEWS_UPDATE",
+						`Updating news item "${newsId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "news.update", value: { ...item, _id: newsId } });
+
+				this.log(
+					"SUCCESS",
+					"NEWS_UPDATE",
+					`Updating news item "${newsId}" successful for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully updated news item"
+				});
+			}
+		);
+	})
 };
 };

+ 1970 - 1158
backend/logic/actions/playlists.js

@@ -1,1173 +1,1985 @@
-"use strict";
-
-const async = require("async");
-
-const hooks = require("./hooks");
-const moduleManager = require("../../index");
-
-const db = require("../db");
-const cache = require("../cache");
-const utils = require("../utils");
-const playlists = require("../playlists");
-const songs = require("../songs");
-const activities = require("../activities");
-
-cache.runJob("SUB", {
-    channel: "playlist.create",
-    cb: (playlistId) => {
-        playlists.runJob("GET_PLAYLIST", { playlistId }).then((playlist) => {
-            utils
-                .runJob("SOCKETS_FROM_USER", { userId: playlist.createdBy })
-                .then((response) => {
-                    response.sockets.forEach((socket) => {
-                        socket.emit("event:playlist.create", playlist);
-                    });
-                });
-        });
-    },
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const SongsModule = moduleManager.modules.songs;
+const StationsModule = moduleManager.modules.stations;
+const CacheModule = moduleManager.modules.cache;
+const PlaylistsModule = moduleManager.modules.playlists;
+const YouTubeModule = moduleManager.modules.youtube;
+const ActivitiesModule = moduleManager.modules.activities;
+
+CacheModule.runJob("SUB", {
+	channel: "playlist.create",
+	cb: playlist => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: playlist.createdBy }, this).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("event:playlist.created", { data: { playlist } }))
+		);
+
+		if (playlist.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${playlist.createdBy}.playlists`,
+				args: ["event:playlist.created", { data: { playlist } }]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.created", { data: { playlist } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "playlist.delete",
-    cb: (res) => {
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:playlist.delete", res.playlistId);
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "playlist.delete",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.deleted", { data: { playlistId: res.playlistId } });
+			});
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `profile.${res.userId}.playlists`,
+			args: ["event:playlist.deleted", { data: { playlistId: res.playlistId } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.deleted", { data: { playlistId: res.playlistId } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "playlist.moveSongToTop",
-    cb: (res) => {
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:playlist.moveSongToTop", {
-                        playlistId: res.playlistId,
-                        songId: res.songId,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "playlist.repositionSong",
+	cb: res => {
+		const { userId, playlistId, song } = res;
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
+			sockets.forEach(socket =>
+				socket.dispatch("event:playlist.song.repositioned", {
+					data: { playlistId, song }
+				})
+			)
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "playlist.moveSongToBottom",
-    cb: (res) => {
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:playlist.moveSongToBottom", {
-                        playlistId: res.playlistId,
-                        songId: res.songId,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "playlist.addSong",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.song.added", {
+					data: {
+						playlistId: res.playlistId,
+						song: res.song
+					}
+				});
+			});
+		});
+
+		if (res.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.song.added",
+					{
+						data: {
+							playlistId: res.playlistId,
+							song: res.song
+						}
+					}
+				]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.song.added", { data: { playlistId: res.playlistId, song: res.song } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "playlist.addSong",
-    cb: (res) => {
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:playlist.addSong", {
-                        playlistId: res.playlistId,
-                        song: res.song,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "playlist.removeSong",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.song.removed", {
+					data: {
+						playlistId: res.playlistId,
+						youtubeId: res.youtubeId
+					}
+				});
+			});
+		});
+
+		if (res.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.song.removed",
+					{
+						data: {
+							playlistId: res.playlistId,
+							youtubeId: res.youtubeId
+						}
+					}
+				]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.song.removed",
+				{ data: { playlistId: res.playlistId, youtubeId: res.youtubeId } }
+			]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "playlist.removeSong",
-    cb: (res) => {
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:playlist.removeSong", {
-                        playlistId: res.playlistId,
-                        songId: res.songId,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "playlist.updateDisplayName",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.displayName.updated", {
+					data: {
+						playlistId: res.playlistId,
+						displayName: res.displayName
+					}
+				});
+			});
+		});
+
+		if (res.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.displayName.updated",
+					{
+						data: {
+							playlistId: res.playlistId,
+							displayName: res.displayName
+						}
+					}
+				]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.displayName.updated",
+				{ data: { playlistId: res.playlistId, displayName: res.displayName } }
+			]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "playlist.updateDisplayName",
-    cb: (res) => {
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:playlist.updateDisplayName", {
-                        playlistId: res.playlistId,
-                        displayName: res.displayName,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "playlist.updatePrivacy",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.privacy.updated", {
+					data: {
+						playlist: res.playlist
+					}
+				});
+			});
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.privacy.updated",
+				{ data: { playlistId: res.playlist._id, privacy: res.playlist.privacy } }
+			]
+		});
+
+		if (res.playlist.privacy === "public")
+			return WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.created",
+					{
+						data: {
+							playlist: res.playlist
+						}
+					}
+				]
+			});
+
+		return WSModule.runJob("EMIT_TO_ROOM", {
+			room: `profile.${res.userId}.playlists`,
+			args: [
+				"event:playlist.deleted",
+				{
+					data: {
+						playlistId: res.playlist._id
+					}
+				}
+			]
+		});
+	}
 });
 });
 
 
-let lib = {
-    /**
-     * Gets the first song from a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are getting the first song from
-     * @param {Function} cb - gets called with the result
-     */
-    getFirstSong: hooks.loginRequired((session, playlistId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    playlists
-                        .runJob("GET_PLAYLIST", { playlistId })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-
-                (playlist, next) => {
-                    if (!playlist || playlist.createdBy !== session.userId)
-                        return next("Playlist not found.");
-                    next(null, playlist.songs[0]);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_GET_FIRST_SONG",
-                        `Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_GET_FIRST_SONG",
-                    `Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`
-                );
-                cb({
-                    status: "success",
-                    song: song,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Gets all playlists for the user requesting it
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    indexForUser: hooks.loginRequired(async (session, cb) => {
-        const playlistModel = await db.runJob("GET_MODEL", {
-            modelName: "playlist",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    playlistModel.find({ createdBy: session.userId }, next);
-                },
-            ],
-            async (err, playlists) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_INDEX_FOR_USER",
-                        `Indexing playlists for user "${session.userId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_INDEX_FOR_USER",
-                    `Successfully indexed playlists for user "${session.userId}".`
-                );
-                cb({
-                    status: "success",
-                    data: playlists,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Creates a new private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Object} data - the data for the new private playlist
-     * @param {Function} cb - gets called with the result
-     */
-    create: hooks.loginRequired(async (session, data, cb) => {
-        const playlistModel = await db.runJob("GET_MODEL", {
-            modelName: "playlist",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    return data
-                        ? next()
-                        : cb({ status: "failure", message: "Invalid data" });
-                },
-
-                (next) => {
-                    const { displayName, songs } = data;
-                    playlistModel.create(
-                        {
-                            displayName,
-                            songs,
-                            createdBy: session.userId,
-                            createdAt: Date.now(),
-                        },
-                        next
-                    );
-                },
-            ],
-            async (err, playlist) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_CREATE",
-                        `Creating private playlist failed for user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                cache.runJob("PUB", {
-                    channel: "playlist.create",
-                    value: playlist._id,
-                });
-                activities.runJob("ADD_ACTIVITY", {
-                    userId: session.userId,
-                    activityType: "created_playlist",
-                    payload: [playlist._id],
-                });
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_CREATE",
-                    `Successfully created private playlist for user "${session.userId}".`
-                );
-                cb({
-                    status: "success",
-                    message: "Successfully created playlist",
-                    data: {
-                        _id: playlist._id,
-                    },
-                });
-            }
-        );
-    }),
-
-    /**
-     * Gets a playlist from id
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are getting
-     * @param {Function} cb - gets called with the result
-     */
-    getPlaylist: hooks.loginRequired((session, playlistId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    playlists
-                        .runJob("GET_PLAYLIST", { playlistId })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-
-                (playlist, next) => {
-                    if (!playlist || playlist.createdBy !== session.userId)
-                        return next("Playlist not found");
-                    next(null, playlist);
-                },
-            ],
-            async (err, playlist) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_GET",
-                        `Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_GET",
-                    `Successfully got private playlist "${playlistId}" for user "${session.userId}".`
-                );
-                cb({
-                    status: "success",
-                    data: playlist,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Obtains basic metadata of a playlist in order to format an activity
-     *
-     * @param session
-     * @param playlistId - the playlist id
-     * @param cb
-     */
-    getPlaylistForActivity: (session, playlistId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    playlists
-                        .runJob("GET_PLAYLIST", { playlistId })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-            ],
-            async (err, playlist) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
-                        `Failed to obtain metadata of playlist ${playlistId} for activity formatting. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
-                        `Obtained metadata of playlist ${playlistId} for activity formatting successfully.`
-                    );
-                    cb({
-                        status: "success",
-                        data: {
-                            title: playlist.displayName,
-                        },
-                    });
-                }
-            }
-        );
-    },
-
-    //TODO Remove this
-    /**
-     * Updates a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are updating
-     * @param {Object} playlist - the new private playlist object
-     * @param {Function} cb - gets called with the result
-     */
-    update: hooks.loginRequired(async (session, playlistId, playlist, cb) => {
-        const playlistModel = await db.runJob("GET_MODEL", {
-            modelName: "playlist",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    playlistModel.updateOne(
-                        { _id: playlistId, createdBy: session.userId },
-                        playlist,
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    playlists
-                        .runJob("UPDATE_PLAYLIST", { playlistId })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-            ],
-            async (err, playlist) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_UPDATE",
-                        `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_UPDATE",
-                    `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
-                );
-                cb({
-                    status: "success",
-                    data: playlist,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Updates a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are updating
-     * @param {Function} cb - gets called with the result
-     */
-    shuffle: hooks.loginRequired(async (session, playlistId, cb) => {
-        const playlistModel = await db.runJob("GET_MODEL", {
-            modelName: "playlist",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    if (!playlistId) return next("No playlist id.");
-                    playlistModel.findById(playlistId, next);
-                },
-
-                (playlist, next) => {
-                    utils
-                        .runJob("SHUFFLE", { array: playlist.songs })
-                        .then((result) => next(null, result.array))
-                        .catch(next);
-                },
-
-                (songs, next) => {
-                    playlistModel.updateOne(
-                        { _id: playlistId },
-                        { $set: { songs } },
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    playlists
-                        .runJob("UPDATE_PLAYLIST", { playlistId })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-            ],
-            async (err, playlist) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_SHUFFLE",
-                        `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_SHUFFLE",
-                    `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
-                );
-                cb({
-                    status: "success",
-                    message: "Successfully shuffled playlist.",
-                    data: playlist,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Adds a song to a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Boolean} isSet - is the song part of a set of songs to be added
-     * @param {String} songId - the id of the song we are trying to add
-     * @param {String} playlistId - the id of the playlist we are adding the song to
-     * @param {Function} cb - gets called with the result
-     */
-    addSongToPlaylist: hooks.loginRequired(
-        async (session, isSet, songId, playlistId, cb) => {
-            const playlistModel = await db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-
-            async.waterfall(
-                [
-                    (next) => {
-                        playlists
-                            .runJob("GET_PLAYLIST", { playlistId })
-                            .then((playlist) => {
-                                if (
-                                    !playlist ||
-                                    playlist.createdBy !== session.userId
-                                )
-                                    return next(
-                                        "Something went wrong when trying to get the playlist"
-                                    );
-
-                                async.each(
-                                    playlist.songs,
-                                    (song, next) => {
-                                        if (song.songId === songId)
-                                            return next(
-                                                "That song is already in the playlist"
-                                            );
-                                        next();
-                                    },
-                                    next
-                                );
-                            })
-                            .catch(next);
-                    },
-                    (next) => {
-                        songs
-                            .runJob("GET_SONG", { id: songId })
-                            .then((response) => {
-                                const song = response.song;
-                                next(null, {
-                                    _id: song._id,
-                                    songId: songId,
-                                    title: song.title,
-                                    duration: song.duration,
-                                });
-                            })
-                            .catch(() => {
-                                utils
-                                    .runJob("GET_SONG_FROM_YOUTUBE", { songId })
-                                    .then((response) =>
-                                        next(null, response.song)
-                                    )
-                                    .catch(next);
-                            });
-                    },
-                    (newSong, next) => {
-                        playlistModel.updateOne(
-                            { _id: playlistId },
-                            { $push: { songs: newSong } },
-                            { runValidators: true },
-                            (err) => {
-                                if (err) return next(err);
-                                playlists
-                                    .runJob("UPDATE_PLAYLIST", { playlistId })
-                                    .then((playlist) =>
-                                        next(null, playlist, newSong)
-                                    )
-                                    .catch(next);
-                            }
-                        );
-                    },
-                ],
-                async (err, playlist, newSong) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "PLAYLIST_ADD_SONG",
-                            `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "PLAYLIST_ADD_SONG",
-                            `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${session.userId}".`
-                        );
-                        if (!isSet)
-                            activities.runJob("ADD_ACTIVITY", {
-                                userId: session.userId,
-                                activityType: "added_song_to_playlist",
-                                payload: [{ songId, playlistId }],
-                            });
-
-                        cache.runJob("PUB", {
-                            channel: "playlist.addSong",
-                            value: {
-                                playlistId: playlist._id,
-                                song: newSong,
-                                userId: session.userId,
-                            },
-                        });
-                        return cb({
-                            status: "success",
-                            message:
-                                "Song has been successfully added to the playlist",
-                            data: playlist.songs,
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Adds a set of songs to a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} url - the url of the the YouTube playlist
-     * @param {String} playlistId - the id of the playlist we are adding the set of songs to
-     * @param {Boolean} musicOnly - whether to only add music to the playlist
-     * @param {Function} cb - gets called with the result
-     */
-    addSetToPlaylist: hooks.loginRequired(
-        (session, url, playlistId, musicOnly, cb) => {
-            let videosInPlaylistTotal = 0;
-            let songsInPlaylistTotal = 0;
-            let songsSuccess = 0;
-            let songsFail = 0;
-
-            let addedSongs = [];
-
-            async.waterfall(
-                [
-                    (next) => {
-                        utils
-                            .runJob("GET_PLAYLIST_FROM_YOUTUBE", {
-                                url,
-                                musicOnly,
-                            })
-                            .then((response) => {
-                                if (response.filteredSongs) {
-                                    videosInPlaylistTotal =
-                                        response.songs.length;
-                                    songsInPlaylistTotal =
-                                        response.filteredSongs.length;
-                                } else {
-                                    songsInPlaylistTotal = videosInPlaylistTotal =
-                                        response.songs.length;
-                                }
-                                next(null, response.songs);
-                            });
-                    },
-                    (songIds, next) => {
-                        let processed = 0;
-                        function checkDone() {
-                            if (processed === songIds.length) next();
-                        }
-                        for (let s = 0; s < songIds.length; s++) {
-                            lib.addSongToPlaylist(
-                                session,
-                                true,
-                                songIds[s],
-                                playlistId,
-                                (res) => {
-                                    processed++;
-                                    if (res.status === "success") {
-                                        addedSongs.push(songIds[s]);
-                                        songsSuccess++;
-                                    } else songsFail++;
-                                    checkDone();
-                                }
-                            );
-                        }
-                    },
-
-                    (next) => {
-                        playlists
-                            .runJob("GET_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        if (!playlist || playlist.createdBy !== session.userId)
-                            return next("Playlist not found.");
-                        next(null, playlist);
-                    },
-                ],
-                async (err, playlist) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "PLAYLIST_IMPORT",
-                            `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    } else {
-                        activities.runJob("ADD_ACTIVITY", {
-                            userId: session.userId,
-                            activityType: "added_songs_to_playlist",
-                            payload: addedSongs,
-                        });
-                        console.log(
-                            "SUCCESS",
-                            "PLAYLIST_IMPORT",
-                            `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${songsSuccess}, songs failed: ${songsFail}.`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Playlist has been successfully imported.",
-                            data: playlist.songs,
-                            stats: {
-                                videosInPlaylistTotal,
-                                songsInPlaylistTotal,
-                                songsAddedSuccessfully: songsSuccess,
-                                songsFailedToAdd: songsFail,
-                            },
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Removes a song from a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} songId - the id of the song we are removing from the private playlist
-     * @param {String} playlistId - the id of the playlist we are removing the song from
-     * @param {Function} cb - gets called with the result
-     */
-    removeSongFromPlaylist: hooks.loginRequired(
-        async (session, songId, playlistId, cb) => {
-            const playlistModel = await db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (!songId || typeof songId !== "string")
-                            return next("Invalid song id.");
-                        if (!playlistId || typeof playlistId !== "string")
-                            return next("Invalid playlist id.");
-                        next();
-                    },
-
-                    (next) => {
-                        playlists
-                            .runJob("GET_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        if (!playlist || playlist.createdBy !== session.userId)
-                            return next("Playlist not found");
-                        playlistModel.updateOne(
-                            { _id: playlistId },
-                            { $pull: { songs: { songId: songId } } },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        playlists
-                            .runJob("UPDATE_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-                ],
-                async (err, playlist) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "PLAYLIST_REMOVE_SONG",
-                            `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "PLAYLIST_REMOVE_SONG",
-                            `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${session.userId}".`
-                        );
-                        cache.runJob("PUB", {
-                            channel: "playlist.removeSong",
-                            value: {
-                                playlistId: playlist._id,
-                                songId: songId,
-                                userId: session.userId,
-                            },
-                        });
-                        return cb({
-                            status: "success",
-                            message:
-                                "Song has been successfully removed from playlist",
-                            data: playlist.songs,
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates the displayName of a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are updating the displayName for
-     * @param {Function} cb - gets called with the result
-     */
-    updateDisplayName: hooks.loginRequired(
-        async (session, playlistId, displayName, cb) => {
-            const playlistModel = await db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        playlistModel.updateOne(
-                            { _id: playlistId, createdBy: session.userId },
-                            { $set: { displayName } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        playlists
-                            .runJob("UPDATE_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-                ],
-                async (err, playlist) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "PLAYLIST_UPDATE_DISPLAY_NAME",
-                            `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "PLAYLIST_UPDATE_DISPLAY_NAME",
-                        `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "playlist.updateDisplayName",
-                        value: {
-                            playlistId: playlistId,
-                            displayName: displayName,
-                            userId: session.userId,
-                        },
-                    });
-                    return cb({
-                        status: "success",
-                        message: "Playlist has been successfully updated",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Moves a song to the top of the list in a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are moving the song to the top from
-     * @param {String} songId - the id of the song we are moving to the top of the list
-     * @param {Function} cb - gets called with the result
-     */
-    moveSongToTop: hooks.loginRequired(
-        async (session, playlistId, songId, cb) => {
-            const playlistModel = await db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        playlists
-                            .runJob("GET_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        if (!playlist || playlist.createdBy !== session.userId)
-                            return next("Playlist not found");
-                        async.each(
-                            playlist.songs,
-                            (song, next) => {
-                                if (song.songId === songId) return next(song);
-                                next();
-                            },
-                            (err) => {
-                                if (err && err.songId) return next(null, err);
-                                next("Song not found");
-                            }
-                        );
-                    },
-
-                    (song, next) => {
-                        playlistModel.updateOne(
-                            { _id: playlistId },
-                            { $pull: { songs: { songId } } },
-                            (err) => {
-                                if (err) return next(err);
-                                return next(null, song);
-                            }
-                        );
-                    },
-
-                    (song, next) => {
-                        playlistModel.updateOne(
-                            { _id: playlistId },
-                            {
-                                $push: {
-                                    songs: {
-                                        $each: [song],
-                                        $position: 0,
-                                    },
-                                },
-                            },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        playlists
-                            .runJob("UPDATE_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-                ],
-                async (err, playlist) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "PLAYLIST_MOVE_SONG_TO_TOP",
-                            `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "PLAYLIST_MOVE_SONG_TO_TOP",
-                        `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "playlist.moveSongToTop",
-                        value: {
-                            playlistId,
-                            songId,
-                            userId: session.userId,
-                        },
-                    });
-                    return cb({
-                        status: "success",
-                        message: "Playlist has been successfully updated",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Moves a song to the bottom of the list in a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are moving the song to the bottom from
-     * @param {String} songId - the id of the song we are moving to the bottom of the list
-     * @param {Function} cb - gets called with the result
-     */
-    moveSongToBottom: hooks.loginRequired(
-        async (session, playlistId, songId, cb) => {
-            const playlistModel = await db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        playlists
-                            .runJob("GET_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        if (!playlist || playlist.createdBy !== session.userId)
-                            return next("Playlist not found");
-                        async.each(
-                            playlist.songs,
-                            (song, next) => {
-                                if (song.songId === songId) return next(song);
-                                next();
-                            },
-                            (err) => {
-                                if (err && err.songId) return next(null, err);
-                                next("Song not found");
-                            }
-                        );
-                    },
-
-                    (song, next) => {
-                        playlistModel.updateOne(
-                            { _id: playlistId },
-                            { $pull: { songs: { songId } } },
-                            (err) => {
-                                if (err) return next(err);
-                                return next(null, song);
-                            }
-                        );
-                    },
-
-                    (song, next) => {
-                        playlistModel.updateOne(
-                            { _id: playlistId },
-                            {
-                                $push: {
-                                    songs: song,
-                                },
-                            },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        playlists
-                            .runJob("UPDATE_PLAYLIST", { playlistId })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-                ],
-                async (err, playlist) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "PLAYLIST_MOVE_SONG_TO_BOTTOM",
-                            `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "PLAYLIST_MOVE_SONG_TO_BOTTOM",
-                        `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "playlist.moveSongToBottom",
-                        value: {
-                            playlistId,
-                            songId,
-                            userId: session.userId,
-                        },
-                    });
-                    return cb({
-                        status: "success",
-                        message: "Playlist has been successfully updated",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Removes a private playlist
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} playlistId - the id of the playlist we are moving the song to the top from
-     * @param {Function} cb - gets called with the result
-     */
-    remove: hooks.loginRequired(async (session, playlistId, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    playlists
-                        .runJob("DELETE_PLAYLIST", { playlistId })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-
-                (next) => {
-                    stationModel.find({ privatePlaylist: playlistId }, next);
-                },
-
-                (stations, next) => {
-                    async.each(
-                        stations,
-                        (station, next) => {
-                            async.waterfall(
-                                [
-                                    (next) => {
-                                        stationModel.updateOne(
-                                            { _id: station._id },
-                                            { $set: { privatePlaylist: null } },
-                                            { runValidators: true },
-                                            next
-                                        );
-                                    },
-
-                                    (res, next) => {
-                                        if (!station.partyMode) {
-                                            moduleManager.modules["stations"]
-                                                .runJob("UPDATE_STATION", {
-                                                    stationId: station._id,
-                                                })
-                                                .then((station) =>
-                                                    next(null, station)
-                                                )
-                                                .catch(next);
-                                            cache.runJob("PUB", {
-                                                channel:
-                                                    "privatePlaylist.selected",
-                                                value: {
-                                                    playlistId: null,
-                                                    stationId: station._id,
-                                                },
-                                            });
-                                        } else next();
-                                    },
-                                ],
-                                (err) => {
-                                    next();
-                                }
-                            );
-                        },
-                        (err) => {
-                            next();
-                        }
-                    );
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "PLAYLIST_REMOVE",
-                        `Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "PLAYLIST_REMOVE",
-                    `Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
-                );
-                cache.runJob("PUB", {
-                    channel: "playlist.delete",
-                    value: {
-                        userId: session.userId,
-                        playlistId,
-                    },
-                });
-                activities.runJob("ADD_ACTIVITY", {
-                    userId: session.userId,
-                    activityType: "deleted_playlist",
-                    payload: [playlistId],
-                });
-                return cb({
-                    status: "success",
-                    message: "Playlist successfully removed",
-                });
-            }
-        );
-    }),
-};
+export default {
+	/**
+	 * Gets all playlists
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.find({}).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_INDEX", `Indexing playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_INDEX", "Indexing playlists successful.");
+				return cb({ status: "success", data: { playlists } });
+			}
+		);
+	}),
+
+	/**
+	 * Searches through all playlists that can be included in a community station
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchCommunity: isLoginRequired(async function searchCommunity(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includeUser: true,
+						includeGenre: true,
+						includeOwn: true,
+						includeSongs: true,
+						userId: session.userId,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_COMMUNITY", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_COMMUNITY", "Searching playlists successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
+	/**
+	 * Searches through all playlists that can be included in an official station
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchOfficial: isAdminRequired(async function searchOfficial(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includeGenre: true,
+						includePrivate: true,
+						includeSongs: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_OFFICIAL", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_OFFICIAL", "Searching playlists successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
+	/**
+	 * Gets the first song from a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are getting the first song from
+	 * @param {Function} cb - gets called with the result
+	 */
+	getFirstSong: isLoginRequired(function getFirstSong(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
+					playlist.songs.sort((a, b) => a.position - b.position);
+					return next(null, playlist.songs[0]);
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET_FIRST_SONG",
+						`Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET_FIRST_SONG",
+					`Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					data: { song }
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets a list of all the playlists for a specific user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the user id in question
+	 * @param {Function} cb - gets called with the result
+	 */
+	indexForUser: async function indexForUser(session, userId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findById(userId).select({ "preferences.orderOfPlaylists": -1 }).exec(next);
+				},
+
+				({ preferences }, next) => {
+					const { orderOfPlaylists } = preferences;
+
+					const match = {
+						createdBy: userId,
+						type: "user"
+					};
+
+					// if a playlist order exists
+					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
+
+					playlistModel
+						.aggregate()
+						.match(match)
+						.addFields({
+							weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
+						})
+						.sort({ weight: 1 })
+						.exec(next);
+				},
+
+				(playlists, next) => {
+					if (session.userId === userId) return next(null, playlists); // user requesting playlists is the owner of the playlists
+
+					const filteredPlaylists = [];
+
+					return async.each(
+						playlists,
+						(playlist, nextPlaylist) => {
+							if (playlist.privacy === "public") filteredPlaylists.push(playlist);
+							return nextPlaylist();
+						},
+						() => next(null, filteredPlaylists)
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_INDEX_FOR_USER",
+						`Indexing playlists for user "${userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${userId}".`);
+
+				return cb({
+					status: "success",
+					data: { playlists }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Gets all playlists for the user requesting it
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {boolean} showNonModifiablePlaylists - whether or not to show non modifiable playlists e.g. liked songs
+	 * @param {Function} cb - gets called with the result
+	 */
+	indexMyPlaylists: isLoginRequired(async function indexMyPlaylists(session, showNonModifiablePlaylists, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findById(session.userId).select({ "preferences.orderOfPlaylists": -1 }).exec(next);
+				},
+
+				({ preferences }, next) => {
+					const { orderOfPlaylists } = preferences;
+
+					const match = {
+						createdBy: session.userId,
+						type: "user"
+					};
+
+					// if non modifiable playlists should be shown as well
+					if (!showNonModifiablePlaylists) match.isUserModifiable = true;
+
+					// if a playlist order exists
+					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
+
+					playlistModel
+						.aggregate()
+						.match(match)
+						.addFields({
+							weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
+						})
+						.sort({ weight: 1 })
+						.exec(next);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_INDEX_FOR_ME",
+						`Indexing playlists for user "${session.userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_INDEX_FOR_ME",
+					`Successfully indexed playlists for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					data: { playlists }
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Creates a new private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} data - the data for the new private playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: isLoginRequired(async function create(session, data, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const blacklist = ["liked songs", "likedsongs", "disliked songs", "dislikedsongs"];
+
+		async.waterfall(
+			[
+				next => (data ? next() : cb({ status: "error", message: "Invalid data" })),
+
+				next => {
+					const { displayName, songs, privacy } = data;
+
+					if (blacklist.indexOf(displayName.toLowerCase()) !== -1)
+						return next("That playlist name is blacklisted. Please use a different name.");
+
+					return playlistModel.create(
+						{
+							displayName,
+							songs,
+							privacy,
+							createdBy: session.userId,
+							createdAt: Date.now(),
+							createdFor: null,
+							type: "user"
+						},
+						next
+					);
+				},
+
+				(playlist, next) => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $push: { "preferences.orderOfPlaylists": playlist._id } },
+						err => {
+							if (err) return next(err);
+							return next(null, playlist);
+						}
+					);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_CREATE",
+						`Creating private playlist failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.create",
+					value: playlist
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: playlist.createdBy,
+					type: "playlist__create",
+					payload: {
+						message: `Created playlist <playlistId>${playlist.displayName}</playlistId>`,
+						playlistId: playlist._id
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CREATE",
+					`Successfully created private playlist for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully created playlist",
+					data: {
+						playlistId: playlist._id
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets a playlist from id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are getting
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPlaylist: function getPlaylist(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found");
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
+						if (session)
+							// check if user requested to get a playlist is an admin
+							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user && user.role === "admin") return next(null, playlist);
+									return next("User unauthorised to view playlist.");
+								});
+							});
+						return next("User unauthorised to view playlist.");
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET",
+						`Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET",
+					`Successfully got private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { playlist }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Gets a playlist from station id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} stationId - the id of the station we are getting
+	 * @param {string} includeSongs - include songs
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPlaylistForStation: function getPlaylist(session, stationId, includeSongs, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_STATION_PLAYLIST", { stationId, includeSongs }, this)
+						.then(response => next(null, response.playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found");
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
+						if (session)
+							// check if user requested to get a playlist is an admin
+							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user && user.role === "admin") return next(null, playlist);
+									return next("User unauthorised to view playlist.");
+								});
+							});
+						return next("User unauthorised to view playlist.");
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET",
+						`Getting playlist for station "${stationId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET",
+					`Successfully got playlist for station "${stationId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { playlist }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Shuffles songs in a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating
+	 * @param {Function} cb - gets called with the result
+	 */
+	shuffle: isLoginRequired(async function shuffle(session, playlistId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) return next("No playlist id.");
+					return playlistModel.findById(playlistId, next);
+				},
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be shuffled.");
+
+					return UtilsModule.runJob("SHUFFLE_SONG_POSITIONS", { array: playlist.songs }, this)
+						.then(result => next(null, result.array))
+						.catch(next);
+				},
+
+				(songs, next) => {
+					playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_SHUFFLE",
+						`Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_SHUFFLE",
+					`Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully shuffled playlist.",
+					data: { playlist }
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Changes the order (position) of a song in a playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are targeting
+	 * @param {object} song - the song to be repositioned
+	 * @param {string} song.youtubeId - the youtube id of the song being repositioned
+	 * @param {string} song.newIndex - the new position of the song in the playlist
+	 * @param {...any} song.args - any other elements that would be included with a song item in a playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	repositionSong: isLoginRequired(async function repositionSong(session, playlistId, song, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) return next("Please provide a playlist.");
+					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
+					return next();
+				},
+
+				// remove song from playlist
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $pull: { songs: { youtubeId: song.youtubeId } } },
+						next
+					);
+				},
+
+				// add song back to playlist (in new position)
+				(res, next) => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $push: { songs: { $each: [song], $position: song.newIndex } } },
+						err => next(err)
+					);
+				},
+
+				// update the cache with the new songs positioning
+				next => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_REPOSITION_SONG",
+						`Repositioning song ${song.youtubeId}  for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REPOSITION_SONG",
+					`Successfully repositioned song ${song.youtubeId} for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.repositionSong",
+					value: {
+						userId: session.userId,
+						playlistId,
+						song
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully repositioned song"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Adds a song to a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {boolean} isSet - is the song part of a set of songs to be added
+	 * @param {string} youtubeId - the youtube id of the song we are trying to add
+	 * @param {string} playlistId - the id of the playlist we are adding the song to
+	 * @param {Function} cb - gets called with the result
+	 */
+	addSongToPlaylist: isLoginRequired(async function addSongToPlaylist(session, isSet, youtubeId, playlistId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							if (!playlist || playlist.createdBy !== session.userId)
+								return next("Something went wrong when trying to get the playlist");
+
+							return async.each(
+								playlist.songs,
+								(song, nextSong) => {
+									if (song.youtubeId === youtubeId)
+										return next("That song is already in the playlist");
+									return nextSong();
+								},
+								err => next(err)
+							);
+						})
+						.catch(next);
+				},
+
+				next => {
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+						.then(UserModel => {
+							UserModel.findOne(
+								{ _id: session.userId },
+								{ "preferences.anonymousSongRequests": 1 },
+								next
+							);
+						})
+						.catch(next);
+				},
+
+				(user, next) => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId,
+							userId: user.preferences.anonymousSongRequests ? null : session.userId,
+							automaticallyRequested: true
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, status } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								status
+							});
+						})
+						.catch(next);
+				},
+				(newSong, next) => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $push: { songs: newSong } },
+						{ runValidators: true },
+						err => {
+							if (err) return next(err);
+							return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+								.then(playlist => next(null, playlist, newSong))
+								.catch(next);
+						}
+					);
+				}
+			],
+			async (err, playlist, newSong) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_ADD_SONG",
+						`Adding song "${youtubeId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_ADD_SONG",
+					`Successfully added song "${youtubeId}" to private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				if (!isSet && playlist.displayName !== "Liked Songs" && playlist.displayName !== "Disliked Songs") {
+					const songName = newSong.artists
+						? `${newSong.title} by ${newSong.artists.join(", ")}`
+						: newSong.title;
+
+					if (playlist.privacy === "public")
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "playlist__add_song",
+							payload: {
+								message: `Added <youtubeId>${songName}</youtubeId> to playlist <playlistId>${playlist.displayName}</playlistId>`,
+								thumbnail: newSong.thumbnail,
+								playlistId,
+								youtubeId
+							}
+						});
+				}
+
+				StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+					.then(response => {
+						response.stationIds.forEach(stationId => {
+							PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+						});
+					})
+					.catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.addSong",
+					value: {
+						playlistId: playlist._id,
+						song: newSong,
+						userId: session.userId,
+						privacy: playlist.privacy
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully added to the playlist",
+					data: { songs: playlist.songs }
+				});
+			}
+		);
+	}),
 
 
-module.exports = lib;
+	/**
+	 * Adds a set of songs to a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the YouTube playlist
+	 * @param {string} playlistId - the id of the playlist we are adding the set of songs to
+	 * @param {boolean} musicOnly - whether to only add music to the playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	addSetToPlaylist: isLoginRequired(function addSetToPlaylist(session, url, playlistId, musicOnly, cb) {
+		let videosInPlaylistTotal = 0;
+		let songsInPlaylistTotal = 0;
+		let addSongsStats = null;
+
+		const addedSongs = [];
+
+		async.waterfall(
+			[
+				next => {
+					YouTubeModule.runJob("GET_PLAYLIST", { url, musicOnly }, this)
+						.then(res => {
+							if (res.filteredSongs) {
+								videosInPlaylistTotal = res.songs.length;
+								songsInPlaylistTotal = res.filteredSongs.length;
+							} else {
+								songsInPlaylistTotal = videosInPlaylistTotal = res.songs.length;
+							}
+							next(null, res.songs);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+				(youtubeIds, next) => {
+					let successful = 0;
+					let failed = 0;
+					let alreadyInPlaylist = 0;
+
+					if (youtubeIds.length === 0) next();
+
+					async.eachLimit(
+						youtubeIds,
+						1,
+						(youtubeId, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "playlists",
+									action: "addSongToPlaylist",
+									args: [true, youtubeId, playlistId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "success") {
+										successful += 1;
+										addedSongs.push(youtubeId);
+									} else failed += 1;
+									if (res.message === "That song is already in the playlist") alreadyInPlaylist += 1;
+								})
+								.catch(() => {
+									failed += 1;
+								})
+								.finally(() => next());
+						},
+						() => {
+							addSongsStats = { successful, failed, alreadyInPlaylist };
+							next(null);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
+					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_IMPORT",
+						`Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				if (playlist.privacy === "public")
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "playlist__import_playlist",
+						payload: {
+							message: `Imported ${addSongsStats.successful} songs to playlist <playlistId>${playlist.displayName}</playlistId>`,
+							playlistId
+						}
+					});
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_IMPORT",
+					`Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}.`
+				);
+
+				return cb({
+					status: "success",
+					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`,
+					data: {
+						songs: playlist.songs,
+						stats: {
+							videosInPlaylistTotal,
+							songsInPlaylistTotal
+						}
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a song from a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} youtubeId - the youtube id of the song we are removing from the private playlist
+	 * @param {string} playlistId - the id of the playlist we are removing the song from
+	 * @param {Function} cb - gets called with the result
+	 */
+	removeSongFromPlaylist: isLoginRequired(async function removeSongFromPlaylist(session, youtubeId, playlistId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!youtubeId || typeof youtubeId !== "string") return next("Invalid song id.");
+					if (!playlistId || typeof youtubeId !== "string") return next("Invalid playlist id.");
+					return next();
+				},
+
+				// remove song from playlist
+				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { youtubeId } } }, next),
+
+				// update cache representation of the playlist
+				(res, next) => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+						.then(response => {
+							response.stationIds.forEach(stationId => {
+								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+							});
+						})
+						.catch();
+
+					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
+						.then(res =>
+							next(null, playlist, {
+								title: res.song.title,
+								thumbnail: res.song.thumbnail,
+								artists: res.song.artists
+							})
+						)
+						.catch(() => {
+							YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
+								.then(response => next(null, playlist, response.song))
+								.catch(next);
+						});
+				},
+
+				(playlist, youtubeSong, next) => {
+					const songName = youtubeSong.artists
+						? `${youtubeSong.title} by ${youtubeSong.artists.join(", ")}`
+						: youtubeSong.title;
+
+					if (
+						playlist.displayName !== "Liked Songs" &&
+						playlist.displayName !== "Disliked Songs" &&
+						playlist.privacy === "public"
+					) {
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "playlist__remove_song",
+							payload: {
+								message: `Removed <youtubeId>${songName}</youtubeId> from playlist <playlistId>${playlist.displayName}</playlistId>`,
+								thumbnail: youtubeSong.thumbnail,
+								playlistId,
+								youtubeId
+							}
+						});
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_REMOVE_SONG",
+						`Removing song "${youtubeId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REMOVE_SONG",
+					`Successfully removed song "${youtubeId}" from private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.removeSong",
+					value: {
+						playlistId: playlist._id,
+						youtubeId,
+						userId: session.userId,
+						privacy: playlist.privacy
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully removed from playlist",
+					data: { songs: playlist.songs }
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates the displayName of a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the displayName for
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateDisplayName: isLoginRequired(async function updateDisplayName(session, playlistId, displayName, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					return next(null);
+				},
+
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId, createdBy: session.userId },
+						{ $set: { displayName } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_DISPLAY_NAME",
+						`Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_DISPLAY_NAME",
+					`Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updateDisplayName",
+					value: {
+						playlistId,
+						displayName,
+						userId: session.userId,
+						privacy: playlist.privacy
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "playlist__edit_display_name",
+					payload: {
+						message: `Changed display name of playlist <playlistId>${displayName}</playlistId>`,
+						playlistId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a user's own modifiable user playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are removing
+	 * @param {Function} cb - gets called with the result
+	 */
+	remove: isLoginRequired(async function remove(session, playlistId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (playlist.createdBy !== session.userId) return next("You do not own this playlist.");
+					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					return next(null, playlist);
+				},
+
+				(playlist, next) => {
+					userModel.updateOne(
+						{ _id: playlist.createdBy },
+						{ $pull: { "preferences.orderOfPlaylists": playlist._id } },
+						err => next(err, playlist)
+					);
+				},
+
+				(playlist, next) => {
+					PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId }, this)
+						.then(() => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_REMOVE",
+						`Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REMOVE",
+					`Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.delete",
+					value: {
+						userId: session.userId,
+						playlistId
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: playlist.createdBy,
+					type: "playlist__remove",
+					payload: {
+						message: `Removed playlist ${playlist.displayName}`
+					}
+				});
+
+				ActivitiesModule.runJob("REMOVE_ACTIVITY_REFERENCES", { type: "playlistId", playlistId });
+
+				return cb({
+					status: "success",
+					message: "Playlist successfully removed"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a user's modifiable user playlist as an admin
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are removing
+	 * @param {Function} cb - gets called with the result
+	 */
+	removeAdmin: isAdminRequired(async function removeAdmin(session, playlistId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					return next(null, playlist);
+				},
+
+				(playlist, next) => {
+					userModel.updateOne(
+						{ _id: playlist.createdBy },
+						{ $pull: { "preferences.orderOfPlaylists": playlist._id } },
+						err => next(err, playlist, playlist.createdBy)
+					);
+				},
+
+				(playlist, playlistCreator, next) => {
+					PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId }, this)
+						.then(() => next(null, playlistCreator))
+						.catch(next);
+				}
+			],
+			async (err, playlistCreator) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_REMOVE_ADMIN",
+						`Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REMOVE_ADMIN",
+					`Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.delete",
+					value: {
+						userId: playlistCreator,
+						playlistId
+					}
+				});
+
+				ActivitiesModule.runJob("REMOVE_ACTIVITY_REFERENCES", { type: "playlistId", playlistId });
+
+				return cb({
+					status: "success",
+					message: "Playlist successfully removed"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates the privacy of a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the privacy for
+	 * @param {string} privacy - what the new privacy of the playlist should be e.g. public
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePrivacy: isLoginRequired(async function updatePrivacy(session, playlistId, privacy, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId, createdBy: session.userId },
+						{ $set: { privacy } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					if (res.n === 0) next("No user playlist found with that id and owned by you.");
+					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					}
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_PRIVACY",
+						`Updating privacy to "${privacy}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_PRIVACY",
+					`Successfully updated privacy to "${privacy}" for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updatePrivacy",
+					value: {
+						userId: session.userId,
+						playlist
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "playlist__edit_privacy",
+					payload: {
+						message: `Changed privacy of playlist <playlistId>${playlist.displayName}</playlistId> to ${privacy}`,
+						playlistId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates the privacy of a playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the privacy for
+	 * @param {string} privacy - what the new privacy of the playlist should be e.g. public
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePrivacyAdmin: isAdminRequired(async function updatePrivacyAdmin(session, playlistId, privacy, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.updateOne({ _id: playlistId }, { $set: { privacy } }, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					if (res.n === 0) next("No playlist found with that id.");
+					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					}
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_PRIVACY_ADMIN",
+						`Updating privacy to "${privacy}" for playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_PRIVACY_ADMIn",
+					`Successfully updated privacy to "${privacy}" for playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				if (playlist.type === "user") {
+					CacheModule.runJob("PUB", {
+						channel: "playlist.updatePrivacy",
+						value: {
+							userId: playlist.createdBy,
+							playlist
+						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Deletes all orphaned station playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedStationPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_STATION_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
+						`Deleting orphaned station playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
+					"Deleting orphaned station playlists successful."
+				);
+				return cb({ status: "success", message: "Successfully deleted orphaned station playlists." });
+			}
+		);
+	}),
+
+	/**
+	 * Deletes all orphaned genre playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_GENRE_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
+						`Deleting orphaned genre playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
+					"Deleting orphaned genre playlists successful."
+				);
+				return cb({ status: "success", message: "Successfully deleted orphaned genre playlists." });
+			}
+		);
+	}),
+
+	/**
+	 * Requests orpahned playlist songs
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestOrphanedPlaylistSongs: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("REQUEST_ORPHANED_PLAYLIST_SONGS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_ORPHANED_PLAYLIST_SONGS",
+						`Requesting orphaned playlist songs failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"REQUEST_ORPHANED_PLAYLIST_SONGS",
+					"Requesting orphaned playlist songs was successful."
+				);
+				return cb({ status: "success", message: "Successfully requested orphaned playlist songs." });
+			}
+		);
+	}),
+
+	/**
+	 * Clears and refills a station playlist
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillStationPlaylist: isAdminRequired(async function index(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) next("Please specify a playlist id");
+					else {
+						PlaylistsModule.runJob("CLEAR_AND_REFILL_STATION_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
+						`Clearing and refilling station playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
+					`Successfully cleared and refilled station playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully cleared and refilled"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Clears and refills a genre playlist
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillGenrePlaylist: isAdminRequired(async function index(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) next("Please specify a playlist id");
+					else {
+						PlaylistsModule.runJob("CLEAR_AND_REFILL_GENRE_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
+						`Clearing and refilling genre playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
+					`Successfully cleared and refilled genre playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully cleared and refilled"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Clears and refills all station playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillAllStationPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_ALL_STATION_PLAYLISTS", {}, this)
+						.then(response => {
+							next(null, response.playlists);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+
+				(playlists, next) => {
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob(
+								"CLEAR_AND_REFILL_STATION_PLAYLIST",
+								{ playlistId: playlist._id },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
+						`Clearing and refilling all station playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
+					`Successfully cleared and refilled all station playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Clears and refills all genre playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillAllGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_ALL_GENRE_PLAYLISTS", {}, this)
+						.then(response => {
+							next(null, response.playlists);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+
+				(playlists, next) => {
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob(
+								"CLEAR_AND_REFILL_GENRE_PLAYLIST",
+								{ playlistId: playlist._id },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
+						`Clearing and refilling all genre playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
+					`Successfully cleared and refilled all genre playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled"
+				});
+			}
+		);
+	})
+};

+ 212 - 163
backend/logic/actions/punishments.js

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

+ 0 - 394
backend/logic/actions/queueSongs.js

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

+ 500 - 354
backend/logic/actions/reports.js

@@ -1,359 +1,505 @@
-"use strict";
-
-const async = require("async");
-
-const hooks = require("./hooks");
-
-const moduleManager = require("../../index");
-
-const db = require("../db");
-const cache = require("../cache");
-const utils = require("../utils");
-// const logger = require("../logger");
-const songs = require("../songs");
-
-const reportableIssues = [
-    {
-        name: "Video",
-        reasons: [
-            "Doesn't exist",
-            "It's private",
-            "It's not available in my country",
-        ],
-    },
-    {
-        name: "Title",
-        reasons: ["Incorrect", "Inappropriate"],
-    },
-    {
-        name: "Duration",
-        reasons: [
-            "Skips too soon",
-            "Skips too late",
-            "Starts too soon",
-            "Skips too late",
-        ],
-    },
-    {
-        name: "Artists",
-        reasons: ["Incorrect", "Inappropriate"],
-    },
-    {
-        name: "Thumbnail",
-        reasons: ["Incorrect", "Inappropriate", "Doesn't exist"],
-    },
-];
-
-cache.runJob("SUB", {
-    channel: "report.resolve",
-    cb: (reportId) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: "admin.reports",
-            args: ["event:admin.report.resolved", reportId],
-        });
-    },
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const SongsModule = moduleManager.modules.songs;
+const CacheModule = moduleManager.modules.cache;
+const ActivitiesModule = moduleManager.modules.activities;
+
+CacheModule.runJob("SUB", {
+	channel: "report.issue.toggle",
+	cb: data =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`edit-song.${data.songId}`, `view-report.${data.reportId}`],
+			args: [
+				"event:admin.report.issue.toggled",
+				{ data: { issueId: data.issueId, reportId: data.reportId, resolved: data.resolved } }
+			]
+		})
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "report.create",
-    cb: (report) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: "admin.reports",
-            args: ["event:admin.report.created", report],
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "report.resolve",
+	cb: ({ reportId, songId }) =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.reports", `edit-song.${songId}`, `view-report.${reportId}`],
+			args: ["event:admin.report.resolved", { data: { reportId } }]
+		})
+});
+
+CacheModule.runJob("SUB", {
+	channel: "report.create",
+	cb: report => {
+		console.log(report);
+
+		DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+			userModel
+				.findById(report.createdBy)
+				.select({ avatar: -1, name: -1, username: -1 })
+				.exec((err, { avatar, name, username }) => {
+					report.createdBy = {
+						avatar,
+						name,
+						username,
+						_id: report.createdBy
+					};
+
+					WSModule.runJob("EMIT_TO_ROOMS", {
+						rooms: ["admin.reports", `edit-song.${report.song._id}`],
+						args: ["event:admin.report.created", { data: { report } }]
+					});
+				});
+		});
+	}
 });
 });
 
 
-module.exports = {
-    /**
-     * Gets all reports
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    index: hooks.adminRequired(async (session, cb) => {
-        const reportModel = await db.runJob("GET_MODEL", {
-            modelName: "report",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    reportModel
-                        .find({ resolved: false })
-                        .sort({ released: "desc" })
-                        .exec(next);
-                },
-            ],
-            async (err, reports) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REPORTS_INDEX",
-                        `Indexing reports failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "REPORTS_INDEX",
-                    "Indexing reports successful."
-                );
-                cb({ status: "success", data: reports });
-            }
-        );
-    }),
-
-    /**
-     * Gets a specific report
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} reportId - the id of the report to return
-     * @param {Function} cb - gets called with the result
-     */
-    findOne: hooks.adminRequired(async (session, reportId, cb) => {
-        const reportModel = await db.runJob("GET_MODEL", {
-            modelName: "report",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    reportModel.findOne({ _id: reportId }).exec(next);
-                },
-            ],
-            async (err, report) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REPORTS_FIND_ONE",
-                        `Finding report "${reportId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "REPORTS_FIND_ONE",
-                    `Finding report "${reportId}" successful.`
-                );
-                cb({ status: "success", data: report });
-            }
-        );
-    }),
-
-    /**
-     * Gets all reports for a songId (_id)
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} songId - the id of the song to index reports for
-     * @param {Function} cb - gets called with the result
-     */
-    getReportsForSong: hooks.adminRequired(async (session, songId, cb) => {
-        const reportModel = await db.runJob("GET_MODEL", {
-            modelName: "report",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    reportModel
-                        .find({ song: { _id: songId }, resolved: false })
-                        .sort({ released: "desc" })
-                        .exec(next);
-                },
-
-                (reports, next) => {
-                    let data = [];
-                    for (let i = 0; i < reports.length; i++) {
-                        data.push(reports[i]._id);
-                    }
-                    next(null, data);
-                },
-            ],
-            async (err, data) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "GET_REPORTS_FOR_SONG",
-                        `Indexing reports for song "${songId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "GET_REPORTS_FOR_SONG",
-                        `Indexing reports for song "${songId}" successful.`
-                    );
-                    return cb({ status: "success", data });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Resolves a report
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} reportId - the id of the report that is getting resolved
-     * @param {Function} cb - gets called with the result
-     */
-    resolve: hooks.adminRequired(async (session, reportId, cb) => {
-        const reportModel = await db.runJob("GET_MODEL", {
-            modelName: "report",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    reportModel.findOne({ _id: reportId }).exec(next);
-                },
-
-                (report, next) => {
-                    if (!report) return next("Report not found.");
-                    report.resolved = true;
-                    report.save((err) => {
-                        if (err) next(err.message);
-                        else next();
-                    });
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REPORTS_RESOLVE",
-                        `Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    cache.runJob("PUB", {
-                        channel: "report.resolve",
-                        value: reportId,
-                    });
-                    console.log(
-                        "SUCCESS",
-                        "REPORTS_RESOLVE",
-                        `User "${session.userId}" resolved report "${reportId}".`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully resolved Report",
-                    });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Creates a new report
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Object} data - the object of the report data
-     * @param {Function} cb - gets called with the result
-     */
-    create: hooks.loginRequired(async (session, data, cb) => {
-        const reportModel = await db.runJob("GET_MODEL", {
-            modelName: "report",
-        });
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId: data.songId }).exec(next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("Song not found.");
-                    songs
-                        .runJob("GET_SONG", { id: song._id })
-                        .then((response) => next(null, response.song))
-                        .catch(next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("Song not found.");
-
-                    delete data.songId;
-                    data.song = {
-                        _id: song._id,
-                        songId: song.songId,
-                    };
-
-                    for (let z = 0; z < data.issues.length; z++) {
-                        if (
-                            reportableIssues.filter((issue) => {
-                                return issue.name == data.issues[z].name;
-                            }).length > 0
-                        ) {
-                            for (let r = 0; r < reportableIssues.length; r++) {
-                                if (
-                                    reportableIssues[r].reasons.every(
-                                        (reason) =>
-                                            data.issues[z].reasons.indexOf(
-                                                reason
-                                            ) < -1
-                                    )
-                                ) {
-                                    return cb({
-                                        status: "failure",
-                                        message: "Invalid data",
-                                    });
-                                }
-                            }
-                        } else
-                            return cb({
-                                status: "failure",
-                                message: "Invalid data",
-                            });
-                    }
-
-                    next();
-                },
-
-                (next) => {
-                    let issues = [];
-
-                    for (let r = 0; r < data.issues.length; r++) {
-                        if (!data.issues[r].reasons.length <= 0)
-                            issues.push(data.issues[r]);
-                    }
-
-                    data.issues = issues;
-
-                    next();
-                },
-
-                (next) => {
-                    data.createdBy = session.userId;
-                    data.createdAt = Date.now();
-                    reportModel.create(data, next);
-                },
-            ],
-            async (err, report) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REPORTS_CREATE",
-                        `Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    cache.runJob("PUB", {
-                        channel: "report.create",
-                        value: report,
-                    });
-                    console.log(
-                        "SUCCESS",
-                        "REPORTS_CREATE",
-                        `User "${session.userId}" created report for "${data.songId}".`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully created report",
-                    });
-                }
-            }
-        );
-    }),
+export default {
+	/**
+	 * Gets all reports that haven't been yet resolved
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => reportModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next),
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							console.log(typeof report.createdBy);
+
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "REPORTS_INDEX", `Indexing reports failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "REPORTS_INDEX", "Indexing reports successful.");
+				return cb({ status: "success", data: { reports } });
+			}
+		);
+	}),
+
+	/**
+	 * Gets a specific report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} reportId - the id of the report to return
+	 * @param {Function} cb - gets called with the result
+	 */
+	findOne: isAdminRequired(async function findOne(session, reportId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => reportModel.findOne({ _id: reportId }).exec(next),
+				(report, next) =>
+					userModel
+						.findById(report.createdBy)
+						.select({ avatar: -1, name: -1, username: -1 })
+						.exec((err, user) => {
+							if (!user)
+								next(err, {
+									...report._doc,
+									createdBy: { _id: report.createdBy }
+								});
+							else
+								next(err, {
+									...report._doc,
+									createdBy: {
+										avatar: user.avatar,
+										name: user.name,
+										username: user.username,
+										_id: report.createdBy
+									}
+								});
+						})
+			],
+			async (err, report) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "REPORTS_FIND_ONE", `Finding report "${reportId}" successful.`);
+				return cb({ status: "success", data: { report } });
+			}
+		);
+	}),
+
+	/**
+	 * Gets all reports for a songId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the id of the song to index reports for
+	 * @param {Function} cb - gets called with the result
+	 */
+	getReportsForSong: isAdminRequired(async function getReportsForSong(session, songId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next =>
+					reportModel.find({ "song._id": songId, resolved: false }).sort({ createdAt: "desc" }).exec(next),
+
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" successful.`);
+				return cb({ status: "success", data: { reports } });
+			}
+		);
+	}),
+
+	/**
+	 * Gets all a users reports for a specific songId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the id of the song
+	 * @param {Function} cb - gets called with the result
+	 */
+	myReportsForSong: isLoginRequired(async function myReportsForSong(session, songId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next =>
+					reportModel
+						.find({ "song._id": songId, createdBy: session.userId, resolved: false })
+						.sort({ createdAt: "desc" })
+						.exec(next),
+
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MY_REPORTS_FOR_SONG",
+						`Indexing reports of user ${session.userId} for song "${songId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"MY_REPORTS_FOR_SONG",
+					`Indexing reports of user ${session.userId} for song "${songId}" successful.`
+				);
+
+				return cb({ status: "success", data: { reports } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a report as a whole
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} reportId - the id of the report that is getting resolved
+	 * @param {Function} cb - gets called with the result
+	 */
+	resolve: isAdminRequired(async function resolve(session, reportId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					reportModel.findById(reportId).exec(next);
+				},
+
+				(report, next) => {
+					if (!report) return next("Report not found.");
+
+					report.resolved = true;
+
+					return report.save(err => {
+						if (err) return next(err.message);
+						return next(null, report.song._id);
+					});
+				}
+			],
+			async (err, songId) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_RESOLVE",
+						`Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "report.resolve",
+					value: { reportId, songId }
+				});
+
+				this.log("SUCCESS", "REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved Report"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Resolves/Unresolves an issue within a report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} reportId - the id of the report that is getting resolved
+	 * @param {string} issueId - the id of the issue within the report
+	 * @param {Function} cb - gets called with the result
+	 */
+	toggleIssue: isAdminRequired(async function toggleIssue(session, reportId, issueId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					reportModel.findById(reportId).exec(next);
+				},
+
+				(report, next) => {
+					if (!report) return next("Report not found.");
+
+					const issue = report.issues.find(issue => issue._id.toString() === issueId);
+					issue.resolved = !issue.resolved;
+
+					return report.save(err => {
+						if (err) return next(err.message);
+						return next(null, issue.resolved, report.song._id);
+					});
+				}
+			],
+			async (err, resolved, songId) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_TOGGLE_ISSUE",
+						`Resolving an issue within report "${reportId}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "report.issue.toggle",
+					value: { reportId, issueId, songId, resolved }
+				});
+
+				this.log(
+					"SUCCESS",
+					"REPORTS_TOGGLE_ISSUE",
+					`User "${session.userId}" resolved an issue in report "${reportId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved issue within report"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Creates a new report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} report - the object of the report data
+	 * @param {string} report.youtubeId - the youtube id of the song that is being reported
+	 * @param {Array} report.issues - all issues reported (custom or defined)
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: isLoginRequired(async function create(session, report, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		const { youtubeId } = report;
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }).exec(next),
+
+				(song, next) => {
+					if (!song) return next("Song not found.");
+
+					return SongsModule.runJob("GET_SONG", { songId: song._id }, this)
+						.then(res => next(null, res.song))
+						.catch(next);
+				},
+
+				(song, next) => {
+					if (!song) return next("Song not found.");
+
+					delete report.youtubeId;
+					report.song = {
+						_id: song._id,
+						youtubeId: song.youtubeId
+					};
+
+					return next(null, { title: song.title, artists: song.artists, thumbnail: song.thumbnail });
+				},
+
+				(song, next) =>
+					reportModel.create(
+						{
+							createdBy: session.userId,
+							createdAt: Date.now(),
+							...report
+						},
+						(err, report) => next(err, report, song)
+					)
+			],
+			async (err, report, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_CREATE",
+						`Creating report for "${report.song._id}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__report",
+					payload: {
+						message: `Created a <reportId>${report._id}</reportId> for song <youtubeId>${song.title}</youtubeId>`,
+						youtubeId: report.song.youtubeId,
+						reportId: report._id,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "report.create",
+					value: report
+				});
+
+				this.log("SUCCESS", "REPORTS_CREATE", `User "${session.userId}" created report for "${youtubeId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully created report"
+				});
+			}
+		);
+	})
 };
 };

+ 1469 - 1029
backend/logic/actions/songs.js

@@ -1,1044 +1,1484 @@
-"use strict";
-
-const async = require("async");
-
-const hooks = require("./hooks");
-const queueSongs = require("./queueSongs");
-
-// const moduleManager = require("../../index");
-
-const db = require("../db");
-const songs = require("../songs");
-const cache = require("../cache");
-const utils = require("../utils");
-const activities = require("../activities");
-// const logger = moduleManager.modules["logger"];
-
-cache.runJob("SUB", {
-    channel: "song.removed",
-    cb: (songId) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: "admin.songs",
-            args: ["event:admin.song.removed", songId],
-        });
-    },
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const SongsModule = moduleManager.modules.songs;
+const ActivitiesModule = moduleManager.modules.activities;
+const YouTubeModule = moduleManager.modules.youtube;
+const PlaylistsModule = moduleManager.modules.playlists;
+
+CacheModule.runJob("SUB", {
+	channel: "song.newUnverifiedSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+
+		songModel.findOne({ _id: songId }, (err, song) =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.unverifiedSongs", `edit-song.${songId}`],
+				args: ["event:admin.unverifiedSong.created", { data: { song } }]
+			})
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "song.added",
-    cb: async (songId) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        songModel.findOne({ _id: songId }, (err, song) => {
-            utils.runJob("EMIT_TO_ROOM", {
-                room: "admin.songs",
-                args: ["event:admin.song.added", song],
-            });
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "song.removedUnverifiedSong",
+	cb: songId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.unverifiedSongs",
+			args: ["event:admin.unverifiedSong.deleted", { data: { songId } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "song.updated",
-    cb: async (songId) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        songModel.findOne({ _id: songId }, (err, song) => {
-            utils.runJob("EMIT_TO_ROOM", {
-                room: "admin.songs",
-                args: ["event:admin.song.updated", song],
-            });
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "song.updatedUnverifiedSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.unverifiedSongs",
+				args: ["event:admin.unverifiedSong.updated", { data: { song } }]
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "song.like",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `song.${data.songId}`,
-            args: [
-                "event:song.like",
-                {
-                    songId: data.songId,
-                    likes: data.likes,
-                    dislikes: data.dislikes,
-                },
-            ],
-        });
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:song.newRatings", {
-                        songId: data.songId,
-                        liked: true,
-                        disliked: false,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "song.newVerifiedSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
+
+		songModel.findOne({ _id: songId }, (err, song) =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.songs", `edit-song.${songId}`],
+				args: ["event:admin.verifiedSong.created", { data: { song } }]
+			})
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "song.dislike",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `song.${data.songId}`,
-            args: [
-                "event:song.dislike",
-                {
-                    songId: data.songId,
-                    likes: data.likes,
-                    dislikes: data.dislikes,
-                },
-            ],
-        });
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:song.newRatings", {
-                        songId: data.songId,
-                        liked: false,
-                        disliked: true,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "song.removedVerifiedSong",
+	cb: songId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.songs",
+			args: ["event:admin.verifiedSong.deleted", { data: { songId } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "song.unlike",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `song.${data.songId}`,
-            args: [
-                "event:song.unlike",
-                {
-                    songId: data.songId,
-                    likes: data.likes,
-                    dislikes: data.dislikes,
-                },
-            ],
-        });
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:song.newRatings", {
-                        songId: data.songId,
-                        liked: false,
-                        disliked: false,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "song.updatedVerifiedSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.songs",
+				args: ["event:admin.verifiedSong.updated", { data: { song } }]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.newHiddenSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+
+		songModel.findOne({ _id: songId }, (err, song) =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.hiddenSongs", `edit-song.${songId}`],
+				args: ["event:admin.hiddenSong.created", { data: { song } }]
+			})
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "song.undislike",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `song.${data.songId}`,
-            args: [
-                "event:song.undislike",
-                {
-                    songId: data.songId,
-                    likes: data.likes,
-                    dislikes: data.dislikes,
-                },
-            ],
-        });
-        utils
-            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
-            .then((response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:song.newRatings", {
-                        songId: data.songId,
-                        liked: false,
-                        disliked: false,
-                    });
-                });
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "song.removedHiddenSong",
+	cb: songId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.hiddenSongs",
+			args: ["event:admin.hiddenSong.deleted", { data: { songId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.updatedHiddenSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.hiddenSongs",
+				args: ["event:admin.hiddenSong.updated", { data: { song } }]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.like",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.liked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: true,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.dislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.disliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: true
+					}
+				});
+			});
+		});
+	}
 });
 });
 
 
-module.exports = {
-    /**
-     * Returns the length of the songs list
-     *
-     * @param session
-     * @param cb
-     */
-    length: hooks.adminRequired(async (session, cb) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.countDocuments({}, next);
-                },
-            ],
-            async (err, count) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_LENGTH",
-                        `Failed to get length from songs. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "SONGS_LENGTH",
-                    `Got length from songs successfully.`
-                );
-                cb(count);
-            }
-        );
-    }),
-
-    /**
-     * Gets a set of songs
-     *
-     * @param session
-     * @param set - the set number to return
-     * @param cb
-     */
-    getSet: hooks.adminRequired(async (session, set, cb) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel
-                        .find({})
-                        .skip(15 * (set - 1))
-                        .limit(15)
-                        .exec(next);
-                },
-            ],
-            async (err, songs) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_GET_SET",
-                        `Failed to get set from songs. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "SONGS_GET_SET",
-                    `Got set from songs successfully.`
-                );
-                cb(songs);
-            }
-        );
-    }),
-
-    /**
-     * Gets a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    getSong: hooks.adminRequired((session, songId, cb) => {
-        console.log(songId);
-
-        async.waterfall(
-            [
-                (next) => {
-                    song.getSong(songId, next);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_GET_SONG",
-                        `Failed to get song ${songId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "SONGS_GET_SONG",
-                        `Got song ${songId} successfully.`
-                    );
-                    cb({ status: "success", data: song });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Obtains basic metadata of a song in order to format an activity
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    getSongForActivity: (session, songId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    songs
-                        .runJob("GET_SONG_FROM_ID", { songId })
-                        .then((responsesong) => next(null, response.song))
-                        .catch(next);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_GET_SONG_FOR_ACTIVITY",
-                        `Failed to obtain metadata of song ${songId} for activity formatting. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    if (song) {
-                        console.log(
-                            "SUCCESS",
-                            "SONGS_GET_SONG_FOR_ACTIVITY",
-                            `Obtained metadata of song ${songId} for activity formatting successfully.`
-                        );
-                        cb({
-                            status: "success",
-                            data: {
-                                title: song.title,
-                                thumbnail: song.thumbnail,
-                            },
-                        });
-                    } else {
-                        console.log(
-                            "ERROR",
-                            "SONGS_GET_SONG_FOR_ACTIVITY",
-                            `Song ${songId} does not exist so failed to obtain for activity formatting.`
-                        );
-                        cb({ status: "failure" });
-                    }
-                }
-            }
-        );
-    },
-
-    /**
-     * Updates a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param song - the updated song object
-     * @param cb
-     */
-    update: hooks.adminRequired(async (session, songId, song, cb) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.updateOne(
-                        { _id: songId },
-                        song,
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    songs
-                        .runJob("UPDATE_SONG", { songId })
-                        .then((song) => next(null, song))
-                        .catch(next);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_UPDATE",
-                        `Failed to update song "${songId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "SONGS_UPDATE",
-                    `Successfully updated song "${songId}".`
-                );
-                cache.runJob("PUB", {
-                    channel: "song.updated",
-                    value: song.songId,
-                });
-                cb({
-                    status: "success",
-                    message: "Song has been successfully updated",
-                    data: song,
-                });
-            }
-        );
-    }),
-
-    /**
-     * Removes a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    remove: hooks.adminRequired(async (session, songId, cb) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.deleteOne({ _id: songId }, next);
-                },
-
-                (res, next) => {
-                    //TODO Check if res gets returned from above
-                    cache
-                        .runJob("HDEL", { table: "songs", key: songId })
-                        .then(() => next())
-                        .catch(next);
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_UPDATE",
-                        `Failed to remove song "${songId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "SONGS_UPDATE",
-                    `Successfully remove song "${songId}".`
-                );
-                cache.runJob("PUB", { channel: "song.removed", value: songId });
-                cb({
-                    status: "success",
-                    message: "Song has been successfully updated",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Adds a song
-     *
-     * @param session
-     * @param song - the song object
-     * @param cb
-     */
-    add: hooks.adminRequired(async (session, song, cb) => {
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId: song.songId }, next);
-                },
-
-                (existingSong, next) => {
-                    if (existingSong)
-                        return next("Song is already in rotation.");
-                    next();
-                },
-
-                (next) => {
-                    const newSong = new songModel(song);
-                    newSong.acceptedBy = session.userId;
-                    newSong.acceptedAt = Date.now();
-                    newSong.save(next);
-                },
-
-                (res, next) => {
-                    queueSongs.remove(session, song._id, () => {
-                        next();
-                    });
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_ADD",
-                        `User "${session.userId}" failed to add song. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "SONGS_ADD",
-                    `User "${session.userId}" successfully added song "${song.songId}".`
-                );
-                cache.runJob("PUB", {
-                    channel: "song.added",
-                    value: song.songId,
-                });
-                cb({
-                    status: "success",
-                    message: "Song has been moved from the queue successfully.",
-                });
-            }
-        );
-        //TODO Check if video is in queue and Add the song to the appropriate stations
-    }),
-
-    /**
-     * Likes a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    like: hooks.loginRequired(async (session, songId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId }, next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("No song found with that id.");
-                    next(null, song);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_LIKE",
-                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                let oldSongId = songId;
-                songId = song._id;
-                userModel.findOne({ _id: session.userId }, (err, user) => {
-                    if (user.liked.indexOf(songId) !== -1)
-                        return cb({
-                            status: "failure",
-                            message: "You have already liked this song.",
-                        });
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        {
-                            $push: { liked: songId },
-                            $pull: { disliked: songId },
-                        },
-                        (err) => {
-                            if (!err) {
-                                userModel.countDocuments(
-                                    { liked: songId },
-                                    (err, likes) => {
-                                        if (err)
-                                            return cb({
-                                                status: "failure",
-                                                message:
-                                                    "Something went wrong while liking this song.",
-                                            });
-                                        userModel.countDocuments(
-                                            { disliked: songId },
-                                            (err, dislikes) => {
-                                                if (err)
-                                                    return cb({
-                                                        status: "failure",
-                                                        message:
-                                                            "Something went wrong while liking this song.",
-                                                    });
-                                                songModel.update(
-                                                    { _id: songId },
-                                                    {
-                                                        $set: {
-                                                            likes: likes,
-                                                            dislikes: dislikes,
-                                                        },
-                                                    },
-                                                    (err) => {
-                                                        if (err)
-                                                            return cb({
-                                                                status:
-                                                                    "failure",
-                                                                message:
-                                                                    "Something went wrong while liking this song.",
-                                                            });
-                                                        songs.runJob(
-                                                            "UPDATE_SONG",
-                                                            { songId }
-                                                        );
-                                                        cache.runJob("PUB", {
-                                                            channel:
-                                                                "song.like",
-                                                            value: JSON.stringify(
-                                                                {
-                                                                    songId: oldSongId,
-                                                                    userId:
-                                                                        session.userId,
-                                                                    likes: likes,
-                                                                    dislikes: dislikes,
-                                                                }
-                                                            ),
-                                                        });
-                                                        activities.runJob(
-                                                            "ADD_ACTIVITY",
-                                                            {
-                                                                userId:
-                                                                    session.userId,
-                                                                activityType:
-                                                                    "liked_song",
-                                                                payload: [
-                                                                    songId,
-                                                                ],
-                                                            }
-                                                        );
-                                                        return cb({
-                                                            status: "success",
-                                                            message:
-                                                                "You have successfully liked this song.",
-                                                        });
-                                                    }
-                                                );
-                                            }
-                                        );
-                                    }
-                                );
-                            } else
-                                return cb({
-                                    status: "failure",
-                                    message:
-                                        "Something went wrong while liking this song.",
-                                });
-                        }
-                    );
-                });
-            }
-        );
-    }),
-
-    /**
-     * Dislikes a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    dislike: hooks.loginRequired(async (session, songId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId }, next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("No song found with that id.");
-                    next(null, song);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_DISLIKE",
-                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                let oldSongId = songId;
-                songId = song._id;
-                userModel.findOne({ _id: session.userId }, (err, user) => {
-                    if (user.disliked.indexOf(songId) !== -1)
-                        return cb({
-                            status: "failure",
-                            message: "You have already disliked this song.",
-                        });
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        {
-                            $push: { disliked: songId },
-                            $pull: { liked: songId },
-                        },
-                        (err) => {
-                            if (!err) {
-                                userModel.countDocuments(
-                                    { liked: songId },
-                                    (err, likes) => {
-                                        if (err)
-                                            return cb({
-                                                status: "failure",
-                                                message:
-                                                    "Something went wrong while disliking this song.",
-                                            });
-                                        userModel.countDocuments(
-                                            { disliked: songId },
-                                            (err, dislikes) => {
-                                                if (err)
-                                                    return cb({
-                                                        status: "failure",
-                                                        message:
-                                                            "Something went wrong while disliking this song.",
-                                                    });
-                                                songModel.update(
-                                                    { _id: songId },
-                                                    {
-                                                        $set: {
-                                                            likes: likes,
-                                                            dislikes: dislikes,
-                                                        },
-                                                    },
-                                                    (err, res) => {
-                                                        if (err)
-                                                            return cb({
-                                                                status:
-                                                                    "failure",
-                                                                message:
-                                                                    "Something went wrong while disliking this song.",
-                                                            });
-                                                        songs.runJob(
-                                                            "UPDATE_SONG",
-                                                            { songId }
-                                                        );
-                                                        cache.runJob("PUB", {
-                                                            channel:
-                                                                "song.dislike",
-                                                            value: JSON.stringify(
-                                                                {
-                                                                    songId: oldSongId,
-                                                                    userId:
-                                                                        session.userId,
-                                                                    likes: likes,
-                                                                    dislikes: dislikes,
-                                                                }
-                                                            ),
-                                                        });
-                                                        return cb({
-                                                            status: "success",
-                                                            message:
-                                                                "You have successfully disliked this song.",
-                                                        });
-                                                    }
-                                                );
-                                            }
-                                        );
-                                    }
-                                );
-                            } else
-                                return cb({
-                                    status: "failure",
-                                    message:
-                                        "Something went wrong while disliking this song.",
-                                });
-                        }
-                    );
-                });
-            }
-        );
-    }),
-
-    /**
-     * Undislikes a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    undislike: hooks.loginRequired(async (session, songId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId }, next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("No song found with that id.");
-                    next(null, song);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_UNDISLIKE",
-                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                let oldSongId = songId;
-                songId = song._id;
-                userModel.findOne({ _id: session.userId }, (err, user) => {
-                    if (user.disliked.indexOf(songId) === -1)
-                        return cb({
-                            status: "failure",
-                            message: "You have not disliked this song.",
-                        });
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        { $pull: { liked: songId, disliked: songId } },
-                        (err) => {
-                            if (!err) {
-                                userModel.countDocuments(
-                                    { liked: songId },
-                                    (err, likes) => {
-                                        if (err)
-                                            return cb({
-                                                status: "failure",
-                                                message:
-                                                    "Something went wrong while undisliking this song.",
-                                            });
-                                        userModel.countDocuments(
-                                            { disliked: songId },
-                                            (err, dislikes) => {
-                                                if (err)
-                                                    return cb({
-                                                        status: "failure",
-                                                        message:
-                                                            "Something went wrong while undisliking this song.",
-                                                    });
-                                                songModel.update(
-                                                    { _id: songId },
-                                                    {
-                                                        $set: {
-                                                            likes: likes,
-                                                            dislikes: dislikes,
-                                                        },
-                                                    },
-                                                    (err) => {
-                                                        if (err)
-                                                            return cb({
-                                                                status:
-                                                                    "failure",
-                                                                message:
-                                                                    "Something went wrong while undisliking this song.",
-                                                            });
-                                                        songs.runJob(
-                                                            "UPDATE_SONG",
-                                                            { songId }
-                                                        );
-                                                        cache.runJob("PUB", {
-                                                            channel:
-                                                                "song.undislike",
-                                                            value: JSON.stringify(
-                                                                {
-                                                                    songId: oldSongId,
-                                                                    userId:
-                                                                        session.userId,
-                                                                    likes: likes,
-                                                                    dislikes: dislikes,
-                                                                }
-                                                            ),
-                                                        });
-                                                        return cb({
-                                                            status: "success",
-                                                            message:
-                                                                "You have successfully undisliked this song.",
-                                                        });
-                                                    }
-                                                );
-                                            }
-                                        );
-                                    }
-                                );
-                            } else
-                                return cb({
-                                    status: "failure",
-                                    message:
-                                        "Something went wrong while undisliking this song.",
-                                });
-                        }
-                    );
-                });
-            }
-        );
-    }),
-
-    /**
-     * Unlikes a song
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    unlike: hooks.loginRequired(async (session, songId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId }, next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("No song found with that id.");
-                    next(null, song);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_UNLIKE",
-                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                let oldSongId = songId;
-                songId = song._id;
-                userModel.findOne({ _id: session.userId }, (err, user) => {
-                    if (user.liked.indexOf(songId) === -1)
-                        return cb({
-                            status: "failure",
-                            message: "You have not liked this song.",
-                        });
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        { $pull: { liked: songId, disliked: songId } },
-                        (err) => {
-                            if (!err) {
-                                userModel.countDocuments(
-                                    { liked: songId },
-                                    (err, likes) => {
-                                        if (err)
-                                            return cb({
-                                                status: "failure",
-                                                message:
-                                                    "Something went wrong while unliking this song.",
-                                            });
-                                        userModel.countDocuments(
-                                            { disliked: songId },
-                                            (err, dislikes) => {
-                                                if (err)
-                                                    return cb({
-                                                        status: "failure",
-                                                        message:
-                                                            "Something went wrong while undiking this song.",
-                                                    });
-                                                songModel.updateOne(
-                                                    { _id: songId },
-                                                    {
-                                                        $set: {
-                                                            likes: likes,
-                                                            dislikes: dislikes,
-                                                        },
-                                                    },
-                                                    (err) => {
-                                                        if (err)
-                                                            return cb({
-                                                                status:
-                                                                    "failure",
-                                                                message:
-                                                                    "Something went wrong while unliking this song.",
-                                                            });
-                                                        songs.runJob(
-                                                            "UPDATE_SONG",
-                                                            { songId }
-                                                        );
-                                                        cache.runJob("PUB", {
-                                                            channel:
-                                                                "song.unlike",
-                                                            value: JSON.stringify(
-                                                                {
-                                                                    songId: oldSongId,
-                                                                    userId:
-                                                                        session.userId,
-                                                                    likes: likes,
-                                                                    dislikes: dislikes,
-                                                                }
-                                                            ),
-                                                        });
-                                                        return cb({
-                                                            status: "success",
-                                                            message:
-                                                                "You have successfully unliked this song.",
-                                                        });
-                                                    }
-                                                );
-                                            }
-                                        );
-                                    }
-                                );
-                            } else
-                                return cb({
-                                    status: "failure",
-                                    message:
-                                        "Something went wrong while unliking this song.",
-                                });
-                        }
-                    );
-                });
-            }
-        );
-    }),
-
-    /**
-     * Gets user's own song ratings
-     *
-     * @param session
-     * @param songId - the song id
-     * @param cb
-     */
-    getOwnSongRatings: hooks.loginRequired(async (session, songId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
-        async.waterfall(
-            [
-                (next) => {
-                    songModel.findOne({ songId }, next);
-                },
-
-                (song, next) => {
-                    if (!song) return next("No song found with that id.");
-                    next(null, song);
-                },
-            ],
-            async (err, song) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "SONGS_GET_OWN_RATINGS",
-                        `User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                let newSongId = song._id;
-                userModel.findOne(
-                    { _id: session.userId },
-                    async (err, user) => {
-                        if (!err && user) {
-                            return cb({
-                                status: "success",
-                                songId: songId,
-                                liked: user.liked.indexOf(newSongId) !== -1,
-                                disliked:
-                                    user.disliked.indexOf(newSongId) !== -1,
-                            });
-                        } else {
-                            return cb({
-                                status: "failure",
-                                message: await utils.runJob("GET_ERROR", {
-                                    error: err,
-                                }),
-                            });
-                        }
-                    }
-                );
-            }
-        );
-    }),
+CacheModule.runJob("SUB", {
+	channel: "song.unlike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.unliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.undislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.undisliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+export default {
+	/**
+	 * Returns the length of the songs list
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	length: isAdminRequired(async function length(session, status, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.countDocuments({ status }, next);
+				}
+			],
+			async (err, count) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_LENGTH",
+						`Failed to get length from songs that have the status ${status}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"SONGS_LENGTH",
+					`Got length from songs that have the status ${status} successfully.`
+				);
+				return cb({ status: "success", message: "Successfully got length of songs.", data: { length: count } });
+			}
+		);
+	}),
+
+	/**
+	 * Gets a set of songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param set - the set number to return
+	 * @param cb
+	 */
+	getSet: isAdminRequired(async function getSet(session, set, status, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel
+						.find({ status })
+						.skip(15 * (set - 1))
+						.limit(15)
+						.exec(next);
+				}
+			],
+			async (err, songs) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_SET",
+						`Failed to get set from songs that have the status ${status}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_GET_SET", `Got set from songs that have the status ${status} successfully.`);
+				return cb({ status: "success", message: "Successfully got set of songs.", data: { songs } });
+			}
+		);
+	}),
+
+	/**
+	 * Updates all songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	updateAll: isAdminRequired(async function length(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("UPDATE_ALL_SONGS", {}, this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_UPDATE_ALL", `Failed to update all songs. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_UPDATE_ALL", `Updated all songs successfully.`);
+				return cb({ status: "success", message: "Successfully updated all songs." });
+			}
+		);
+	}),
+
+	/**
+	 * Gets a song from the Musare song id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id
+	 * @param {Function} cb
+	 */
+	getSongFromSongId: isAdminRequired(function getSongFromSongId(session, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_SONG", { songId }, this)
+						.then(response => next(null, response.song))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_GET_SONG_FROM_MUSARE_ID", `Failed to get song ${songId}. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_GET_SONG_FROM_MUSARE_ID", `Got song ${songId} successfully.`);
+				return cb({ status: "success", data: { song } });
+			}
+		);
+	}),
+
+	/**
+	 * Updates a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id
+	 * @param {object} song - the updated song object
+	 * @param {Function} cb
+	 */
+	update: isAdminRequired(async function update(session, songId, song, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		let existingSong = null;
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ _id: songId }, next);
+				},
+
+				(_existingSong, next) => {
+					existingSong = _existingSong;
+					songModel.updateOne({ _id: songId }, song, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					SongsModule.runJob("UPDATE_SONG", { songId }, this)
+						.then(song => {
+							existingSong.genres
+								.concat(song.genres)
+								.filter((value, index, self) => self.indexOf(value) === index)
+								.forEach(genre => {
+									PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+										.then(() => {})
+										.catch(() => {});
+								});
+
+							next(null, song);
+						})
+						.catch(next);
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "SONGS_UPDATE", `Successfully updated song "${songId}".`);
+
+				if (song.status === "verified") {
+					CacheModule.runJob("PUB", {
+						channel: "song.updatedVerifiedSong",
+						value: song._id
+					});
+				} else if (song.status === "unverified") {
+					CacheModule.runJob("PUB", {
+						channel: "song.updatedUnverifiedSong",
+						value: song._id
+					});
+				} else if (song.status === "hidden") {
+					CacheModule.runJob("PUB", {
+						channel: "song.updatedHiddenSong",
+						value: song._id
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully updated",
+					data: { song }
+				});
+			}
+		);
+	}),
+
+	// /**
+	//  * Removes a song
+	//  *
+	//  * @param session
+	//  * @param songId - the song id
+	//  * @param cb
+	//  */
+	// remove: isAdminRequired(async function remove(session, songId, cb) {
+	// 	const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	// 	let song = null;
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				songModel.findOne({ _id: songId }, next);
+	// 			},
+
+	// 			(_song, next) => {
+	// 				song = _song;
+	// 				songModel.deleteOne({ _id: songId }, next);
+	// 			},
+
+	// 			(res, next) => {
+	// 				CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
+	// 					.then(() => {
+	// 						next();
+	// 					})
+	// 					.catch(next)
+	// 					.finally(() => {
+	// 						song.genres.forEach(genre => {
+	// 							PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+	// 								.then(() => {})
+	// 								.catch(() => {});
+	// 						});
+	// 					});
+	// 			}
+	// 		],
+	// 		async err => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+	// 				this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
+
+	// 				return cb({ status: "error", message: err });
+	// 			}
+
+	// 			this.log("SUCCESS", "SONGS_REMOVE", `Successfully removed song "${songId}".`);
+
+	// 			if (song.status === "verified") {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedVerifiedSong",
+	// 					value: songId
+	// 				});
+	// 			}
+	// 			if (song.status === "unverified") {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedUnverifiedSong",
+	// 					value: songId
+	// 				});
+	// 			}
+	// 			if (song.status === "hidden") {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedHiddenSong",
+	// 					value: songId
+	// 				});
+	// 			}
+
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Song has been successfully removed"
+	// 			});
+	// 		}
+	// 	);
+	// }),
+
+	/**
+	 * Searches through official songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} page - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchOfficial: isLoginRequired(async function searchOfficial(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					SongsModule.runJob("SEARCH", {
+						query,
+						includeVerified: true,
+						trimmed: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_SEARCH_OFFICIAL", `Searching songs failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_SEARCH_OFFICIAL", "Searching songs successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
+	/**
+	 * Requests a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} youtubeId - the youtube id of the song that gets requested
+	 * @param {string} returnSong - returns the simple song
+	 * @param {Function} cb - gets called with the result
+	 */
+	request: isLoginRequired(async function add(session, youtubeId, returnSong, cb) {
+		SongsModule.runJob("REQUEST_SONG", { youtubeId, userId: session.userId }, this)
+			.then(response => {
+				this.log(
+					"SUCCESS",
+					"SONGS_REQUEST",
+					`User "${session.userId}" successfully requested song "${youtubeId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully requested that song",
+					song: returnSong ? response.song : null
+				});
+			})
+			.catch(async _err => {
+				const err = await UtilsModule.runJob("GET_ERROR", { error: _err }, this);
+				this.log(
+					"ERROR",
+					"SONGS_REQUEST",
+					`Requesting song "${youtubeId}" failed for user ${session.userId}. "${err}"`
+				);
+				return cb({ status: "error", message: err, song: returnSong && _err.data ? _err.data.song : null });
+			});
+	}),
+
+	/**
+	 * Hides a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id of the song that gets hidden
+	 * @param {Function} cb - gets called with the result
+	 */
+	hide: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("HIDE_SONG", { songId }, this)
+			.then(() => {
+				this.log("SUCCESS", "SONGS_HIDE", `User "${session.userId}" successfully hid song "${songId}".`);
+				return cb({
+					status: "success",
+					message: "Successfully hid that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SONGS_HIDE", `Hiding song "${songId}" failed for user ${session.userId}. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Unhides a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id of the song that gets hidden
+	 * @param {Function} cb - gets called with the result
+	 */
+	unhide: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("UNHIDE_SONG", { songId }, this)
+			.then(() => {
+				this.log("SUCCESS", "SONGS_UNHIDE", `User "${session.userId}" successfully unhid song "${songId}".`);
+				return cb({
+					status: "success",
+					message: "Successfully unhid that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"SONGS_UNHIDE",
+					`Unhiding song "${songId}" failed for user ${session.userId}. "${err}"`
+				);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Verifies a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	verify: isAdminRequired(async function add(session, songId, cb) {
+		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					SongModel.findOne({ _id: songId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
+				},
+
+				(song, next) => {
+					const oldStatus = song.status;
+
+					song.verifiedBy = session.userId;
+					song.verifiedAt = Date.now();
+					song.status = "verified";
+
+					song.save(err => next(err, song, oldStatus));
+				},
+
+				(song, oldStatus, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+					next(null, song, oldStatus);
+				}
+			],
+			async (err, song, oldStatus) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_VERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "SONGS_VERIFY", `User "${session.userId}" successfully verified song "${songId}".`);
+
+				if (oldStatus === "hidden")
+					CacheModule.runJob("PUB", {
+						channel: "song.removedHiddenSong",
+						value: song._id
+					});
+
+				CacheModule.runJob("PUB", {
+					channel: "song.newVerifiedSong",
+					value: song._id
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "song.removedUnverifiedSong",
+					value: song._id
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been verified successfully."
+				});
+			}
+		);
+		// TODO Check if video is in queue and Add the song to the appropriate stations
+	}),
+
+	/**
+	 * Un-verifies a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	unverify: isAdminRequired(async function add(session, songId, cb) {
+		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					SongModel.findOne({ _id: songId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
+				},
+
+				(song, next) => {
+					song.status = "unverified";
+					song.save(err => {
+						next(err, song);
+					});
+				},
+
+				(song, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId });
+
+					next(null);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_UNVERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"SONGS_UNVERIFY",
+					`User "${session.userId}" successfully unverified song "${songId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "song.newUnverifiedSong",
+					value: songId
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "song.removedVerifiedSong",
+					value: songId
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been unverified successfully."
+				});
+			}
+		);
+		// TODO Check if video is in queue and Add the song to the appropriate stations
+	}),
+
+	/**
+	 * Requests a set of songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the YouTube playlist
+	 * @param {boolean} musicOnly - whether to only get music from the playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, returnSongs, cb) {
+		async.waterfall(
+			[
+				next => {
+					YouTubeModule.runJob(
+						"GET_PLAYLIST",
+						{
+							url,
+							musicOnly
+						},
+						this
+					)
+						.then(res => {
+							next(null, res.songs);
+						})
+						.catch(next);
+				},
+				(youtubeIds, next) => {
+					let successful = 0;
+					let songs = {};
+					let failed = 0;
+					let alreadyInDatabase = 0;
+
+					if (youtubeIds.length === 0) next();
+
+					async.eachOfLimit(
+						youtubeIds,
+						1,
+						(youtubeId, index, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "songs",
+									action: "request",
+									args: [youtubeId, returnSongs]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "success") successful += 1;
+									else failed += 1;
+									if (res.message === "This song is already in the database.") alreadyInDatabase += 1;
+									if (res.song) songs[index] = res.song;
+									else songs[index] = null;
+								})
+								.catch(() => {
+									failed += 1;
+								})
+								.finally(() => {
+									next();
+								});
+						},
+						() => {
+							if (returnSongs)
+								songs = Object.keys(songs)
+									.sort()
+									.map(key => songs[key]);
+
+							next(null, { successful, failed, alreadyInDatabase, songs });
+						}
+					);
+				}
+			],
+			async (err, response) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_SET",
+						`Importing a YouTube playlist to be requested failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"REQUEST_SET",
+					`Successfully imported a YouTube playlist to be requested for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+					songs: returnSongs ? response.songs : null
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Likes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	like: isLoginRequired(async function like(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }, next),
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, user.likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already liked this song.");
+								return next("Unable to add song to the 'Liked Songs' playlist.");
+							}
+
+							return next(null, song, user.dislikedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, dislikedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_LIKE",
+						`User "${session.userId}" failed to like song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.like",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__like",
+					payload: {
+						message: `Liked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully liked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Dislikes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	dislike: isLoginRequired(async function dislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ youtubeId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already disliked this song.");
+								return next("Unable to add song to the 'Disliked Songs' playlist.");
+							}
+
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_DISLIKE",
+						`User "${session.userId}" failed to dislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.dislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__dislike",
+					payload: {
+						message: `Disliked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully disliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Undislikes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	undislike: isLoginRequired(async function undislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ youtubeId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_UNDISLIKE",
+						`User "${session.userId}" failed to undislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.undislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__undislike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Disliked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully undisliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Unlikes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	unlike: isLoginRequired(async function unlike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ youtubeId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_UNLIKE",
+						`User "${session.userId}" failed to unlike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.unlike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__unlike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Liked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully unliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets song ratings
+	 *
+	 * @param session
+	 * @param songId - the Musare song id
+	 * @param cb
+	 */
+
+	getSongRatings: isLoginRequired(async function getSongRatings(session, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_SONG", { songId }, this)
+						.then(res => next(null, res.song))
+						.catch(next);
+				},
+
+				(song, next) => {
+					next(null, {
+						likes: song.likes,
+						dislikes: song.dislikes
+					});
+				}
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						likes,
+						dislikes
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets user's own song ratings
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+
+	getOwnSongRatings: isLoginRequired(async function getOwnSongRatings(session, youtubeId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }, next),
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null);
+				},
+
+				next =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Liked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Liked Songs' playlist does not exist.");
+
+							let isLiked = false;
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'liked songs' playlist
+								if (song.youtubeId === youtubeId) isLiked = true;
+							});
+
+							return next(null, isLiked);
+						}
+					),
+
+				(isLiked, next) =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Disliked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Disliked Songs' playlist does not exist.");
+
+							const ratings = { isLiked, isDisliked: false };
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'disliked songs' playlist
+								if (song.youtubeId === youtubeId) ratings.isDisliked = true;
+							});
+
+							return next(null, ratings);
+						}
+					)
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_OWN_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { isLiked, isDisliked } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						youtubeId,
+						liked: isLiked,
+						disliked: isDisliked
+					}
+				});
+			}
+		);
+	})
 };
 };

+ 3945 - 2354
backend/logic/actions/stations.js

@@ -1,2377 +1,3968 @@
-"use strict";
-
-const async = require("async"),
-    request = require("request"),
-    config = require("config"),
-    _ = require("underscore")._;
-
-const hooks = require("./hooks");
-
-const db = require("../db");
-const cache = require("../cache");
-const notifications = require("../notifications");
-const utils = require("../utils");
-const stations = require("../stations");
-const songs = require("../songs");
-const activities = require("../activities");
-
-// const logger = moduleManager.modules["logger"];
-
-let userList = {};
-let usersPerStation = {};
-let usersPerStationCount = {};
-
-// Temporarily disabled until the messages in console can be limited
-// setInterval(async () => {
-//     let stationsCountUpdated = [];
-//     let stationsUpdated = [];
-
-//     let oldUsersPerStation = usersPerStation;
-//     usersPerStation = {};
-
-//     let oldUsersPerStationCount = usersPerStationCount;
-//     usersPerStationCount = {};
-
-//     const userModel = await db.runJob("GET_MODEL", {
-//         modelName: "user",
-//     });
-//
-//     async.each(
-//         Object.keys(userList),
-//         function(socketId, next) {
-//             utils.runJob("SOCKET_FROM_SESSION", { socketId }).then((socket) => {
-//                 let stationId = userList[socketId];
-//                 if (
-//                     !socket ||
-//                     Object.keys(socket.rooms).indexOf(
-//                         `station.${stationId}`
-//                     ) === -1
-//                 ) {
-//                     if (stationsCountUpdated.indexOf(stationId) === -1)
-//                         stationsCountUpdated.push(stationId);
-//                     if (stationsUpdated.indexOf(stationId) === -1)
-//                         stationsUpdated.push(stationId);
-//                     delete userList[socketId];
-//                     return next();
-//                 }
-//                 if (!usersPerStationCount[stationId])
-//                     usersPerStationCount[stationId] = 0;
-//                 usersPerStationCount[stationId]++;
-//                 if (!usersPerStation[stationId])
-//                     usersPerStation[stationId] = [];
-
-//                 async.waterfall(
-//                     [
-//                         (next) => {
-//                             if (!socket.session || !socket.session.sessionId)
-//                                 return next("No session found.");
-//                             cache
-//                                 .runJob("HGET", {
-//                                     table: "sessions",
-//                                     key: socket.session.sessionId,
-//                                 })
-//                                 .then((session) => next(null, session))
-//                                 .catch(next);
-//                         },
-
-//                         (session, next) => {
-//                             if (!session) return next("Session not found.");
-//                             userModel.findOne({ _id: session.userId }, next);
-//                         },
-
-//                         (user, next) => {
-//                             if (!user) return next("User not found.");
-//                             if (
-//                                 usersPerStation[stationId].indexOf(
-//                                     user.username
-//                                 ) !== -1
-//                             )
-//                                 return next("User already in the list.");
-//                             next(null, user.username);
-//                         },
-//                     ],
-//                     (err, username) => {
-//                         if (!err) {
-//                             usersPerStation[stationId].push(username);
-//                         }
-//                         next();
-//                     }
-//                 );
-//             });
-//             //TODO Code to show users
-//         },
-//         (err) => {
-//             for (let stationId in usersPerStationCount) {
-//                 if (
-//                     oldUsersPerStationCount[stationId] !==
-//                     usersPerStationCount[stationId]
-//                 ) {
-//                     if (stationsCountUpdated.indexOf(stationId) === -1)
-//                         stationsCountUpdated.push(stationId);
-//                 }
-//             }
-
-//             for (let stationId in usersPerStation) {
-//                 if (
-//                     _.difference(
-//                         usersPerStation[stationId],
-//                         oldUsersPerStation[stationId]
-//                     ).length > 0 ||
-//                     _.difference(
-//                         oldUsersPerStation[stationId],
-//                         usersPerStation[stationId]
-//                     ).length > 0
-//                 ) {
-//                     if (stationsUpdated.indexOf(stationId) === -1)
-//                         stationsUpdated.push(stationId);
-//                 }
-//             }
-
-//             stationsCountUpdated.forEach((stationId) => {
-//                 //console.log("INFO", "UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
-//                 cache.runJob("PUB", {
-//                     table: "station.updateUserCount",
-//                     value: stationId,
-//                 });
-//             });
-
-//             stationsUpdated.forEach((stationId) => {
-//                 //console.log("INFO", "UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
-//                 cache.runJob("PUB", {
-//                     table: "station.updateUsers",
-//                     value: stationId,
-//                 });
-//             });
-
-//             //console.log("Userlist", usersPerStation);
-//         }
-//     );
-// }, 3000);
-
-cache.runJob("SUB", {
-    channel: "station.updateUsers",
-    cb: (stationId) => {
-        let list = usersPerStation[stationId] || [];
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${stationId}`,
-            args: ["event:users.updated", list],
-        });
-    },
+import async from "async";
+import mongoose from "mongoose";
+
+import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const SongsModule = moduleManager.modules.songs;
+const PlaylistsModule = moduleManager.modules.playlists;
+const CacheModule = moduleManager.modules.cache;
+const NotificationsModule = moduleManager.modules.notifications;
+const StationsModule = moduleManager.modules.stations;
+const ActivitiesModule = moduleManager.modules.activities;
+
+CacheModule.runJob("SUB", {
+	channel: "station.updateUsers",
+	cb: ({ stationId, usersPerStation }) => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.users.updated", { data: { users: usersPerStation } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.updateUserCount",
-    cb: (stationId) => {
-        let count = usersPerStationCount[stationId] || 0;
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${stationId}`,
-            args: ["event:userCount.updated", count],
-        });
-        stations.runJob("GET_STATION", { stationId }).then(async (station) => {
-            if (station.privacy === "public")
-                utils.runJob("EMIT_TO_ROOM", {
-                    room: "home",
-                    args: ["event:userCount.updated", stationId, count],
-                });
-            else {
-                let sockets = await utils.runJob("GET_ROOM_SOCKETS", {
-                    room: "home",
-                });
-                for (let socketId in sockets) {
-                    let socket = sockets[socketId];
-                    let session = sockets[socketId].session;
-                    if (session.sessionId) {
-                        cache
-                            .runJob("HGET", {
-                                table: "sessions",
-                                key: session.sessionId,
-                            })
-                            .then((session) => {
-                                if (session)
-                                    db.runJob("GET_MODEL", {
-                                        modelName: "user",
-                                    }).then((userModel) =>
-                                        userModel.findOne(
-                                            { _id: session.userId },
-                                            (err, user) => {
-                                                if (user.role === "admin")
-                                                    socket.emit(
-                                                        "event:userCount.updated",
-                                                        stationId,
-                                                        count
-                                                    );
-                                                else if (
-                                                    station.type ===
-                                                        "community" &&
-                                                    station.owner ===
-                                                        session.userId
-                                                )
-                                                    socket.emit(
-                                                        "event:userCount.updated",
-                                                        stationId,
-                                                        count
-                                                    );
-                                            }
-                                        )
-                                    );
-                            });
-                    }
-                }
-            }
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.updateUserCount",
+	cb: ({ stationId, usersPerStationCount }) => {
+		const count = usersPerStationCount || 0;
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.userCount.updated", { data: { userCount: count } }]
+		});
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(async station => {
+			if (station.privacy === "public")
+				WSModule.runJob("EMIT_TO_ROOM", {
+					room: "home",
+					args: ["event:station.userCount.updated", { data: { stationId, userCount: count } }]
+				});
+			else {
+				const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
+					room: "home"
+				});
+
+				sockets.forEach(async socketId => {
+					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (!socket) return;
+					const { session } = socket;
+
+					if (session.sessionId) {
+						CacheModule.runJob("HGET", {
+							table: "sessions",
+							key: session.sessionId
+						}).then(session => {
+							if (session)
+								DBModule.runJob(
+									"GET_MODEL",
+									{
+										modelName: "user"
+									},
+									this
+								).then(userModel =>
+									userModel.findOne({ _id: session.userId }, (err, user) => {
+										if (user.role === "admin")
+											socket.dispatch("event:station.userCount.updated", {
+												data: { stationId, count }
+											});
+										else if (station.type === "community" && station.owner === session.userId)
+											socket.dispatch("event:station.userCount.updated", {
+												data: { stationId, count }
+											});
+									})
+								);
+						});
+					}
+				});
+			}
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.queueLockToggled",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${data.stationId}`,
-            args: ["event:queueLockToggled", data.locked],
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.queueLockToggled",
+	cb: data => {
+		const { stationId, locked } = data;
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.queue.lock.toggled", { data: { locked } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:station.queue.lock.toggled", { data: { stationId, locked } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.updatePartyMode",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${data.stationId}`,
-            args: ["event:partyMode.updated", data.partyMode],
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.updatePartyMode",
+	cb: data => {
+		const { stationId, partyMode } = data;
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.partyMode.updated", { data: { partyMode } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.partyMode.updated", { data: { stationId, partyMode } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket => {
+					socket.dispatch("event:station.partyMode.updated", { data: { stationId, partyMode } });
+				});
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "privatePlaylist.selected",
-    cb: (data) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${data.stationId}`,
-            args: ["event:privatePlaylist.selected", data.playlistId],
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.newPlayMode",
+	cb: data => {
+		const { stationId, playMode } = data;
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.playMode.updated", { data: { playMode } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:station.playMode.updated", { data: { stationId, playMode } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.pause",
-    cb: (stationId) => {
-        stations.runJob("GET_STATION", { stationId }).then((station) => {
-            utils.runJob("EMIT_TO_ROOM", {
-                room: `station.${stationId}`,
-                args: ["event:stations.pause", { pausedAt: station.pausedAt }],
-            });
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.includedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+
+		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+				args: ["event:station.includedPlaylist", { data: { stationId, playlist } }]
+			})
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.resume",
-    cb: (stationId) => {
-        stations.runJob("GET_STATION", { stationId }).then((station) => {
-            utils.runJob("EMIT_TO_ROOM", {
-                room: `station.${stationId}`,
-                args: [
-                    "event:stations.resume",
-                    { timePaused: station.timePaused },
-                ],
-            });
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.excludedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+
+		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+				args: ["event:station.excludedPlaylist", { data: { stationId, playlist } }]
+			})
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.queueUpdate",
-    cb: (stationId) => {
-        stations.runJob("GET_STATION", { stationId }).then((station) => {
-            utils.runJob("EMIT_TO_ROOM", {
-                room: `station.${stationId}`,
-                args: ["event:queue.update", station.queue],
-            });
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.removedIncludedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.removedIncludedPlaylist", { data: { stationId, playlistId } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.voteSkipSong",
-    cb: (stationId) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${stationId}`,
-            args: ["event:song.voteSkipSong"],
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.removedExcludedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.removedExcludedPlaylist", { data: { stationId, playlistId } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.remove",
-    cb: (stationId) => {
-        utils.runJob("EMIT_TO_ROOM", {
-            room: `station.${stationId}`,
-            args: ["event:stations.remove"],
-        });
-        utils.runJob("EMIT_TO_ROOM", {
-            room: "admin.stations",
-            args: ["event:admin.station.removed", stationId],
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.pause",
+	cb: stationId => {
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.pause", { data: { pausedAt: station.pausedAt } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.pause", { data: { stationId, pausedAt: station.pausedAt } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket => {
+					socket.dispatch("event:station.pause", { data: { stationId } });
+				});
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "station.create",
-    cb: async (stationId) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-
-        stations
-            .runJob("INITIALIZE_STATION", { stationId })
-            .then(async (response) => {
-                const station = response.station;
-                station.userCount = usersPerStationCount[stationId] || 0;
-                utils.runJob("EMIT_TO_ROOM", {
-                    room: "admin.stations",
-                    args: ["event:admin.station.added", station],
-                });
-                // TODO If community, check if on whitelist
-                if (station.privacy === "public")
-                    utils.runJob("EMIT_TO_ROOM", {
-                        room: "home",
-                        args: ["event:stations.created", station],
-                    });
-                else {
-                    let sockets = await utils.runJob("GET_ROOM_SOCKETS", {
-                        room: "home",
-                    });
-                    for (let socketId in sockets) {
-                        let socket = sockets[socketId];
-                        let session = sockets[socketId].session;
-                        if (session.sessionId) {
-                            cache
-                                .runJob("HGET", {
-                                    table: "sessions",
-                                    key: session.sessionId,
-                                })
-                                .then((session) => {
-                                    if (session) {
-                                        userModel.findOne(
-                                            { _id: session.userId },
-                                            (err, user) => {
-                                                if (user.role === "admin")
-                                                    socket.emit(
-                                                        "event:stations.created",
-                                                        station
-                                                    );
-                                                else if (
-                                                    station.type ===
-                                                        "community" &&
-                                                    station.owner ===
-                                                        session.userId
-                                                )
-                                                    socket.emit(
-                                                        "event:stations.created",
-                                                        station
-                                                    );
-                                            }
-                                        );
-                                    }
-                                });
-                        }
-                    }
-                }
-            });
-    },
+CacheModule.runJob("SUB", {
+	channel: "station.resume",
+	cb: stationId => {
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.resume", { data: { timePaused: station.timePaused } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.resume", { data: { stationId, timePaused: station.timePaused } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			})
+				.then(response => {
+					const { socketsThatCan } = response;
+					socketsThatCan.forEach(socket => {
+						socket.dispatch("event:station.resume", { data: { stationId } });
+					});
+				})
+				.catch(console.log);
+		});
+	}
 });
 });
 
 
-module.exports = {
-    /**
-     * Get a list of all the stations
-     *
-     * @param session
-     * @param cb
-     * @return {{ status: String, stations: Array }}
-     */
-    index: (session, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    console.log(111);
-                    cache
-                        .runJob("HGETALL", { table: "stations" })
-                        .then((stations) => {
-                            next(null, stations);
-                        });
-                },
-
-                (stations, next) => {
-                    console.log(222);
-
-                    let resultStations = [];
-                    for (let id in stations) {
-                        resultStations.push(stations[id]);
-                    }
-                    next(null, stations);
-                },
-
-                (stationsArray, next) => {
-                    console.log(333);
-
-                    let resultStations = [];
-                    async.each(
-                        stationsArray,
-                        (station, next) => {
-                            async.waterfall(
-                                [
-                                    (next) => {
-                                        stations
-                                            .runJob("CAN_USER_VIEW_STATION", {
-                                                station,
-                                                userId: session.userId,
-                                            })
-                                            .then((exists) => {
-                                                console.log(444, exists);
-
-                                                next(null, exists);
-                                            })
-                                            .catch(next);
-                                    },
-                                ],
-                                (err, exists) => {
-                                    if (err) console.log(err);
-                                    station.userCount =
-                                        usersPerStationCount[station._id] || 0;
-                                    if (exists) resultStations.push(station);
-                                    next();
-                                }
-                            );
-                        },
-                        () => {
-                            next(null, resultStations);
-                        }
-                    );
-                },
-            ],
-            async (err, stations) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_INDEX",
-                        `Indexing stations failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_INDEX",
-                    `Indexing stations successful.`,
-                    false
-                );
-                return cb({ status: "success", stations: stations });
-            }
-        );
-    },
-
-    /**
-     * Obtains basic metadata of a station in order to format an activity
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    getStationForActivity: (session, stationId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-            ],
-            async (err, station) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_GET_STATION_FOR_ACTIVITY",
-                        `Failed to obtain metadata of station ${stationId} for activity formatting. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_GET_STATION_FOR_ACTIVITY",
-                        `Obtained metadata of station ${stationId} for activity formatting successfully.`
-                    );
-                    return cb({
-                        status: "success",
-                        data: {
-                            title: station.displayName,
-                            thumbnail: station.currentSong
-                                ? station.currentSong.thumbnail
-                                : "",
-                        },
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Verifies that a station exists
-     *
-     * @param session
-     * @param stationName - the station name
-     * @param cb
-     */
-    existsByName: (session, stationName, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION_BY_NAME", { stationName })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next(null, false);
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((exists) => next(null, exists))
-                        .catch(next);
-                },
-            ],
-            async (err, exists) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATION_EXISTS_BY_NAME",
-                        `Checking if station "${stationName}" exists failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATION_EXISTS_BY_NAME",
-                    `Station "${stationName}" exists successfully.` /*, false*/
-                );
-                cb({ status: "success", exists });
-            }
-        );
-    },
-
-    /**
-     * Gets the official playlist for a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    getPlaylist: (session, stationId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((canView) => {
-                            if (canView) return next(null, station);
-                            return next("Insufficient permissions.");
-                        })
-                        .catch((err) => {
-                            return next(err);
-                        });
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    else if (station.type !== "official")
-                        return next("This is not an official station.");
-                    else next();
-                },
-
-                (next) => {
-                    cache
-                        .runJob("HGET", {
-                            table: "officialPlaylists",
-                            key: stationId,
-                        })
-                        .then((playlist) => next(null, playlist))
-                        .catch(next);
-                },
-
-                (playlist, next) => {
-                    if (!playlist) return next("Playlist not found.");
-                    next(null, playlist);
-                },
-            ],
-            async (err, playlist) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_GET_PLAYLIST",
-                        `Getting playlist for station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_GET_PLAYLIST",
-                        `Got playlist for station "${stationId}" successfully.`,
-                        false
-                    );
-                    cb({ status: "success", data: playlist.songs });
-                }
-            }
-        );
-    },
-
-    /**
-     * Joins the station by its name
-     *
-     * @param session
-     * @param stationName - the station name
-     * @param cb
-     * @return {{ status: String, userCount: Integer }}
-     */
-    join: (session, stationName, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION_BY_NAME", { stationName })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((canView) => {
-                            if (!canView) next("Not allowed to join station.");
-                            else next(null, station);
-                        })
-                        .catch((err) => {
-                            return next(err);
-                        });
-                },
-
-                (station, next) => {
-                    utils.runJob("SOCKET_JOIN_ROOM", {
-                        socketId: session.socketId,
-                        room: `station.${station._id}`,
-                    });
-                    let data = {
-                        _id: station._id,
-                        type: station.type,
-                        currentSong: station.currentSong,
-                        startedAt: station.startedAt,
-                        paused: station.paused,
-                        timePaused: station.timePaused,
-                        pausedAt: station.pausedAt,
-                        description: station.description,
-                        displayName: station.displayName,
-                        privacy: station.privacy,
-                        locked: station.locked,
-                        partyMode: station.partyMode,
-                        owner: station.owner,
-                        privatePlaylist: station.privatePlaylist,
-                    };
-                    userList[session.socketId] = station._id;
-                    next(null, data);
-                },
-
-                (data, next) => {
-                    data = JSON.parse(JSON.stringify(data));
-                    data.userCount = usersPerStationCount[data._id] || 0;
-                    data.users = usersPerStation[data._id] || [];
-                    if (!data.currentSong || !data.currentSong.title)
-                        return next(null, data);
-                    utils.runJob("SOCKET_JOIN_SONG_ROOM", {
-                        socketId: session.socketId,
-                        room: `song.${data.currentSong.songId}`,
-                    });
-                    data.currentSong.skipVotes =
-                        data.currentSong.skipVotes.length;
-                    songs
-                        .runJob("GET_SONG_FROM_ID", {
-                            songId: data.currentSong.songId,
-                        })
-                        .then((response) => {
-                            const song = response.song;
-                            if (song) {
-                                data.currentSong.likes = song.likes;
-                                data.currentSong.dislikes = song.dislikes;
-                            } else {
-                                data.currentSong.likes = -1;
-                                data.currentSong.dislikes = -1;
-                            }
-                        })
-                        .catch((err) => {
-                            data.currentSong.likes = -1;
-                            data.currentSong.dislikes = -1;
-                        })
-                        .finally(() => {
-                            next(null, data);
-                        });
-                },
-            ],
-            async (err, data) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_JOIN",
-                        `Joining station "${stationName}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_JOIN",
-                    `Joined station "${data._id}" successfully.`
-                );
-                cb({ status: "success", data });
-            }
-        );
-    },
-
-    /**
-     * Toggles if a station is locked
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    toggleLock: hooks.ownerRequired(async (session, stationId, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    stationModel.updateOne(
-                        { _id: stationId },
-                        { $set: { locked: !station.locked } },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    stations
-                        .runJob("UPDATE_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-            ],
-            async (err, station) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_UPDATE_LOCKED_STATUS",
-                        `Toggling the queue lock for station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_LOCKED_STATUS",
-                        `Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "station.queueLockToggled",
-                        value: {
-                            stationId,
-                            locked: station.locked,
-                        },
-                    });
-                    return cb({ status: "success", data: station.locked });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Votes to skip a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    voteSkip: hooks.loginRequired(async (session, stationId, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-
-        let skipVotes = 0;
-        let shouldSkip = false;
-
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((canView) => {
-                            if (canView) return next(null, station);
-                            return next("Insufficient permissions.");
-                        })
-                        .catch((err) => {
-                            return next(err);
-                        });
-                },
-
-                (station, next) => {
-                    if (!station.currentSong)
-                        return next("There is currently no song to skip.");
-                    if (
-                        station.currentSong.skipVotes.indexOf(
-                            session.userId
-                        ) !== -1
-                    )
-                        return next(
-                            "You have already voted to skip this song."
-                        );
-                    next(null, station);
-                },
-
-                (station, next) => {
-                    stationModel.updateOne(
-                        { _id: stationId },
-                        { $push: { "currentSong.skipVotes": session.userId } },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    stations
-                        .runJob("UPDATE_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    next(null, station);
-                },
-
-                (station, next) => {
-                    skipVotes = station.currentSong.skipVotes.length;
-                    utils
-                        .runJob("GET_ROOM_SOCKETS", {
-                            room: `station.${stationId}`,
-                        })
-                        .then((sockets) => next(null, sockets))
-                        .catch(next);
-                },
-
-                (sockets, next) => {
-                    if (sockets.length <= skipVotes) shouldSkip = true;
-                    next();
-                },
-            ],
-            async (err, station) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_VOTE_SKIP",
-                        `Vote skipping station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_VOTE_SKIP",
-                    `Vote skipping "${stationId}" successful.`
-                );
-                cache.runJob("PUB", {
-                    channel: "station.voteSkipSong",
-                    value: stationId,
-                });
-                cb({
-                    status: "success",
-                    message: "Successfully voted to skip the song.",
-                });
-                if (shouldSkip) stations.runJob("SKIP_STATION", { stationId });
-            }
-        );
-    }),
-
-    /**
-     * Force skips a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    forceSkip: hooks.ownerRequired((session, stationId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    next();
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_FORCE_SKIP",
-                        `Force skipping station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                notifications.runJob("UNSCHEDULE", {
-                    name: `stations.nextSong?id=${stationId}`,
-                });
-                stations.runJob("SKIP_STATION", { stationId });
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_FORCE_SKIP",
-                    `Force skipped station "${stationId}" successfully.`
-                );
-                return cb({
-                    status: "success",
-                    message: "Successfully skipped station.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Leaves the user's current station
-     *
-     * @param session
-     * @param stationId
-     * @param cb
-     * @return {{ status: String, userCount: Integer }}
-     */
-    leave: (session, stationId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    next();
-                },
-            ],
-            async (err, userCount) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_LEAVE",
-                        `Leaving station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_LEAVE",
-                    `Left station "${stationId}" successfully.`
-                );
-                utils.runJob("SOCKET_LEAVE_ROOMS", { socketId: session });
-                delete userList[session.socketId];
-                return cb({
-                    status: "success",
-                    message: "Successfully left station.",
-                    userCount,
-                });
-            }
-        );
-    },
-
-    /**
-     * Updates a station's name
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newName - the new station name
-     * @param cb
-     */
-    updateName: hooks.ownerRequired(async (session, stationId, newName, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    stationModel.updateOne(
-                        { _id: stationId },
-                        { $set: { name: newName } },
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    stations
-                        .runJob("UPDATE_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_UPDATE_NAME",
-                        `Updating station "${stationId}" name to "${newName}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_UPDATE_NAME",
-                    `Updated station "${stationId}" name to "${newName}" successfully.`
-                );
-                return cb({
-                    status: "success",
-                    message: "Successfully updated the name.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Updates a station's display name
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newDisplayName - the new station display name
-     * @param cb
-     */
-    updateDisplayName: hooks.ownerRequired(
-        async (session, stationId, newDisplayName, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            { $set: { displayName: newDisplayName } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_UPDATE_DISPLAY_NAME",
-                            `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_DISPLAY_NAME",
-                        `Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully updated the display name.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a station's description
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newDescription - the new station description
-     * @param cb
-     */
-    updateDescription: hooks.ownerRequired(
-        async (session, stationId, newDescription, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            { $set: { description: newDescription } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_UPDATE_DESCRIPTION",
-                            `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_DESCRIPTION",
-                        `Updated station "${stationId}" description to "${newDescription}" successfully.`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully updated the description.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a station's privacy
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newPrivacy - the new station privacy
-     * @param cb
-     */
-    updatePrivacy: hooks.ownerRequired(
-        async (session, stationId, newPrivacy, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            { $set: { privacy: newPrivacy } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_UPDATE_PRIVACY",
-                            `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_PRIVACY",
-                        `Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully updated the privacy.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a station's genres
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newGenres - the new station genres
-     * @param cb
-     */
-    updateGenres: hooks.ownerRequired(
-        async (session, stationId, newGenres, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            { $set: { genres: newGenres } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_UPDATE_GENRES",
-                            `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_GENRES",
-                        `Updated station "${stationId}" genres to "${newGenres}" successfully.`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully updated the genres.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a station's blacklisted genres
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newBlacklistedGenres - the new station blacklisted genres
-     * @param cb
-     */
-    updateBlacklistedGenres: hooks.ownerRequired(
-        async (session, stationId, newBlacklistedGenres, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            {
-                                $set: {
-                                    blacklistedGenres: newBlacklistedGenres,
-                                },
-                            },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_UPDATE_BLACKLISTED_GENRES",
-                            `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_BLACKLISTED_GENRES",
-                        `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully updated the blacklisted genres.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a station's party mode
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param newPartyMode - the new station party mode
-     * @param cb
-     */
-    updatePartyMode: hooks.ownerRequired(
-        async (session, stationId, newPartyMode, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stations
-                            .runJob("GET_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-
-                    (station, next) => {
-                        if (!station) return next("Station not found.");
-                        if (station.partyMode === newPartyMode)
-                            return next(
-                                "The party mode was already " +
-                                    (newPartyMode ? "enabled." : "disabled.")
-                            );
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            { $set: { partyMode: newPartyMode } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_UPDATE_PARTY_MODE",
-                            `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_UPDATE_PARTY_MODE",
-                        `Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "station.updatePartyMode",
-                        value: {
-                            stationId: stationId,
-                            partyMode: newPartyMode,
-                        },
-                    });
-                    stations.runJob("SKIP_STATION", { stationId });
-                    return cb({
-                        status: "success",
-                        message: "Successfully updated the party mode.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Pauses a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    pause: hooks.ownerRequired(async (session, stationId, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    if (station.paused)
-                        return next("That station was already paused.");
-                    stationModel.updateOne(
-                        { _id: stationId },
-                        { $set: { paused: true, pausedAt: Date.now() } },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    stations
-                        .runJob("UPDATE_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_PAUSE",
-                        `Pausing station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_PAUSE",
-                    `Paused station "${stationId}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "station.pause",
-                    value: stationId,
-                });
-                notifications.runJob("UNSCHEDULE", {
-                    name: `stations.nextSong?id=${stationId}`,
-                });
-                return cb({
-                    status: "success",
-                    message: "Successfully paused.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Resumes a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    resume: hooks.ownerRequired(async (session, stationId, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    if (!station.paused)
-                        return next("That station is not paused.");
-                    station.timePaused += Date.now() - station.pausedAt;
-                    stationModel.updateOne(
-                        { _id: stationId },
-                        {
-                            $set: { paused: false },
-                            $inc: { timePaused: Date.now() - station.pausedAt },
-                        },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    stations
-                        .runJob("UPDATE_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_RESUME",
-                        `Resuming station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_RESUME",
-                    `Resuming station "${stationId}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "station.resume",
-                    value: stationId,
-                });
-                return cb({
-                    status: "success",
-                    message: "Successfully resumed.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Removes a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    remove: hooks.ownerRequired(async (session, stationId, cb) => {
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    stationModel.deleteOne({ _id: stationId }, (err) =>
-                        next(err)
-                    );
-                },
-
-                (next) => {
-                    cache
-                        .runJob("HDEL", { table: "stations", key: stationId })
-                        .then(next)
-                        .catch(next);
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_REMOVE",
-                        `Removing station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_REMOVE",
-                    `Removing station "${stationId}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "station.remove",
-                    value: stationId,
-                });
-                activities.runJob("ADD_ACTIVITY", {
-                    userId: session.userId,
-                    activityType: "deleted_station",
-                    payload: [stationId],
-                });
-                return cb({
-                    status: "success",
-                    message: "Successfully removed.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Create a station
-     *
-     * @param session
-     * @param data - the station data
-     * @param cb
-     */
-    create: hooks.loginRequired(async (session, data, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-
-        data.name = data.name.toLowerCase();
-        let blacklist = [
-            "country",
-            "edm",
-            "musare",
-            "hip-hop",
-            "rap",
-            "top-hits",
-            "todays-hits",
-            "old-school",
-            "christmas",
-            "about",
-            "support",
-            "staff",
-            "help",
-            "news",
-            "terms",
-            "privacy",
-            "profile",
-            "c",
-            "community",
-            "tos",
-            "login",
-            "register",
-            "p",
-            "official",
-            "o",
-            "trap",
-            "faq",
-            "team",
-            "donate",
-            "buy",
-            "shop",
-            "forums",
-            "explore",
-            "settings",
-            "admin",
-            "auth",
-            "reset_password",
-        ];
-        async.waterfall(
-            [
-                (next) => {
-                    if (!data) return next("Invalid data.");
-                    next();
-                },
-
-                (next) => {
-                    stationModel.findOne(
-                        {
-                            $or: [
-                                { name: data.name },
-                                {
-                                    displayName: new RegExp(
-                                        `^${data.displayName}$`,
-                                        "i"
-                                    ),
-                                },
-                            ],
-                        },
-                        next
-                    );
-                },
-
-                (station, next) => {
-                    if (station)
-                        return next(
-                            "A station with that name or display name already exists."
-                        );
-                    const {
-                        name,
-                        displayName,
-                        description,
-                        genres,
-                        playlist,
-                        type,
-                        blacklistedGenres,
-                    } = data;
-                    if (type === "official") {
-                        userModel.findOne(
-                            { _id: session.userId },
-                            (err, user) => {
-                                if (err) return next(err);
-                                if (!user) return next("User not found.");
-                                if (user.role !== "admin")
-                                    return next("Admin required.");
-                                stationModel.create(
-                                    {
-                                        name,
-                                        displayName,
-                                        description,
-                                        type,
-                                        privacy: "private",
-                                        playlist,
-                                        genres,
-                                        blacklistedGenres,
-                                        currentSong: stations.defaultSong,
-                                    },
-                                    next
-                                );
-                            }
-                        );
-                    } else if (type === "community") {
-                        if (blacklist.indexOf(name) !== -1)
-                            return next(
-                                "That name is blacklisted. Please use a different name."
-                            );
-                        stationModel.create(
-                            {
-                                name,
-                                displayName,
-                                description,
-                                type,
-                                privacy: "private",
-                                owner: session.userId,
-                                queue: [],
-                                currentSong: null,
-                            },
-                            next
-                        );
-                    }
-                },
-            ],
-            async (err, station) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_CREATE",
-                        `Creating station failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_CREATE",
-                    `Created station "${station._id}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "station.create",
-                    value: station._id,
-                });
-                activities.runJob("ADD_ACTIVITY", {
-                    userId: session.userId,
-                    activityType: "created_station",
-                    payload: [station._id],
-                });
-                return cb({
-                    status: "success",
-                    message: "Successfully created station.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Adds song to station queue
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param songId - the song id
-     * @param cb
-     */
-    addToQueue: hooks.loginRequired(async (session, stationId, songId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const stationModel = await db.runJob("GET_MODEL", {
-            modelName: "station",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    if (station.locked) {
-                        userModel.findOne(
-                            { _id: session.userId },
-                            (err, user) => {
-                                if (
-                                    user.role !== "admin" &&
-                                    station.owner !== session.userId
-                                )
-                                    return next(
-                                        "Only owners and admins can add songs to a locked queue."
-                                    );
-                                else return next(null, station);
-                            }
-                        );
-                    } else {
-                        return next(null, station);
-                    }
-                },
-
-                (station, next) => {
-                    if (station.type !== "community")
-                        return next("That station is not a community station.");
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((canView) => {
-                            if (canView) return next(null, station);
-                            return next("Insufficient permissions.");
-                        })
-                        .catch((err) => {
-                            return next(err);
-                        });
-                },
-
-                (station, next) => {
-                    if (
-                        station.currentSong &&
-                        station.currentSong.songId === songId
-                    )
-                        return next("That song is currently playing.");
-                    async.each(
-                        station.queue,
-                        (queueSong, next) => {
-                            if (queueSong.songId === songId)
-                                return next(
-                                    "That song is already in the queue."
-                                );
-                            next();
-                        },
-                        (err) => {
-                            next(err, station);
-                        }
-                    );
-                },
-
-                (station, next) => {
-                    // songs
-                    //     .runJob("GET_SONG", { id: songId })
-                    //     .then((song) => {
-                    //         if (song) return next(null, song, station);
-                    //         else {
-                    utils
-                        .runJob("GET_SONG_FROM_YOUTUBE", { songId })
-                        .then((response) => {
-                            const song = response.song;
-                            song.artists = [];
-                            song.skipDuration = 0;
-                            song.likes = -1;
-                            song.dislikes = -1;
-                            song.thumbnail = "empty";
-                            song.explicit = false;
-                            next(null, song, station);
-                        })
-                        .catch((err) => {
-                            next(err);
-                        });
-                    //     }
-                    // })
-                    // .catch((err) => {
-                    //     next(err);
-                    // });
-                },
-
-                (song, station, next) => {
-                    let queue = station.queue;
-                    song.requestedBy = session.userId;
-                    queue.push(song);
-
-                    let totalDuration = 0;
-                    queue.forEach((song) => {
-                        totalDuration += song.duration;
-                    });
-                    if (totalDuration >= 3600 * 3)
-                        return next("The max length of the queue is 3 hours.");
-                    next(null, song, station);
-                },
-
-                (song, station, next) => {
-                    let queue = station.queue;
-                    if (queue.length === 0) return next(null, song, station);
-                    let totalDuration = 0;
-                    const userId = queue[queue.length - 1].requestedBy;
-                    station.queue.forEach((song) => {
-                        if (userId === song.requestedBy) {
-                            totalDuration += song.duration;
-                        }
-                    });
-
-                    if (totalDuration >= 900)
-                        return next(
-                            "The max length of songs per user is 15 minutes."
-                        );
-                    next(null, song, station);
-                },
-
-                (song, station, next) => {
-                    let queue = station.queue;
-                    if (queue.length === 0) return next(null, song);
-                    let totalSongs = 0;
-                    const userId = queue[queue.length - 1].requestedBy;
-                    queue.forEach((song) => {
-                        if (userId === song.requestedBy) {
-                            totalSongs++;
-                        }
-                    });
-
-                    if (totalSongs <= 2) return next(null, song);
-                    if (totalSongs > 3)
-                        return next(
-                            "The max amount of songs per user is 3, and only 2 in a row is allowed."
-                        );
-                    if (
-                        queue[queue.length - 2].requestedBy !== userId ||
-                        queue[queue.length - 3] !== userId
-                    )
-                        return next(
-                            "The max amount of songs per user is 3, and only 2 in a row is allowed."
-                        );
-                    next(null, song);
-                },
-
-                (song, next) => {
-                    stationModel.updateOne(
-                        { _id: stationId },
-                        { $push: { queue: song } },
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    stations
-                        .runJob("UPDATE_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-            ],
-            async (err, station) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_ADD_SONG_TO_QUEUE",
-                        `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_ADD_SONG_TO_QUEUE",
-                    `Added song "${songId}" to station "${stationId}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "station.queueUpdate",
-                    value: stationId,
-                });
-                return cb({
-                    status: "success",
-                    message: "Successfully added song to queue.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Removes song from station queue
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param songId - the song id
-     * @param cb
-     */
-    removeFromQueue: hooks.ownerRequired(
-        async (session, stationId, songId, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (!songId) return next("Invalid song id.");
-                        stations
-                            .runJob("GET_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-
-                    (station, next) => {
-                        if (!station) return next("Station not found.");
-                        if (station.type !== "community")
-                            return next("Station is not a community station.");
-                        async.each(
-                            station.queue,
-                            (queueSong, next) => {
-                                if (queueSong.songId === songId)
-                                    return next(true);
-                                next();
-                            },
-                            (err) => {
-                                if (err === true) return next();
-                                next("Song is not currently in the queue.");
-                            }
-                        );
-                    },
-
-                    (next) => {
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            { $pull: { queue: { songId: songId } } },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err, station) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_REMOVE_SONG_TO_QUEUE",
-                            `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_REMOVE_SONG_TO_QUEUE",
-                        `Removed song "${songId}" from station "${stationId}" successfully.`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "station.queueUpdate",
-                        value: stationId,
-                    });
-                    return cb({
-                        status: "success",
-                        message: "Successfully removed song from queue.",
-                    });
-                }
-            );
-        }
-    ),
-
-    /**
-     * Gets the queue from a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param cb
-     */
-    getQueue: (session, stationId, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    if (station.type !== "community")
-                        return next("Station is not a community station.");
-                    next(null, station);
-                },
-
-                (station, next) => {
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((canView) => {
-                            if (canView) return next(null, station);
-                            return next("Insufficient permissions.");
-                        })
-                        .catch((err) => {
-                            return next(err);
-                        });
-                },
-            ],
-            async (err, station) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "STATIONS_GET_QUEUE",
-                        `Getting queue for station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "STATIONS_GET_QUEUE",
-                    `Got queue for station "${stationId}" successfully.`
-                );
-                return cb({
-                    status: "success",
-                    message: "Successfully got queue.",
-                    queue: station.queue,
-                });
-            }
-        );
-    },
-
-    /**
-     * Selects a private playlist for a station
-     *
-     * @param session
-     * @param stationId - the station id
-     * @param playlistId - the private playlist id
-     * @param cb
-     */
-    selectPrivatePlaylist: hooks.ownerRequired(
-        async (session, stationId, playlistId, cb) => {
-            const stationModel = await db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            const playlistModel = await db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stations
-                            .runJob("GET_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-
-                    (station, next) => {
-                        if (!station) return next("Station not found.");
-                        if (station.type !== "community")
-                            return next("Station is not a community station.");
-                        if (station.privatePlaylist === playlistId)
-                            return next(
-                                "That private playlist is already selected."
-                            );
-                        playlistModel.findOne({ _id: playlistId }, next);
-                    },
-
-                    (playlist, next) => {
-                        if (!playlist) return next("Playlist not found.");
-                        let currentSongIndex =
-                            playlist.songs.length > 0
-                                ? playlist.songs.length - 1
-                                : 0;
-                        stationModel.updateOne(
-                            { _id: stationId },
-                            {
-                                $set: {
-                                    privatePlaylist: playlistId,
-                                    currentSongIndex: currentSongIndex,
-                                },
-                            },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        stations
-                            .runJob("UPDATE_STATION", { stationId })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err, station) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "STATIONS_SELECT_PRIVATE_PLAYLIST",
-                            `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
-                        );
-                        return cb({ status: "failure", message: err });
-                    }
-                    console.log(
-                        "SUCCESS",
-                        "STATIONS_SELECT_PRIVATE_PLAYLIST",
-                        `Selected private playlist "${playlistId}" for station "${stationId}" successfully.`
-                    );
-                    notifications.runJob("UNSCHEDULE", {
-                        name: `stations.nextSong?id${stationId}`,
-                    });
-                    if (!station.partyMode)
-                        stations.runJob("SKIP_STATION", { stationId });
-                    cache.runJob("PUB", {
-                        channel: "privatePlaylist.selected",
-                        value: {
-                            playlistId,
-                            stationId,
-                        },
-                    });
-                    return cb({
-                        status: "success",
-                        message: "Successfully selected playlist.",
-                    });
-                }
-            );
-        }
-    ),
-
-    favoriteStation: hooks.loginRequired(async (session, stationId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    stations
-                        .runJob("GET_STATION", { stationId })
-                        .then((station) => next(null, station))
-                        .catch(next);
-                },
-
-                (station, next) => {
-                    if (!station) return next("Station not found.");
-                    stations
-                        .runJob("CAN_USER_VIEW_STATION", {
-                            station,
-                            userId: session.userId,
-                        })
-                        .then((canView) => {
-                            if (canView) return next();
-                            return next("Insufficient permissions.");
-                        })
-                        .catch((err) => {
-                            return next(err);
-                        });
-                },
-
-                (next) => {
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        { $addToSet: { favoriteStations: stationId } },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    if (res.nModified === 0)
-                        return next("The station was already favorited.");
-                    next();
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "FAVORITE_STATION",
-                        `Favoriting station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "FAVORITE_STATION",
-                    `Favorited station "${stationId}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "user.favoritedStation",
-                    value: {
-                        userId: session.userId,
-                        stationId,
-                    },
-                });
-                return cb({
-                    status: "success",
-                    message: "Succesfully favorited station.",
-                });
-            }
-        );
-    }),
-
-    unfavoriteStation: hooks.loginRequired(async (session, stationId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        { $pull: { favoriteStations: stationId } },
-                        next
-                    );
-                },
-
-                (res, next) => {
-                    if (res.nModified === 0)
-                        return next("The station wasn't favorited.");
-                    next();
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "UNFAVORITE_STATION",
-                        `Unfavoriting station "${stationId}" failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "UNFAVORITE_STATION",
-                    `Unfavorited station "${stationId}" successfully.`
-                );
-                cache.runJob("PUB", {
-                    channel: "user.unfavoritedStation",
-                    value: {
-                        userId: session.userId,
-                        stationId,
-                    },
-                });
-                return cb({
-                    status: "success",
-                    message: "Succesfully unfavorited station.",
-                });
-            }
-        );
-    }),
+CacheModule.runJob("SUB", {
+	channel: "station.privacyUpdate",
+	cb: response => {
+		const { stationId, previousPrivacy } = response;
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			if (previousPrivacy !== station.privacy) {
+				if (station.privacy === "public") {
+					// Station became public
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "home",
+						args: ["event:station.created", { data: { station } }]
+					});
+				} else if (previousPrivacy === "public") {
+					// Station became hidden
+
+					StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+						room: `home`,
+						station
+					}).then(response => {
+						const { socketsThatCan, socketsThatCannot } = response;
+						socketsThatCan.forEach(socket => {
+							socket.dispatch("event:station.privacy.updated", {
+								data: { stationId, privacy: station.privacy }
+							});
+						});
+						socketsThatCannot.forEach(socket => {
+							socket.dispatch("event:station.deleted", { data: { stationId } });
+						});
+					});
+				} else {
+					// Station was hidden and is still hidden
+
+					StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+						room: `home`,
+						station
+					}).then(response => {
+						const { socketsThatCan } = response;
+						socketsThatCan.forEach(socket => {
+							socket.dispatch("event:station.privacy.updated", {
+								data: { stationId, privacy: station.privacy }
+							});
+						});
+					});
+				}
+			}
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.privacy.updated", { data: { privacy: station.privacy } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.privacy.updated", { data: { stationId, privacy: station.privacy } }]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.nameUpdate",
+	cb: res => {
+		const { stationId, name } = res;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket =>
+					socket.dispatch("event:station.name.updated", { data: { stationId, name } })
+				);
+			});
+		});
+
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.name.updated", { data: { stationId, name } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.displayNameUpdate",
+	cb: response => {
+		const { stationId, displayName } = response;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket =>
+					socket.dispatch("event:station.displayName.updated", { data: { stationId, displayName } })
+				);
+			})
+		);
+
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.displayName.updated", { data: { stationId, displayName } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.descriptionUpdate",
+	cb: response => {
+		const { stationId, description } = response;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket =>
+					socket.dispatch("event:station.description.updated", { data: { stationId, description } })
+				);
+			})
+		);
+
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.description.updated", { data: { stationId, description } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.themeUpdate",
+	cb: res => {
+		const { stationId } = res;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.theme.updated", { data: { theme: station.theme } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.theme.updated", { data: { stationId, theme: station.theme } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(res => {
+				const { socketsThatCan } = res;
+				socketsThatCan.forEach(socket => {
+					socket.dispatch("event:station.theme.updated", { data: { stationId, theme: station.theme } });
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.queueUpdate",
+	cb: stationId => {
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.queue.updated", { data: { queue: station.queue } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.queue.updated", { data: { stationId, queue: station.queue } }]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.repositionSongInQueue",
+	cb: res => {
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${res.stationId}`, `manage-station.${res.stationId}`],
+			args: ["event:station.queue.song.repositioned", { data: { song: res.song } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.voteSkipSong",
+	cb: stationId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.voteSkipSong"]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.remove",
+	cb: stationId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.deleted"]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `home`,
+			args: ["event:station.deleted", { data: { stationId } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.stations",
+			args: ["event:admin.station.deleted", { data: { stationId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.create",
+	cb: async stationId => {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		StationsModule.runJob("INITIALIZE_STATION", { stationId }).then(async res => {
+			const { station } = res;
+			station.userCount = StationsModule.usersPerStationCount[stationId] || 0;
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.stations",
+				args: ["event:admin.station.created", { data: { station } }]
+			});
+
+			if (station.privacy === "public")
+				WSModule.runJob("EMIT_TO_ROOM", {
+					room: "home",
+					args: ["event:station.created", { data: { station } }]
+				});
+			else {
+				const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
+					room: "home"
+				});
+
+				sockets.forEach(async socketId => {
+					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (!socket) return;
+					const { session } = socket;
+
+					if (session.sessionId) {
+						CacheModule.runJob("HGET", {
+							table: "sessions",
+							key: session.sessionId
+						}).then(session => {
+							if (session) {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user.role === "admin")
+										socket.dispatch("event:station.created", { data: { station } });
+									else if (station.type === "community" && station.owner === session.userId)
+										socket.dispatch("event:station.created", { data: { station } });
+								});
+							}
+						});
+					}
+				});
+			}
+		});
+	}
+});
+
+export default {
+	/**
+	 * Get a list of all the stations
+	 *
+	 * @param {object} session - user session
+	 * @param {Function} cb - callback
+	 */
+	async index(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		async.waterfall(
+			[
+				// get array of the ids of the user's favorite stations
+				next => {
+					if (session.userId)
+						return userModel.findById(session.userId).select({ favoriteStations: -1 }).exec(next);
+					return next(null, { favoriteStations: [] });
+				},
+
+				({ favoriteStations }, next) => {
+					CacheModule.runJob("HGETALL", { table: "stations" }, this).then(stations =>
+						next(null, stations, favoriteStations)
+					);
+				},
+
+				(stations, favorited, next) => {
+					const filteredStations = [];
+
+					async.eachLimit(
+						stations,
+						1,
+						(station, nextStation) => {
+							async.waterfall(
+								[
+									callback => {
+										// only relevant if user logged in
+										if (session.userId) {
+											if (favorited.indexOf(station._id) !== -1) station.isFavorited = true;
+											return callback();
+										}
+
+										return callback();
+									},
+
+									callback => {
+										StationsModule.runJob(
+											"CAN_USER_VIEW_STATION",
+											{
+												station,
+												userId: session.userId,
+												hideUnlisted: true
+											},
+											this
+										)
+											.then(exists => callback(null, exists))
+											.catch(callback);
+									}
+								],
+								(err, exists) => {
+									if (err) return this.log("ERROR", "STATIONS_INDEX", err);
+
+									station.userCount = StationsModule.usersPerStationCount[station._id] || 0;
+
+									if (exists) filteredStations.push(station);
+									return nextStation();
+								}
+							);
+						},
+						() => next(null, filteredStations, favorited)
+					);
+				}
+			],
+			async (err, stations, favorited) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_INDEX", `Indexing stations failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_INDEX", `Indexing stations successful.`, false);
+
+				return cb({ status: "success", data: { stations, favorited } });
+			}
+		);
+	},
+
+	/**
+	 * Obtains basic metadata of a station in order to format an activity
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getStationForActivity(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_STATION_FOR_ACTIVITY",
+						`Failed to obtain metadata of station ${stationId} for activity formatting. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_GET_STATION_FOR_ACTIVITY",
+					`Obtained metadata of station ${stationId} for activity formatting successfully.`
+				);
+				return cb({
+					status: "success",
+					data: {
+						title: station.displayName,
+						thumbnail: station.currentSong ? station.currentSong.thumbnail : ""
+					}
+				});
+			}
+		);
+	},
+
+	/**
+	 * Verifies that a station exists from its name
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationName - the station name
+	 * @param {Function} cb - callback
+	 */
+	existsByName(session, stationName, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION_BY_NAME", { stationName }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next(null, false);
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(exists => next(null, exists))
+						.catch(next);
+				}
+			],
+			async (err, exists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATION_EXISTS_BY_NAME",
+						`Checking if station "${stationName}" exists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATION_EXISTS_BY_NAME",
+					`Station "${stationName}" exists successfully.` /* , false */
+				);
+
+				return cb({ status: "success", data: { exists } });
+			}
+		);
+	},
+
+	/**
+	 * Verifies that a station exists from its id
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	existsById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next(null, false);
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(exists => next(null, exists))
+						.catch(next);
+				}
+			],
+			async (err, exists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATION_EXISTS_BY_ID",
+						`Checking if station "${stationId}" exists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATION_EXISTS_BY_ID",
+					`Station "${stationId}" exists successfully.` /* , false */
+				);
+
+				return cb({ status: "success", data: { exists } });
+			}
+		);
+	},
+
+	/**
+	 * Gets the official playlist for a station
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getPlaylist(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type !== "official") return next("This is not an official station.");
+					return next();
+				},
+
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "officialPlaylists",
+							key: stationId
+						},
+						this
+					)
+						.then(playlist => {
+							next(null, playlist);
+						})
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found.");
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_PLAYLIST",
+						`Getting playlist for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_GET_PLAYLIST",
+					`Got playlist for station "${stationId}" successfully.`,
+					false
+				);
+
+				return cb({ status: "success", data: { songs: playlist.songs } });
+			}
+		);
+	},
+
+	/**
+	 * Joins the station by its name
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationIdentifier - the station name or station id
+	 * @param {Function} cb - callback
+	 */
+	join(session, stationIdentifier, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION_BY_NAME", { stationName: stationIdentifier }, this)
+						.then(station => next(null, station))
+						.catch(() =>
+							// station identifier may be using stationid instead
+							StationsModule.runJob("GET_STATION", { stationId: stationIdentifier }, this)
+								.then(station => next(null, station))
+								.catch(next)
+						);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (!canView) next("Not allowed to join station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					WSModule.runJob("SOCKET_JOIN_ROOM", {
+						socketId: session.socketId,
+						room: `station.${station._id}`
+					});
+
+					const data = {
+						_id: station._id,
+						type: station.type,
+						currentSong: station.currentSong,
+						startedAt: station.startedAt,
+						paused: station.paused,
+						timePaused: station.timePaused,
+						pausedAt: station.pausedAt,
+						description: station.description,
+						displayName: station.displayName,
+						name: station.name,
+						privacy: station.privacy,
+						locked: station.locked,
+						partyMode: station.partyMode,
+						playMode: station.playMode,
+						owner: station.owner,
+						includedPlaylists: station.includedPlaylists,
+						excludedPlaylists: station.excludedPlaylists,
+						theme: station.theme
+					};
+
+					StationsModule.userList[session.socketId] = station._id;
+
+					next(null, data);
+				},
+
+				(data, next) => {
+					data = JSON.parse(JSON.stringify(data));
+
+					data.userCount = StationsModule.usersPerStationCount[data._id] || 0;
+					data.users = StationsModule.usersPerStation[data._id] || [];
+
+					if (!data.currentSong || !data.currentSong.title) return next(null, data);
+
+					WSModule.runJob("SOCKET_JOIN_SONG_ROOM", {
+						socketId: session.socketId,
+						room: `song.${data.currentSong.youtubeId}`
+					});
+
+					data.currentSong.skipVotes = data.currentSong.skipVotes.length;
+
+					return SongsModule.runJob(
+						"GET_SONG_FROM_YOUTUBE_ID",
+						{ youtubeId: data.currentSong.youtubeId },
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							if (song) {
+								data.currentSong.likes = song.likes;
+								data.currentSong.dislikes = song.dislikes;
+							} else {
+								data.currentSong.likes = -1;
+								data.currentSong.dislikes = -1;
+							}
+						})
+						.catch(() => {
+							data.currentSong.likes = -1;
+							data.currentSong.dislikes = -1;
+						})
+						.finally(() => next(null, data));
+				},
+
+				(data, next) => {
+					// only relevant if user logged in
+					if (session.userId) {
+						return StationsModule.runJob(
+							"HAS_USER_FAVORITED_STATION",
+							{
+								userId: session.userId,
+								stationId: data._id
+							},
+							this
+						)
+							.then(isStationFavorited => {
+								data.isFavorited = isStationFavorited;
+								return next(null, data);
+							})
+							.catch(err => next(err));
+					}
+
+					return next(null, data);
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_JOIN", `Joining station "${stationIdentifier}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_JOIN", `Joined station "${data._id}" successfully.`);
+				return cb({ status: "success", data });
+			}
+		);
+	},
+
+	/**
+	 * Gets a station by id
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getStationById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const data = {
+						_id: station._id,
+						type: station.type,
+						description: station.description,
+						displayName: station.displayName,
+						name: station.name,
+						privacy: station.privacy,
+						locked: station.locked,
+						partyMode: station.partyMode,
+						playMode: station.playMode,
+						owner: station.owner,
+						theme: station.theme,
+						paused: station.paused,
+						currentSong: station.currentSong
+					};
+
+					next(null, data);
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_STATION_BY_ID", `Getting station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "GET_STATION_BY_ID", `Got station "${stationId}" successfully.`);
+				return cb({ status: "success", data: { station: data } });
+			}
+		);
+	},
+
+	getStationIncludedPlaylistsById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const playlists = [];
+
+					async.eachLimit(
+						station.includedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									playlists.push(playlist);
+									next();
+								})
+								.catch(() => {
+									playlists.push(null);
+									next();
+								});
+						},
+						err => {
+							next(err, playlists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_INCLUDED_PLAYLISTS_BY_ID",
+						`Getting station "${stationId}"'s included playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_INCLUDED_PLAYLISTS_BY_ID",
+					`Got station "${stationId}"'s included playlists successfully.`
+				);
+				return cb({ status: "success", data: { playlists } });
+			}
+		);
+	},
+
+	getStationExcludedPlaylistsById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const playlists = [];
+
+					async.eachLimit(
+						station.excludedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									playlists.push(playlist);
+									next();
+								})
+								.catch(() => {
+									playlists.push(null);
+									next();
+								});
+						},
+						err => {
+							next(err, playlists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_EXCLUDED_PLAYLISTS_BY_ID",
+						`Getting station "${stationId}"'s excluded playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_EXCLUDED_PLAYLISTS_BY_ID",
+					`Got station "${stationId}"'s excluded playlists successfully.`
+				);
+				return cb({ status: "success", data: { playlists } });
+			}
+		);
+	},
+
+	/**
+	 * Toggles if a station is locked
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	toggleLock: isOwnerRequired(async function toggleLock(session, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					stationModel.updateOne({ _id: stationId }, { $set: { locked: !station.locked } }, next);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_LOCKED_STATUS",
+						`Toggling the queue lock for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_LOCKED_STATUS",
+					`Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`
+				);
+				CacheModule.runJob("PUB", {
+					channel: "station.queueLockToggled",
+					value: {
+						stationId,
+						locked: station.locked
+					}
+				});
+				return cb({ status: "success", data: { locked: station.locked } });
+			}
+		);
+	}),
+
+	/**
+	 * Votes to skip a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	voteSkip: isLoginRequired(async function voteSkip(session, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		let skipVotes = 0;
+		let shouldSkip = false;
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (!station.currentSong) return next("There is currently no song to skip.");
+					if (station.currentSong.skipVotes.indexOf(session.userId) !== -1)
+						return next("You have already voted to skip this song.");
+					return next(null, station);
+				},
+
+				(station, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { "currentSong.skipVotes": session.userId } },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next(null, station);
+				},
+
+				(station, next) => {
+					skipVotes = station.currentSong.skipVotes.length;
+					WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${stationId}` }, this)
+						.then(sockets => next(null, sockets))
+						.catch(next);
+				},
+
+				(sockets, next) => {
+					if (sockets.length <= skipVotes) {
+						shouldSkip = true;
+						return next();
+					}
+
+					const users = [];
+
+					return async.each(
+						sockets,
+						(socketId, next) => {
+							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
+								.then(socket => {
+									if (socket && socket.session && socket.session.userId) {
+										if (!users.includes(socket.session.userId)) users.push(socket.session.userId);
+									}
+									return next();
+								})
+								.catch(next);
+						},
+						err => {
+							if (err) return next(err);
+
+							if (users.length <= skipVotes) shouldSkip = true;
+							return next();
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_VOTE_SKIP", `Vote skipping "${stationId}" successful.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.voteSkipSong",
+					value: stationId
+				});
+
+				if (shouldSkip) {
+					StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				}
+
+				return cb({
+					status: "success",
+					message: "Successfully voted to skip the song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Force skips a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	forceSkip: isOwnerRequired(function forceSkip(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next();
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				this.log("SUCCESS", "STATIONS_FORCE_SKIP", `Force skipped station "${stationId}" successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully skipped station."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Leaves the user's current station
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - id of station to leave
+	 * @param {Function} cb - callback
+	 */
+	leave(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next();
+				}
+			],
+			async (err, userCount) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_LEAVE", `Left station "${stationId}" successfully.`);
+
+				WSModule.runJob("SOCKET_LEAVE_ROOM", { socketId: session.socketId, room: `station.${stationId}` });
+				WSModule.runJob("SOCKETS_LEAVE_SONG_ROOMS", { sockets: [session.socketId] });
+
+				delete StationsModule.userList[session.socketId];
+
+				return cb({
+					status: "success",
+					message: "Successfully left station.",
+					data: { userCount }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Updates a station's name
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newName - the new station name
+	 * @param cb
+	 */
+	updateName: isOwnerRequired(async function updateName(session, stationId, newName, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { name: newName } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_NAME",
+						`Updating station "${stationId}" name to "${newName}" failed. "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_NAME",
+					`Updated station "${stationId}" name to "${newName}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.nameUpdate",
+					value: { stationId, name: newName }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_name",
+					payload: {
+						message: `Changed name of station <stationId>${station.displayName}</stationId> to ${newName}`,
+						stationId
+					}
+				});
+
+				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: isOwnerRequired(async function updateDisplayName(session, stationId, newDisplayName, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { displayName: newDisplayName } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					playlistModel.updateOne(
+						{ _id: station.playlist },
+						{ $set: { displayName: `Station - ${station.displayName}` } },
+						err => {
+							next(err, station);
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_DISPLAY_NAME",
+						`Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_DISPLAY_NAME",
+					`Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.displayNameUpdate",
+					value: { stationId, displayName: newDisplayName }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_display_name",
+					payload: {
+						message: `Changed display name of station <stationId>${newDisplayName}</stationId>`,
+						stationId
+					}
+				});
+
+				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: isOwnerRequired(async function updateDescription(session, stationId, newDescription, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { description: newDescription } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_DESCRIPTION",
+						`Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_DESCRIPTION",
+					`Updated station "${stationId}" description to "${newDescription}" successfully.`
+				);
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_description",
+					payload: {
+						message: `Changed description of station <stationId>${station.displayName}</stationId> to ${newDescription}`,
+						stationId
+					}
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "station.descriptionUpdate",
+					value: { stationId, description: newDescription }
+				});
+
+				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: isOwnerRequired(async function updatePrivacy(session, stationId, newPrivacy, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		let previousPrivacy = null;
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.findOne({ _id: stationId }, next);
+				},
+
+				(station, next) => {
+					if (!station) next("No station found.");
+					else {
+						previousPrivacy = station.privacy;
+						next();
+					}
+				},
+
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { privacy: newPrivacy } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_PRIVACY",
+						`Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_PRIVACY",
+					`Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.privacyUpdate",
+					value: { stationId, previousPrivacy }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_privacy",
+					payload: {
+						message: `Changed privacy of station <stationId>${station.displayName}</stationId> to ${newPrivacy}`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the privacy."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newGenres - the new station genres
+	 * @param cb
+	 */
+	updateGenres: isOwnerRequired(async function updateGenres(session, stationId, newGenres, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					const playlists = [];
+					async.eachLimit(
+						newGenres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre, includeSongs: false }, this)
+								.then(response => {
+									playlists.push(response.playlist);
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found")
+										next(
+											`The genre playlist for "${genre}" was not found. Please ensure that this genre playlist exists.`
+										);
+									else next(err);
+								});
+						},
+						err => {
+							next(
+								err,
+								station,
+								playlists.map(playlist => playlist._id.toString())
+							);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromExcluded = playlists.filter(
+						playlistId => station.excludedPlaylists.indexOf(playlistId) !== -1
+					);
+					console.log(
+						`playlistsToRemoveFromExcluded: ${playlistsToRemoveFromExcluded.length}`,
+						playlistsToRemoveFromExcluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromIncluded = station.includedPlaylists.filter(
+						playlistId => playlists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToRemoveFromIncluded: ${playlistsToRemoveFromIncluded.length}`,
+						playlistsToRemoveFromIncluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToAddToIncluded = playlists.filter(
+						playlistId => station.includedPlaylists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToAddToIncluded: ${playlistsToAddToIncluded.length}`,
+						playlistsToAddToIncluded
+					);
+
+					async.eachLimit(
+						playlistsToAddToIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err });
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_GENRES",
+						`Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_GENRES",
+					`Updated station "${stationId}" genres to "${newGenres}" successfully.`
+				);
+
+				if (newGenres.length > 0) {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_genres",
+						payload: {
+							message: `Updated genres of station <stationId>${station.displayName}</stationId> to 
+							${newGenres.join(", ")}`,
+							stationId
+						}
+					});
+				} else {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_genres",
+						payload: {
+							message: `Removed all genres of station <stationId>${station.displayName}</stationId>`,
+							stationId
+						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the genres."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's blacklisted genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newBlacklistedGenres - the new station blacklisted genres
+	 * @param cb
+	 */
+	updateBlacklistedGenres: isOwnerRequired(async function updateBlacklistedGenres(
+		session,
+		stationId,
+		newBlacklistedGenres,
+		cb
+	) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					const playlists = [];
+					async.eachLimit(
+						newBlacklistedGenres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre, includeSongs: false }, this)
+								.then(response => {
+									playlists.push(response.playlist);
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found")
+										next(
+											`The genre playlist for "${genre}" was not found. Please ensure that this genre playlist exists.`
+										);
+									else next(err);
+								});
+						},
+						err => {
+							next(
+								err,
+								station,
+								playlists.map(playlist => playlist._id.toString())
+							);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromIncluded = playlists.filter(
+						playlistId => station.includedPlaylists.indexOf(playlistId) !== -1
+					);
+					console.log(
+						`playlistsToRemoveFromIncluded: ${playlistsToRemoveFromIncluded.length}`,
+						playlistsToRemoveFromIncluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromExcluded = station.excludedPlaylists.filter(
+						playlistId => playlists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToRemoveFromExcluded: ${playlistsToRemoveFromExcluded.length}`,
+						playlistsToRemoveFromExcluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToAddToExcluded = playlists.filter(
+						playlistId => station.excludedPlaylists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToAddToExcluded: ${playlistsToAddToExcluded.length}`,
+						playlistsToAddToExcluded
+					);
+
+					async.eachLimit(
+						playlistsToAddToExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_BLACKLISTED_GENRES",
+						`Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_BLACKLISTED_GENRES",
+					`Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`
+				);
+
+				if (newBlacklistedGenres.length > 0) {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_blacklisted_genres",
+						payload: {
+							message: `Updated blacklisted genres of station <stationId>${
+								station.displayName
+							}</stationId> to ${newBlacklistedGenres.join(", ")}`,
+							stationId
+						}
+					});
+				} else {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_blacklisted_genres",
+						payload: {
+							message: `Removed all blacklisted genres of station <stationId>${station.displayName}</stationId>`,
+							stationId
+						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the blacklisted genres."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's party mode
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newPartyMode - the new station party mode
+	 * @param cb
+	 */
+	updatePartyMode: isOwnerRequired(async function updatePartyMode(session, stationId, newPartyMode, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.partyMode === newPartyMode)
+						return next(`The party mode was already ${newPartyMode ? "enabled." : "disabled."}`);
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { partyMode: newPartyMode, queue: [] } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "station.queueUpdate",
+						value: stationId
+					})
+						.then(() => {})
+						.catch(next);
+
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_PARTY_MODE",
+						`Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_PARTY_MODE",
+					`Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.updatePartyMode",
+					value: {
+						stationId,
+						partyMode: newPartyMode
+					}
+				});
+
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the party mode."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's play mode
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newPlayMode - the new station play mode
+	 * @param cb
+	 */
+	updatePlayMode: isOwnerRequired(async function updatePartyMode(session, stationId, newPlayMode, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.newPlayMode === newPlayMode) return next(`The play mode was already ${newPlayMode}`);
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { playMode: newPlayMode, queue: [] } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "station.queueUpdate",
+						value: stationId
+					})
+						.then()
+						.catch();
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_PLAY_MODE",
+						`Updating station "${stationId}" play mode to "${newPlayMode}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_PLAY_MODE",
+					`Updated station "${stationId}" play mode to "${newPlayMode}" successfully.`
+				);
+				CacheModule.runJob("PUB", {
+					channel: "station.newPlayMode",
+					value: {
+						stationId,
+						playMode: newPlayMode
+					}
+				});
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				return cb({
+					status: "success",
+					message: "Successfully updated the play mode."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's theme
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newTheme - the new station theme
+	 * @param cb
+	 */
+	updateTheme: isOwnerRequired(async function updateTheme(session, stationId, newTheme, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.theme === newTheme) return next("No change in theme.");
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { theme: newTheme } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_THEME",
+						`Updating station "${stationId}" theme to "${newTheme}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_THEME",
+					`Updated station "${stationId}" theme to "${newTheme}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.themeUpdate",
+					value: { stationId }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_theme",
+					payload: {
+						message: `Changed theme of station <stationId>${station.displayName}</stationId> to ${newTheme}`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the theme."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Pauses a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	pause: isOwnerRequired(async function pause(session, stationId, cb) {
+		const stationModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "station"
+			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.paused) return next("That station was already paused.");
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { paused: true, pausedAt: Date.now() } },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_PAUSE", `Paused station "${stationId}" successfully.`);
+				CacheModule.runJob("PUB", {
+					channel: "station.pause",
+					value: stationId
+				});
+				NotificationsModule.runJob("UNSCHEDULE", {
+					name: `stations.nextSong?id=${stationId}`
+				});
+				return cb({
+					status: "success",
+					message: "Successfully paused."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Resumes a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	resume: isOwnerRequired(async function resume(session, stationId, cb) {
+		const stationModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "station"
+			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (!station.paused) return next("That station is not paused.");
+					station.timePaused += Date.now() - station.pausedAt;
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{
+							$set: { paused: false },
+							$inc: { timePaused: Date.now() - station.pausedAt }
+						},
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_RESUME", `Resuming station "${stationId}" successfully.`);
+				CacheModule.runJob("PUB", {
+					channel: "station.resume",
+					value: stationId
+				});
+				return cb({
+					status: "success",
+					message: "Successfully resumed."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	remove: isOwnerRequired(async function remove(session, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.findById(stationId, (err, station) => {
+						if (err) return next(err);
+						return next(null, station);
+					});
+				},
+
+				(station, next) => {
+					stationModel.deleteOne({ _id: stationId }, err => next(err, station));
+				},
+
+				(station, next) => {
+					CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this)
+						.then(() => next(null, station))
+						.catch(next);
+				},
+
+				// remove the playlist for the station
+				(station, next) => {
+					if (station.playlist)
+						PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist })
+							.then(() => {})
+							.catch(next);
+					next(null, station);
+				},
+
+				// remove reference to the station id in any array of a user's favorite stations
+				(station, next) => {
+					userModel.updateMany(
+						{ favoriteStations: stationId },
+						{ $pull: { favoriteStations: stationId } },
+						err => next(err, station)
+					);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_REMOVE", `Removing station "${stationId}" successfully.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.remove",
+					value: stationId
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__remove",
+					payload: { message: `Removed a station named ${station.displayName}` }
+				});
+
+				ActivitiesModule.runJob("REMOVE_ACTIVITY_REFERENCES", { type: "stationId", stationId });
+
+				return cb({
+					status: "success",
+					message: "Successfully removed."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Create a station
+	 *
+	 * @param session
+	 * @param data - the station data
+	 * @param cb
+	 */
+	create: isLoginRequired(async function create(session, data, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		data.name = data.name.toLowerCase();
+
+		const 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",
+			"backend",
+			"api",
+			"songs",
+			"playlists",
+			"playlist"
+		];
+
+		async.waterfall(
+			[
+				next => {
+					if (!data) return next("Invalid data.");
+					return next();
+				},
+
+				next => {
+					stationModel.findOne(
+						{
+							$or: [{ name: data.name }, { displayName: new RegExp(`^${data.displayName}$`, "i") }]
+						},
+						next
+					);
+				},
+
+				// eslint-disable-next-line consistent-return
+				(station, next) => {
+					this.log(station);
+
+					if (station) return next("A station with that name or display name already exists.");
+					const { name, displayName, description, /* playlist, */ type, genres, blacklistedGenres } = data;
+					const stationId = mongoose.Types.ObjectId();
+
+					if (type === "official") {
+						return userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (err) return next(err);
+							if (!user) return next("User not found.");
+							if (user.role !== "admin") return next("Admin required.");
+
+							return async.waterfall(
+								[
+									next => {
+										const playlists = [];
+										async.eachLimit(
+											genres,
+											1,
+											(genre, next) => {
+												PlaylistsModule.runJob(
+													"GET_GENRE_PLAYLIST",
+													{ genre, includeSongs: false },
+													this
+												)
+													.then(response => {
+														playlists.push(response.playlist);
+														next();
+													})
+													.catch(err => {
+														next(
+															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
+														);
+													});
+											},
+											err => {
+												next(
+													err,
+													playlists.map(playlist => playlist._id.toString())
+												);
+											}
+										);
+									},
+
+									(genrePlaylistIds, next) => {
+										const playlists = [];
+										async.eachLimit(
+											blacklistedGenres,
+											1,
+											(genre, next) => {
+												PlaylistsModule.runJob(
+													"GET_GENRE_PLAYLIST",
+													{ genre, includeSongs: false },
+													this
+												)
+													.then(response => {
+														playlists.push(response.playlist);
+														next();
+													})
+													.catch(err => {
+														next(
+															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
+														);
+													});
+											},
+											err => {
+												next(
+													err,
+													genrePlaylistIds,
+													playlists.map(playlist => playlist._id.toString())
+												);
+											}
+										);
+									},
+
+									(genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
+										const duplicateGenre =
+											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
+										const duplicateBlacklistedGenre =
+											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
+										const duplicateCross =
+											genrePlaylistIds.length + blacklistedGenrePlaylistIds.length !==
+											new Set([...genrePlaylistIds, ...blacklistedGenrePlaylistIds]).size;
+										if (duplicateGenre)
+											return next("You cannot have the same genre included twice.");
+										if (duplicateBlacklistedGenre)
+											return next("You cannot have the same blacklisted genre included twice.");
+										if (duplicateCross)
+											return next(
+												"You cannot have the same genre included and blacklisted at the same time."
+											);
+										return next(null, genrePlaylistIds, blacklistedGenrePlaylistIds);
+									}
+								],
+								(err, genrePlaylistIds, blacklistedGenrePlaylistIds) => {
+									if (err) return next(err);
+									return playlistModel.create(
+										{
+											isUserModifiable: false,
+											displayName: `Station - ${displayName}`,
+											songs: [],
+											createdBy: "Musare",
+											createdFor: `${stationId}`,
+											createdAt: Date.now(),
+											type: "station"
+										},
+
+										(err, playlist) => {
+											if (err) next(err);
+											else {
+												stationModel.create(
+													{
+														_id: stationId,
+														name,
+														displayName,
+														description,
+														type,
+														privacy: "private",
+														playlist: playlist._id,
+														currentSong: null,
+														partyMode: false,
+														playMode: "random"
+													},
+													(err, station) => {
+														next(
+															err,
+															station,
+															genrePlaylistIds,
+															blacklistedGenrePlaylistIds
+														);
+													}
+												);
+											}
+										}
+									);
+								}
+							);
+						});
+					}
+					if (type === "community") {
+						if (blacklist.indexOf(name) !== -1)
+							return next("That name is blacklisted. Please use a different name.");
+						return playlistModel.create(
+							{
+								isUserModifiable: false,
+								displayName: `Station - ${name}`,
+								songs: [],
+								createdBy: session.userId,
+								createdFor: `${stationId}`,
+								createdAt: Date.now(),
+								type: "station"
+							},
+
+							(err, playlist) => {
+								if (err) next(err);
+								else {
+									stationModel.create(
+										{
+											_id: stationId,
+											name,
+											displayName,
+											description,
+											playlist: playlist._id,
+											type,
+											privacy: "private",
+											owner: session.userId,
+											queue: [],
+											currentSong: null,
+											partyMode: true,
+											playMode: "random"
+										},
+										(err, station) => {
+											next(err, station, null, null);
+										}
+									);
+								}
+							}
+						);
+					}
+				},
+
+				(station, genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
+					if (station.type !== "official") return next(null, station);
+
+					const stationId = station._id;
+
+					return async.waterfall(
+						[
+							next => {
+								async.eachLimit(
+									genrePlaylistIds,
+									1,
+									(playlistId, next) => {
+										StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+											.then(() => next())
+											.catch(next);
+									},
+									next
+								);
+							},
+
+							next => {
+								async.eachLimit(
+									blacklistedGenrePlaylistIds,
+									1,
+									(playlistId, next) => {
+										StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+											.then(() => next())
+											.catch(next);
+									},
+									next
+								);
+							},
+
+							next => {
+								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+								next();
+							}
+						],
+						async err => {
+							if (err) {
+								err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+								this.log(
+									"ERROR",
+									"STATIONS_CREATE",
+									`Created station ${stationId} successfully, but an error occurred during playing including/excluding. Error: ${err}`
+								);
+							}
+							next(null, station, err);
+						}
+					);
+				}
+			],
+			async (err, station, extraError) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_CREATE", `Creating station failed. "${err}"`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "STATIONS_CREATE", `Created station "${station._id}" successfully.`);
+
+					CacheModule.runJob("PUB", {
+						channel: "station.create",
+						value: station._id
+					});
+
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__create",
+						payload: {
+							message: `Created a station named <stationId>${station.displayName}</stationId>`,
+							stationId: station._id
+						}
+					});
+
+					if (!extraError) {
+						cb({
+							status: "success",
+							message: "Successfully created station."
+						});
+					} else {
+						cb({
+							status: "success",
+							message: `Successfully created station, but with one error at the end: ${extraError}`
+						});
+					}
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Adds song to station queue
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param youtubeId - the song id
+	 * @param cb
+	 */
+	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const stationModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "station"
+			},
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (!station.partyMode) return next("Station is not in party mode.");
+
+					if (station.locked) {
+						return userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (user.role !== "admin" && station.owner !== session.userId)
+								return next("Only owners and admins can add songs to a locked queue.");
+							return next(null, station);
+						});
+					}
+
+					return next(null, station);
+				},
+
+				(station, next) => {
+					if (station.type !== "community") return next("That station is not a community station.");
+
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (station.currentSong && station.currentSong.youtubeId === youtubeId)
+						return next("That song is currently playing.");
+
+					return async.each(
+						station.queue,
+						(queueSong, next) => {
+							if (queueSong.youtubeId === youtubeId) return next("That song is already in the queue.");
+							return next();
+						},
+						err => next(err, station)
+					);
+				},
+
+				(station, next) => {
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+						.then(UserModel => {
+							UserModel.findOne(
+								{ _id: session.userId },
+								{ "preferences.anonymousSongRequests": 1 },
+								(err, user) => next(err, station, user)
+							);
+						})
+						.catch(next);
+				},
+
+				(station, user, next) => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId,
+							userId: user.preferences.anonymousSongRequests ? null : session.userId,
+							automaticallyRequested: true
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, skipDuration, artists, thumbnail, duration, status } = song;
+							next(
+								null,
+								{
+									_id,
+									youtubeId,
+									title,
+									skipDuration,
+									artists,
+									thumbnail,
+									duration,
+									status
+								},
+								station
+							);
+						})
+						.catch(next);
+				},
+
+				(song, station, next) => {
+					const excludedPlaylists = [];
+					async.eachLimit(
+						station.excludedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									excludedPlaylists.push(playlist);
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, song, station, excludedPlaylists);
+						}
+					);
+				},
+
+				(song, station, excludedPlaylists, next) => {
+					const excludedSongs = excludedPlaylists
+						.flatMap(excludedPlaylist => excludedPlaylist.songs)
+						.reduce(
+							(items, item) =>
+								items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+							[]
+						);
+
+					if (excludedSongs.find(excludedSong => excludedSong._id.toString() === song._id.toString()))
+						next("That song is in an excluded playlist and cannot be played.");
+					else next(null, song, station);
+				},
+
+				(song, station, next) => {
+					song.requestedBy = session.userId;
+					song.requestedAt = Date.now();
+					return next(null, song);
+				},
+
+				// (song, station, next) => {
+				// 	song.requestedBy = session.userId;
+				// 	song.requestedAt = Date.now();
+				// 	let totalDuration = 0;
+				// 	station.queue.forEach(song => {
+				// 		totalDuration += song.duration;
+				// 	});
+				// 	if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
+				// 	return next(null, song, station);
+				// },
+
+				// (song, station, next) => {
+				// 	if (station.queue.length === 0) return next(null, song, station);
+				// 	let totalDuration = 0;
+				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+				// 	station.queue.forEach(song => {
+				// 		if (userId === song.requestedBy) {
+				// 			totalDuration += song.duration;
+				// 		}
+				// 	});
+
+				// 	if (totalDuration >= 900) return next("The max length of songs per user is 15 minutes.");
+				// 	return next(null, song, station);
+				// },
+
+				// (song, station, next) => {
+				// 	if (station.queue.length === 0) return next(null, song);
+				// 	let totalSongs = 0;
+				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+				// 	station.queue.forEach(song => {
+				// 		if (userId === song.requestedBy) {
+				// 			totalSongs += 1;
+				// 		}
+				// 	});
+
+				// 	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 (
+				// 		station.queue[station.queue.length - 2].requestedBy !== userId ||
+				// 		station.queue[station.queue.length - 3] !== userId
+				// 	)
+				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
+
+				// 	return next(null, song);
+				// },
+
+				(song, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { queue: song } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_ADD_SONG_TO_QUEUE",
+						`Adding song "${youtubeId}" to station "${stationId}" queue failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_ADD_SONG_TO_QUEUE",
+					`Added song "${youtubeId}" to station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.queueUpdate",
+					value: stationId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully added song to queue."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes song from station queue
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	removeFromQueue: isOwnerRequired(async function removeFromQueue(session, stationId, youtubeId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!youtubeId) return next("Invalid youtube id.");
+					return StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+
+					return async.each(
+						station.queue,
+						(queueSong, next) => {
+							if (queueSong.youtubeId === youtubeId) return next(true);
+							return next();
+						},
+						err => {
+							if (err === true) return next();
+							return next("Song is not currently in the queue.");
+						}
+					);
+				},
+
+				next => {
+					stationModel.updateOne({ _id: stationId }, { $pull: { queue: { youtubeId } } }, next);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_SONG_TO_QUEUE",
+						`Removing song "${youtubeId}" from station "${stationId}" queue failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_SONG_TO_QUEUE",
+					`Removed song "${youtubeId}" from station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.queueUpdate",
+					value: stationId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed song from queue."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets the queue from a station
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getQueue(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next(null, station);
+				},
+
+				(station, next) => {
+					StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => next(null, station.queue)
+			],
+			async (err, queue) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_QUEUE",
+						`Getting queue for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_GET_QUEUE", `Got queue for station "${stationId}" successfully.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully got queue.",
+					data: { queue }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Reposition a song in station queue
+	 *
+	 * @param {object} session - user session
+	 * @param {object} song - contains details about the song that is to be repositioned
+	 * @param {string} song.youtubeId - the youtube id of the song
+	 * @param {number} song.newIndex - the new position for the song in the queue
+	 * @param {number} song.oldIndex - the old position of the song in the queue
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	repositionSongInQueue: isOwnerRequired(async function repositionQueue(session, stationId, song, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
+					return next();
+				},
+
+				// remove song from queue
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $pull: { queue: { youtubeId: song.youtubeId } } },
+						next
+					);
+				},
+
+				// add song back to queue (in new position)
+				(res, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { queue: { $each: [song], $position: song.newIndex } } },
+						err => next(err)
+					);
+				},
+
+				// update the cache representation of the station
+				next => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REPOSITION_SONG_IN_QUEUE",
+						`Repositioning song ${song.youtubeId} in queue of station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REPOSITION_SONG_IN_QUEUE",
+					`Repositioned song ${song.youtubeId} in queue of station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.repositionSongInQueue",
+					value: {
+						song: {
+							youtubeId: song.youtubeId,
+							oldIndex: song.oldIndex,
+							newIndex: song.newIndex
+						},
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully repositioned song in queue."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Includes a playlist in a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	includePlaylist: isOwnerRequired(async function includePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.includedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already included.");
+					if (station.playMode === "sequential" && station.includedPlaylists.length > 0)
+						return next("Error: Only 1 playlist can be included in sequential play mode.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_INCLUDE_PLAYLIST",
+						`Including playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_INCLUDE_PLAYLIST",
+					`Including playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "station.includedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully included playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Remove included a playlist from a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	removeIncludedPlaylist: isOwnerRequired(async function removeIncludedPlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.includedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_INCLUDED_PLAYLIST",
+						`Removing included playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_INCLUDED_PLAYLIST",
+					`Removing included playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "station.removedIncludedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed included playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Excludes a playlist in a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	excludePlaylist: isOwnerRequired(async function excludePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.excludedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already excluded.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_EXCLUDE_PLAYLIST",
+						`Excluding playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_EXCLUDE_PLAYLIST",
+					`Excluding playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "station.excludedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully excluded playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Remove excluded a playlist from a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	removeExcludedPlaylist: isOwnerRequired(async function removeExcludedPlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.excludedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not excluded.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_EXCLUDED_PLAYLIST",
+						`Removing excluded playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_EXCLUDED_PLAYLIST",
+					`Removing excluded playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "station.removedExcludedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed excluded playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Selects a private playlist for a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the private playlist id
+	 * @param cb
+	 */
+	selectPrivatePlaylist: isOwnerRequired(async function selectPrivatePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type !== "community") return next("Station is not a community station.");
+					if (station.includedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_SELECT_PRIVATE_PLAYLIST",
+						`Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_SELECT_PRIVATE_PLAYLIST",
+					`Selected private playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				return cb({
+					status: "success",
+					message: "Successfully selected playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Deselects the private playlist selected in a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	deselectPrivatePlaylist: isOwnerRequired(async function deselectPrivatePlaylist(
+		session,
+		stationId,
+		playlistId,
+		cb
+	) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type !== "community") return next("Station is not a community station.");
+					if (station.includedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_DESELECT_PRIVATE_PLAYLIST",
+						`Deselecting private playlist for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_DESELECT_PRIVATE_PLAYLIST",
+					`Deselected private playlist for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				return cb({
+					status: "success",
+					message: "Successfully deselected playlist."
+				});
+			}
+		);
+	}),
+
+	favoriteStation: isLoginRequired(async function favoriteStation(session, stationId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $addToSet: { favoriteStations: stationId } },
+						(err, res) => next(err, station, res)
+					);
+				},
+
+				(station, res, next) => {
+					if (res.nModified === 0) return next("The station was already favorited.");
+					return next(null, station);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.favoritedStation",
+					value: {
+						userId: session.userId,
+						stationId
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__favorite",
+					payload: {
+						message: `Favorited station <stationId>${station.displayName}</stationId>`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Succesfully favorited station."
+				});
+			}
+		);
+	}),
+
+	unfavoriteStation: isLoginRequired(async function unfavoriteStation(session, stationId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne({ _id: session.userId }, { $pull: { favoriteStations: stationId } }, next);
+				},
+
+				(res, next) => {
+					if (res.nModified === 0) return next("The station wasn't favorited.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.unfavoritedStation",
+					value: {
+						userId: session.userId,
+						stationId
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__unfavorite",
+					payload: {
+						message: `Unfavorited station <stationId>${station.displayName}</stationId>`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Succesfully unfavorited station."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Clears every station queue
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearEveryStationQueue: isAdminRequired(async function clearEveryStationQueue(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("CLEAR_EVERY_STATION_QUEUE", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successful.");
+				return cb({ status: "success", message: "Successfully cleared every station queue." });
+			}
+		);
+	}),
+
+	/**
+	 * Clears and refills a station queue
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillStationQueue: isAdminRequired(async function clearAndRefillStationQueue(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("CLEAR_AND_REFILL_STATION_QUEUE", { stationId }, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"CLEAR_AND_REFILL_STATION_QUEUE",
+						`Clearing and refilling station queue failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"CLEAR_AND_REFILL_STATION_QUEUE",
+					"Clearing and refilling station queue was successful."
+				);
+				return cb({ status: "success", message: "Successfully cleared and refilled station queue." });
+			}
+		);
+	}),
+
+	/**
+	 * Gets skip votes for a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param stationId - the song id to get skipvotes for
+	 * @param cb
+	 */
+
+	getSkipVotes: isLoginRequired(async function getSkipVotes(session, stationId, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(res => next(null, res.currentSong))
+						.catch(console.log);
+				},
+
+				(currentSong, next) => {
+					if (currentSong && currentSong._id === songId)
+						next(null, {
+							skipVotes: currentSong.skipVotes.length,
+							skipVotesCurrent: true
+						});
+					else
+						next(null, {
+							skipVotes: 0,
+							skipVotesCurrent: false
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_SKIP_VOTES",
+						`User "${session.userId}" failed to get skip votes for ${stationId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { skipVotes, skipVotesCurrent } = data;
+
+				return cb({
+					status: "success",
+					data: {
+						skipVotes,
+						skipVotesCurrent
+					}
+				});
+			}
+		);
+	})
 };
 };

+ 2567 - 2073
backend/logic/actions/users.js

@@ -1,2092 +1,2586 @@
-"use strict";
-
-const async = require("async");
-const config = require("config");
-const request = require("request");
-const bcrypt = require("bcrypt");
-const sha256 = require("sha256");
-
-const hooks = require("./hooks");
-
-// const moduleManager = require("../../index");
-
-const db = require("../db");
-const mail = require("../mail");
-const cache = require("../cache");
-const punishments = require("../punishments");
-const utils = require("../utils");
-// const logger = require("../logger");
-const activities = require("../activities");
-
-cache.runJob("SUB", {
-    channel: "user.updateUsername",
-    cb: (user) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: user._id,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.username.changed", user.username);
-                });
-            },
-        });
-    },
+import config from "config";
+
+import async from "async";
+
+import axios from "axios";
+import bcrypt from "bcrypt";
+import sha256 from "sha256";
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const MailModule = moduleManager.modules.mail;
+const PunishmentsModule = moduleManager.modules.punishments;
+const SongsModule = moduleManager.modules.songs;
+const ActivitiesModule = moduleManager.modules.activities;
+const PlaylistsModule = moduleManager.modules.playlists;
+
+CacheModule.runJob("SUB", {
+	channel: "user.updatePreferences",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:user.preferences.updated", { data: { preferences: res.preferences } });
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.removeSessions",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER_WITHOUT_CACHE", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("keep.event:user.session.removed");
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.updateOrderOfFavoriteStations",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.orderOfFavoriteStations.updated", {
+					data: { order: res.favoriteStations }
+				});
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.linkPassword",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.linkPassword");
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.updateOrderOfPlaylists",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.orderOfPlaylists.updated", { data: { order: res.orderOfPlaylists } });
+			});
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `profile.${res.userId}.playlists`,
+			args: ["event:user.orderOfPlaylists.updated", { data: { order: res.orderOfPlaylists } }]
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.linkGitHub",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.linkGitHub");
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.updateUsername",
+	cb: user => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: user._id }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.username.updated", { data: { username: user.username } });
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.unlinkPassword",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.unlinkPassword");
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.removeSessions",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("keep.event:user.session.deleted"))
+		);
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.unlinkGitHub",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.unlinkGitHub");
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.linkPassword",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.password.linked");
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.ban",
-    cb: (data) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: data.userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("keep.event:banned", data.punishment);
-                    socket.disconnect(true);
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.unlinkPassword",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.password.unlinked");
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.favoritedStation",
-    cb: (data) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: data.userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.favoritedStation", data.stationId);
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.linkGithub",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.github.linked");
+			});
+		});
+	}
 });
 });
 
 
-cache.runJob("SUB", {
-    channel: "user.unfavoritedStation",
-    cb: (data) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: data.userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit(
-                        "event:user.unfavoritedStation",
-                        data.stationId
-                    );
-                });
-            },
-        });
-    },
+CacheModule.runJob("SUB", {
+	channel: "user.unlinkGithub",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.github.unlinked");
+			});
+		});
+	}
 });
 });
 
 
-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(async (session, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.find({}).exec(next);
-                },
-            ],
-            async (err, users) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "USER_INDEX",
-                        `Indexing users failed. "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "USER_INDEX",
-                        `Indexing users successful.`
-                    );
-                    let filteredUsers = [];
-                    users.forEach((user) => {
-                        filteredUsers.push({
-                            _id: user._id,
-                            username: user.username,
-                            role: user.role,
-                            liked: user.liked,
-                            disliked: user.disliked,
-                            songsRequested: user.statistics.songsRequested,
-                            email: {
-                                address: user.email.address,
-                                verified: user.email.verified,
-                            },
-                            hasPassword: !!user.services.password,
-                            services: { github: user.services.github },
-                        });
-                    });
-                    return cb({ status: "success", data: filteredUsers });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Logs user in
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} identifier - the email of the user
-     * @param {String} password - the plaintext of the user
-     * @param {Function} cb - gets called with the result
-     */
-    login: async (session, identifier, password, cb) => {
-        identifier = identifier.toLowerCase();
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const sessionSchema = await cache.runJob("GET_SCHEMA", {
-            schemaName: "session",
-        });
-
-        async.waterfall(
-            [
-                // check if a user with the requested identifier exists
-                (next) => {
-                    userModel.findOne(
-                        {
-                            $or: [{ "email.address": identifier }],
-                        },
-                        next
-                    );
-                },
-
-                // if the user doesn't exist, respond with a failure
-                // otherwise compare the requested password and the actual users password
-                (user, next) => {
-                    if (!user) return next("User not found");
-                    if (
-                        !user.services.password ||
-                        !user.services.password.password
-                    )
-                        return next(
-                            "The account you are trying to access uses GitHub to log in."
-                        );
-                    bcrypt.compare(
-                        sha256(password),
-                        user.services.password.password,
-                        (err, match) => {
-                            if (err) return next(err);
-                            if (!match) return next("Incorrect password");
-                            next(null, user);
-                        }
-                    );
-                },
-
-                (user, next) => {
-                    utils.runJob("GUID", {}).then((sessionId) => {
-                        next(null, user, sessionId);
-                    });
-                },
-
-                (user, sessionId, next) => {
-                    cache
-                        .runJob("HSET", {
-                            table: "sessions",
-                            key: sessionId,
-                            value: sessionSchema(sessionId, user._id),
-                        })
-                        .then(() => next(null, sessionId))
-                        .catch(next);
-                },
-            ],
-            async (err, sessionId) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "USER_PASSWORD_LOGIN",
-                        `Login failed with password for user "${identifier}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-                console.log(
-                    "SUCCESS",
-                    "USER_PASSWORD_LOGIN",
-                    `Login successful with password for user "${identifier}"`
-                );
-                cb({
-                    status: "success",
-                    message: "Login successful",
-                    user: {},
-                    SID: sessionId,
-                });
-            }
-        );
-    },
-
-    /**
-     * Registers a new user
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} username - the username for the new user
-     * @param {String} email - the email for the new user
-     * @param {String} password - the plaintext password for the new user
-     * @param {Object} recaptcha - the recaptcha data
-     * @param {Function} cb - gets called with the result
-     */
-    register: async function(
-        session,
-        username,
-        email,
-        password,
-        recaptcha,
-        cb
-    ) {
-        email = email.toLowerCase();
-        let verificationToken = await utils.runJob("GENERATE_RANDOM_STRING", {
-            length: 64,
-        });
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
-            schemaName: "verifyEmail",
-        });
-
-        async.waterfall(
-            [
-                // verify the request with google recaptcha
-                (next) => {
-                    if (!db.passwordValid(password))
-                        return next(
-                            "Invalid password. Check if it meets all the requirements."
-                        );
-                    return next();
-                },
-
-                (next) => {
-                    request(
-                        {
-                            url:
-                                "https://www.google.com/recaptcha/api/siteverify",
-                            method: "POST",
-                            form: {
-                                secret: config.get("apis").recaptcha.secret,
-                                response: recaptcha,
-                            },
-                        },
-                        next
-                    );
-                },
-
-                // check if the response from Google recaptcha is successful
-                // if it is, we check if a user with the requested username already exists
-                (response, body, next) => {
-                    let json = JSON.parse(body);
-                    if (json.success !== true)
-                        return next(
-                            "Response from recaptcha was not successful."
-                        );
-                    userModel.findOne(
-                        { username: new RegExp(`^${username}$`, "i") },
-                        next
-                    );
-                },
-
-                // if the user already exists, respond with that
-                // otherwise check if a user with the requested email already exists
-                (user, next) => {
-                    if (user)
-                        return next(
-                            "A user with that username already exists."
-                        );
-                    userModel.findOne({ "email.address": email }, next);
-                },
-
-                // if the user already exists, respond with that
-                // otherwise, generate a salt to use with hashing the new users password
-                (user, next) => {
-                    if (user)
-                        return next("A user with that email already exists.");
-                    bcrypt.genSalt(10, next);
-                },
-
-                // hash the password
-                (salt, next) => {
-                    bcrypt.hash(sha256(password), salt, next);
-                },
-
-                (hash, next) => {
-                    utils
-                        .runJob("GENERATE_RANDOM_STRING", { length: 12 })
-                        .then((_id) => {
-                            next(null, hash, _id);
-                        });
-                },
-
-                // create the user object
-                (hash, _id, next) => {
-                    next(null, {
-                        _id,
-                        username,
-                        email: {
-                            address: email,
-                            verificationToken,
-                        },
-                        services: {
-                            password: {
-                                password: hash,
-                            },
-                        },
-                    });
-                },
-
-                // generate the url for gravatar avatar
-                (user, next) => {
-                    utils
-                        .runJob("CREATE_GRAVATAR", {
-                            email: user.email.address,
-                        })
-                        .then((url) => {
-                            user.avatar = url;
-                            next(null, user);
-                        });
-                },
-
-                // save the new user to the database
-                (user, next) => {
-                    userModel.create(user, next);
-                },
-
-                // respond with the new user
-                (newUser, next) => {
-                    verifyEmailSchema(
-                        email,
-                        username,
-                        verificationToken,
-                        () => {
-                            next(null, newUser);
-                        }
-                    );
-                },
-            ],
-            async (err, user) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "USER_PASSWORD_REGISTER",
-                        `Register failed with password for user "${username}"."${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    module.exports.login(session, email, password, (result) => {
-                        let obj = {
-                            status: "success",
-                            message: "Successfully registered.",
-                        };
-                        if (result.status === "success") {
-                            obj.SID = result.SID;
-                        }
-                        activities.runJob("ADD_ACTIVITY", {
-                            userId: user._id,
-                            activityType: "created_account",
-                        });
-                        console.log(
-                            "SUCCESS",
-                            "USER_PASSWORD_REGISTER",
-                            `Register successful with password for user "${username}".`
-                        );
-                        return cb(obj);
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Logs out a user
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    logout: (session, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    cache
-                        .runJob("HGET", {
-                            table: "sessions",
-                            key: session.sessionId,
-                        })
-                        .then((session) => next(null, session))
-                        .catch(next);
-                },
-
-                (session, next) => {
-                    if (!session) return next("Session not found");
-                    next(null, session);
-                },
-
-                (session, next) => {
-                    cache
-                        .runJob("HDEL", {
-                            table: "sessions",
-                            key: session.sessionId,
-                        })
-                        .then(() => next())
-                        .catch(next);
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "USER_LOGOUT",
-                        `Logout failed. "${err}" `
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log("SUCCESS", "USER_LOGOUT", `Logout successful.`);
-                    cb({
-                        status: "success",
-                        message: "Successfully logged out.",
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Removes all sessions for a user
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} userId - the id of the user we are trying to delete the sessions of
-     * @param {Function} cb - gets called with the result
-     */
-    removeSessions: hooks.loginRequired(async (session, userId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne({ _id: session.userId }, (err, user) => {
-                        if (err) return next(err);
-                        if (user.role !== "admin" && session.userId !== userId)
-                            return next(
-                                "Only admins and the owner of the account can remove their sessions."
-                            );
-                        else return next();
-                    });
-                },
-
-                (next) => {
-                    cache
-                        .runJob("HGETALL", { table: "sessions" })
-                        .then((sessions) => next(null, sessions))
-                        .catch(next);
-                },
-
-                (sessions, next) => {
-                    if (!sessions)
-                        return next(
-                            "There are no sessions for this user to remove."
-                        );
-                    else {
-                        let keys = Object.keys(sessions);
-                        next(null, keys, sessions);
-                    }
-                },
-
-                (keys, sessions, next) => {
-                    cache.runJob("PUB", {
-                        channel: "user.removeSessions",
-                        value: userId,
-                    });
-                    async.each(
-                        keys,
-                        (sessionId, callback) => {
-                            let session = sessions[sessionId];
-                            if (session.userId === userId) {
-                                cache
-                                    .runJob("HDEL", {
-                                        channel: "sessions",
-                                        key: sessionId,
-                                    })
-                                    .then(() => callback(null))
-                                    .catch(next);
-                            }
-                        },
-                        (err) => {
-                            next(err);
-                        }
-                    );
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REMOVE_SESSIONS_FOR_USER",
-                        `Couldn't remove all sessions for user "${userId}". "${err}"`
-                    );
-                    return cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "REMOVE_SESSIONS_FOR_USER",
-                        `Removed all sessions for user "${userId}".`
-                    );
-                    return cb({
-                        status: "success",
-                        message: "Successfully removed all sessions.",
-                    });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Gets user object from username (only a few properties)
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} username - the username of the user we are trying to find
-     * @param {Function} cb - gets called with the result
-     */
-    findByUsername: async (session, username, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne(
-                        { username: new RegExp(`^${username}$`, "i") },
-                        next
-                    );
-                },
-
-                (account, next) => {
-                    if (!account) return next("User not found.");
-                    next(null, account);
-                },
-            ],
-            async (err, account) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "FIND_BY_USERNAME",
-                        `User not found for username "${username}". "${err}"`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "FIND_BY_USERNAME",
-                        `User found for username "${username}".`
-                    );
-                    return cb({
-                        status: "success",
-                        data: {
-                            _id: account._id,
-                            name: account.name,
-                            username: account.username,
-                            location: account.location,
-                            bio: account.bio,
-                            role: account.role,
-                            avatar: account.avatar,
-                            createdAt: account.createdAt,
-                        },
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Gets a username from an userId
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} userId - the userId of the person we are trying to get the username from
-     * @param {Function} cb - gets called with the result
-     */
-    getUsernameFromId: async (session, userId, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        userModel
-            .findById(userId)
-            .then((user) => {
-                if (user) {
-                    console.log(
-                        "SUCCESS",
-                        "GET_USERNAME_FROM_ID",
-                        `Found username for userId "${userId}".`
-                    );
-                    return cb({
-                        status: "success",
-                        data: user.username,
-                    });
-                } else {
-                    console.log(
-                        "ERROR",
-                        "GET_USERNAME_FROM_ID",
-                        `Getting the username from userId "${userId}" failed. User not found.`
-                    );
-                    cb({
-                        status: "failure",
-                        message: "Couldn't find the user.",
-                    });
-                }
-            })
-            .catch(async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "GET_USERNAME_FROM_ID",
-                        `Getting the username from userId "${userId}" failed. "${err}"`
-                    );
-                    cb({ status: "failure", message: err });
-                }
-            });
-    },
-
-    //TODO Fix security issues
-    /**
-     * Gets user info from session
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    findBySession: async (session, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    cache
-                        .runJob("HGET", {
-                            table: "sessions",
-                            key: session.sessionId,
-                        })
-                        .then((session) => next(null, session))
-                        .catch(next);
-                },
-
-                (session, next) => {
-                    if (!session) return next("Session not found.");
-                    next(null, session);
-                },
-
-                (session, next) => {
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-
-                (user, next) => {
-                    if (!user) return next("User not found.");
-                    next(null, user);
-                },
-            ],
-            async (err, user) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "FIND_BY_SESSION",
-                        `User not found. "${err}"`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    let data = {
-                        email: {
-                            address: user.email.address,
-                        },
-                        avatar: user.avatar,
-                        username: user.username,
-                        name: user.name,
-                        location: user.location,
-                        bio: user.bio,
-                    };
-                    if (
-                        user.services.password &&
-                        user.services.password.password
-                    )
-                        data.password = true;
-                    if (user.services.github && user.services.github.id)
-                        data.github = true;
-                    console.log(
-                        "SUCCESS",
-                        "FIND_BY_SESSION",
-                        `User found. "${user.username}".`
-                    );
-                    return cb({
-                        status: "success",
-                        data,
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Updates a user's username
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newUsername - the new username
-     * @param {Function} cb - gets called with the result
-     */
-    updateUsername: hooks.loginRequired(
-        async (session, updatingUserId, newUsername, cb) => {
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (updatingUserId === session.userId)
-                            return next(null, true);
-                        userModel.findOne({ _id: session.userId }, next);
-                    },
-
-                    (user, next) => {
-                        if (user !== true && (!user || user.role !== "admin"))
-                            return next("Invalid permissions.");
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        if (user.username === newUsername)
-                            return next(
-                                "New username can't be the same as the old username."
-                            );
-                        next(null);
-                    },
-
-                    (next) => {
-                        userModel.findOne(
-                            { username: new RegExp(`^${newUsername}$`, "i") },
-                            next
-                        );
-                    },
-
-                    (user, next) => {
-                        if (!user) return next();
-                        if (user._id === updatingUserId) return next();
-                        next("That username is already in use.");
-                    },
-
-                    (next) => {
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            { $set: { username: newUsername } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_USERNAME",
-                            `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        cache.runJob("PUB", {
-                            channel: "user.updateUsername",
-                            value: {
-                                username: newUsername,
-                                _id: updatingUserId,
-                            },
-                        });
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_USERNAME",
-                            `Updated username for user "${updatingUserId}" to username "${newUsername}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Username updated successfully",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a user's email
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newEmail - the new email
-     * @param {Function} cb - gets called with the result
-     */
-    updateEmail: hooks.loginRequired(
-        async (session, updatingUserId, newEmail, cb) => {
-            newEmail = newEmail.toLowerCase();
-            let verificationToken = await utils.runJob(
-                "GENERATE_RANDOM_STRING",
-                { length: 64 }
-            );
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
-                schemaName: "verifyEmail",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (updatingUserId === session.userId)
-                            return next(null, true);
-                        userModel.findOne({ _id: session.userId }, next);
-                    },
-
-                    (user, next) => {
-                        if (user !== true && (!user || user.role !== "admin"))
-                            return next("Invalid permissions.");
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        if (user.email.address === newEmail)
-                            return next(
-                                "New email can't be the same as your the old email."
-                            );
-                        next();
-                    },
-
-                    (next) => {
-                        userModel.findOne({ "email.address": newEmail }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next();
-                        if (user._id === updatingUserId) return next();
-                        next("That email is already in use.");
-                    },
-
-                    // regenerate the url for gravatar avatar
-                    (next) => {
-                        utils
-                            .runJob("CREATE_GRAVATAR", { email: newEmail })
-                            .then((url) => next(null, url));
-                    },
-
-                    (avatar, next) => {
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            {
-                                $set: {
-                                    avatar: avatar,
-                                    "email.address": newEmail,
-                                    "email.verified": false,
-                                    "email.verificationToken": verificationToken,
-                                },
-                            },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        verifyEmailSchema(
-                            newEmail,
-                            user.username,
-                            verificationToken,
-                            () => {
-                                next();
-                            }
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_EMAIL",
-                            `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_EMAIL",
-                            `Updated email for user "${updatingUserId}" to email "${newEmail}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Email updated successfully.",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a user's name
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newBio - the new name
-     * @param {Function} cb - gets called with the result
-     */
-    updateName: hooks.loginRequired(
-        async (session, updatingUserId, newName, cb) => {
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (updatingUserId === session.userId)
-                            return next(null, true);
-                        userModel.findOne({ _id: session.userId }, next);
-                    },
-
-                    (user, next) => {
-                        if (user !== true && (!user || user.role !== "admin"))
-                            return next("Invalid permissions.");
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            { $set: { name: newName } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_NAME",
-                            `Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_NAME",
-                            `Updated name for user "${updatingUserId}" to name "${newName}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Name updated successfully",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a user's location
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newLocation - the new location
-     * @param {Function} cb - gets called with the result
-     */
-    updateLocation: hooks.loginRequired(
-        async (session, updatingUserId, newLocation, cb) => {
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (updatingUserId === session.userId)
-                            return next(null, true);
-                        userModel.findOne({ _id: session.userId }, next);
-                    },
-
-                    (user, next) => {
-                        if (user !== true && (!user || user.role !== "admin"))
-                            return next("Invalid permissions.");
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            { $set: { location: newLocation } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_LOCATION",
-                            `Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_LOCATION",
-                            `Updated location for user "${updatingUserId}" to location "${newLocation}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Location updated successfully",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a user's bio
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newBio - the new bio
-     * @param {Function} cb - gets called with the result
-     */
-    updateBio: hooks.loginRequired(
-        async (session, updatingUserId, newBio, cb) => {
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (updatingUserId === session.userId)
-                            return next(null, true);
-                        userModel.findOne({ _id: session.userId }, next);
-                    },
-
-                    (user, next) => {
-                        if (user !== true && (!user || user.role !== "admin"))
-                            return next("Invalid permissions.");
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            { $set: { bio: newBio } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_BIO",
-                            `Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_BIO",
-                            `Updated bio for user "${updatingUserId}" to bio "${newBio}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Bio updated successfully",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates the type of a user's avatar
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newType - the new type
-     * @param {Function} cb - gets called with the result
-     */
-    updateAvatarType: hooks.loginRequired(
-        async (session, updatingUserId, newType, cb) => {
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (updatingUserId === session.userId)
-                            return next(null, true);
-                        userModel.findOne({ _id: session.userId }, next);
-                    },
-
-                    (user, next) => {
-                        if (user !== true && (!user || user.role !== "admin"))
-                            return next("Invalid permissions.");
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            { $set: { "avatar.type": newType } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_AVATAR_TYPE",
-                            `Couldn't update avatar type for user "${updatingUserId}" to type "${newType}". "${err}"`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_AVATAR_TYPE",
-                            `Updated avatar type for user "${updatingUserId}" to type "${newType}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Avatar type updated successfully",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a user's role
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} updatingUserId - the updating user's id
-     * @param {String} newRole - the new role
-     * @param {Function} cb - gets called with the result
-     */
-    updateRole: hooks.adminRequired(
-        async (session, updatingUserId, newRole, cb) => {
-            newRole = newRole.toLowerCase();
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        userModel.findOne({ _id: updatingUserId }, next);
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("User not found.");
-                        else if (user.role === newRole)
-                            return next(
-                                "New role can't be the same as the old role."
-                            );
-                        else return next();
-                    },
-                    (next) => {
-                        userModel.updateOne(
-                            { _id: updatingUserId },
-                            { $set: { role: newRole } },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "UPDATE_ROLE",
-                            `User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "UPDATE_ROLE",
-                            `User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Role successfully updated.",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Updates a user's password
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} newPassword - the new password
-     * @param {Function} cb - gets called with the result
-     */
-    updatePassword: hooks.loginRequired(async (session, newPassword, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-
-                (user, next) => {
-                    if (!user.services.password)
-                        return next(
-                            "This account does not have a password set."
-                        );
-                    next();
-                },
-
-                (next) => {
-                    if (!db.passwordValid(newPassword))
-                        return next(
-                            "Invalid password. Check if it meets all the requirements."
-                        );
-                    return next();
-                },
-
-                (next) => {
-                    bcrypt.genSalt(10, next);
-                },
-
-                // hash the password
-                (salt, next) => {
-                    bcrypt.hash(sha256(newPassword), salt, next);
-                },
-
-                (hashedPassword, next) => {
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        {
-                            $set: {
-                                "services.password.password": hashedPassword,
-                            },
-                        },
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "UPDATE_PASSWORD",
-                        `Failed updating user password of user '${session.userId}'. '${err}'.`
-                    );
-                    return cb({ status: "failure", message: err });
-                }
-
-                console.log(
-                    "SUCCESS",
-                    "UPDATE_PASSWORD",
-                    `User '${session.userId}' updated their password.`
-                );
-                cb({
-                    status: "success",
-                    message: "Password successfully updated.",
-                });
-            }
-        );
-    }),
-
-    /**
-     * Requests a password for a session
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} email - the email of the user that requests a password reset
-     * @param {Function} cb - gets called with the result
-     */
-    requestPassword: hooks.loginRequired(async (session, cb) => {
-        let code = await utils.runJob("GENERATE_RANDOM_STRING", { length: 8 });
-        const passwordRequestSchema = await mail.runJob("GET_SCHEMA", {
-            schemaName: "passwordRequest",
-        });
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-
-                (user, next) => {
-                    if (!user) return next("User not found.");
-                    if (
-                        user.services.password &&
-                        user.services.password.password
-                    )
-                        return next("You already have a password set.");
-                    next(null, user);
-                },
-
-                (user, next) => {
-                    let expires = new Date();
-                    expires.setDate(expires.getDate() + 1);
-                    userModel.findOneAndUpdate(
-                        { "email.address": user.email.address },
-                        {
-                            $set: {
-                                "services.password": {
-                                    set: { code: code, expires },
-                                },
-                            },
-                        },
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (user, next) => {
-                    passwordRequestSchema(
-                        user.email.address,
-                        user.username,
-                        code,
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REQUEST_PASSWORD",
-                        `UserId '${session.userId}' failed to request password. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "REQUEST_PASSWORD",
-                        `UserId '${session.userId}' successfully requested a password.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully requested password.",
-                    });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Verifies a password code
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} code - the password code
-     * @param {Function} cb - gets called with the result
-     */
-    verifyPasswordCode: hooks.loginRequired(async (session, code, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    if (!code || typeof code !== "string")
-                        return next("Invalid code1.");
-                    userModel.findOne(
-                        {
-                            "services.password.set.code": code,
-                            _id: session.userId,
-                        },
-                        next
-                    );
-                },
-
-                (user, next) => {
-                    if (!user) return next("Invalid code2.");
-                    if (user.services.password.set.expires < new Date())
-                        return next("That code has expired.");
-                    next(null);
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "VERIFY_PASSWORD_CODE",
-                        `Code '${code}' failed to verify. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "VERIFY_PASSWORD_CODE",
-                        `Code '${code}' successfully verified.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully verified password code.",
-                    });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Adds a password to a user with a code
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} code - the password code
-     * @param {String} newPassword - the new password code
-     * @param {Function} cb - gets called with the result
-     */
-    changePasswordWithCode: hooks.loginRequired(
-        async (session, code, newPassword, cb) => {
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        if (!code || typeof code !== "string")
-                            return next("Invalid code1.");
-                        userModel.findOne(
-                            { "services.password.set.code": code },
-                            next
-                        );
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("Invalid code2.");
-                        if (!user.services.password.set.expires > new Date())
-                            return next("That code has expired.");
-                        next();
-                    },
-
-                    (next) => {
-                        if (!db.passwordValid(newPassword))
-                            return next(
-                                "Invalid password. Check if it meets all the requirements."
-                            );
-                        return next();
-                    },
-
-                    (next) => {
-                        bcrypt.genSalt(10, next);
-                    },
-
-                    // hash the password
-                    (salt, next) => {
-                        bcrypt.hash(sha256(newPassword), salt, next);
-                    },
-
-                    (hashedPassword, next) => {
-                        userModel.updateOne(
-                            { "services.password.set.code": code },
-                            {
-                                $set: {
-                                    "services.password.password": hashedPassword,
-                                },
-                                $unset: { "services.password.set": "" },
-                            },
-                            { runValidators: true },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "ADD_PASSWORD_WITH_CODE",
-                            `Code '${code}' failed to add password. '${err}'`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "ADD_PASSWORD_WITH_CODE",
-                            `Code '${code}' successfully added password.`
-                        );
-                        cache.runJob("PUB", {
-                            channel: "user.linkPassword",
-                            value: session.userId,
-                        });
-                        cb({
-                            status: "success",
-                            message: "Successfully added password.",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    /**
-     * Unlinks password from user
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    unlinkPassword: hooks.loginRequired(async (session, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-
-                (user, next) => {
-                    if (!user) return next("Not logged in.");
-                    if (!user.services.github || !user.services.github.id)
-                        return next(
-                            "You can't remove password login without having GitHub login."
-                        );
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        { $unset: { "services.password": "" } },
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "UNLINK_PASSWORD",
-                        `Unlinking password failed for userId '${session.userId}'. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "UNLINK_PASSWORD",
-                        `Unlinking password successful for userId '${session.userId}'.`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "user.unlinkPassword",
-                        value: session.userId,
-                    });
-                    cb({
-                        status: "success",
-                        message: "Successfully unlinked password.",
-                    });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Unlinks GitHub from user
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {Function} cb - gets called with the result
-     */
-    unlinkGitHub: hooks.loginRequired(async (session, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-
-                (user, next) => {
-                    if (!user) return next("Not logged in.");
-                    if (
-                        !user.services.password ||
-                        !user.services.password.password
-                    )
-                        return next(
-                            "You can't remove GitHub login without having password login."
-                        );
-                    userModel.updateOne(
-                        { _id: session.userId },
-                        { $unset: { "services.github": "" } },
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "UNLINK_GITHUB",
-                        `Unlinking GitHub failed for userId '${session.userId}'. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "UNLINK_GITHUB",
-                        `Unlinking GitHub successful for userId '${session.userId}'.`
-                    );
-                    cache.runJob("PUB", {
-                        channel: "user.unlinkGithub",
-                        value: session.userId,
-                    });
-                    cb({
-                        status: "success",
-                        message: "Successfully unlinked GitHub.",
-                    });
-                }
-            }
-        );
-    }),
-
-    /**
-     * Requests a password reset for an email
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} email - the email of the user that requests a password reset
-     * @param {Function} cb - gets called with the result
-     */
-    requestPasswordReset: async (session, email, cb) => {
-        let code = await utils.runJob("GENERATE_RANDOM_STRING", { length: 8 });
-        console.log(111, code);
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        const resetPasswordRequestSchema = await mail.runJob("GET_SCHEMA", {
-            schemaName: "resetPasswordRequest",
-        });
-        async.waterfall(
-            [
-                (next) => {
-                    if (!email || typeof email !== "string")
-                        return next("Invalid email.");
-                    email = email.toLowerCase();
-                    userModel.findOne({ "email.address": email }, next);
-                },
-
-                (user, next) => {
-                    if (!user) return next("User not found.");
-                    if (
-                        !user.services.password ||
-                        !user.services.password.password
-                    )
-                        return next(
-                            "User does not have a password set, and probably uses GitHub to log in."
-                        );
-                    next(null, user);
-                },
-
-                (user, next) => {
-                    let expires = new Date();
-                    expires.setDate(expires.getDate() + 1);
-                    userModel.findOneAndUpdate(
-                        { "email.address": email },
-                        {
-                            $set: {
-                                "services.password.reset": {
-                                    code: code,
-                                    expires,
-                                },
-                            },
-                        },
-                        { runValidators: true },
-                        next
-                    );
-                },
-
-                (user, next) => {
-                    resetPasswordRequestSchema(
-                        user.email.address,
-                        user.username,
-                        code,
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "REQUEST_PASSWORD_RESET",
-                        `Email '${email}' failed to request password reset. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "REQUEST_PASSWORD_RESET",
-                        `Email '${email}' successfully requested a password reset.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully requested password reset.",
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Verifies a reset code
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} code - the password reset code
-     * @param {Function} cb - gets called with the result
-     */
-    verifyPasswordResetCode: async (session, code, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    if (!code || typeof code !== "string")
-                        return next("Invalid code.");
-                    userModel.findOne(
-                        { "services.password.reset.code": code },
-                        next
-                    );
-                },
-
-                (user, next) => {
-                    if (!user) return next("Invalid code.");
-                    if (!user.services.password.reset.expires > new Date())
-                        return next("That code has expired.");
-                    next(null);
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "VERIFY_PASSWORD_RESET_CODE",
-                        `Code '${code}' failed to verify. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "VERIFY_PASSWORD_RESET_CODE",
-                        `Code '${code}' successfully verified.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully verified password reset code.",
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Changes a user's password with a reset code
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} code - the password reset code
-     * @param {String} newPassword - the new password reset code
-     * @param {Function} cb - gets called with the result
-     */
-    changePasswordWithResetCode: async (session, code, newPassword, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    if (!code || typeof code !== "string")
-                        return next("Invalid code.");
-                    userModel.findOne(
-                        { "services.password.reset.code": code },
-                        next
-                    );
-                },
-
-                (user, next) => {
-                    if (!user) return next("Invalid code.");
-                    if (!user.services.password.reset.expires > new Date())
-                        return next("That code has expired.");
-                    next();
-                },
-
-                (next) => {
-                    if (!db.passwordValid(newPassword))
-                        return next(
-                            "Invalid password. Check if it meets all the requirements."
-                        );
-                    return next();
-                },
-
-                (next) => {
-                    bcrypt.genSalt(10, next);
-                },
-
-                // hash the password
-                (salt, next) => {
-                    bcrypt.hash(sha256(newPassword), salt, next);
-                },
-
-                (hashedPassword, next) => {
-                    userModel.updateOne(
-                        { "services.password.reset.code": code },
-                        {
-                            $set: {
-                                "services.password.password": hashedPassword,
-                            },
-                            $unset: { "services.password.reset": "" },
-                        },
-                        { runValidators: true },
-                        next
-                    );
-                },
-            ],
-            async (err) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "CHANGE_PASSWORD_WITH_RESET_CODE",
-                        `Code '${code}' failed to change password. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "CHANGE_PASSWORD_WITH_RESET_CODE",
-                        `Code '${code}' successfully changed password.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully changed password.",
-                    });
-                }
-            }
-        );
-    },
-
-    /**
-     * Bans a user by userId
-     *
-     * @param {Object} session - the session object automatically added by socket.io
-     * @param {String} value - the user id that is going to be banned
-     * @param {String} reason - the reason for the ban
-     * @param {String} expiresAt - the time the ban expires
-     * @param {Function} cb - gets called with the result
-     */
-    banUserById: hooks.adminRequired(
-        (session, userId, reason, expiresAt, cb) => {
-            async.waterfall(
-                [
-                    (next) => {
-                        if (!userId)
-                            return next("You must provide a userId to ban.");
-                        else if (!reason)
-                            return next(
-                                "You must provide a reason for the ban."
-                            );
-                        else return next();
-                    },
-
-                    (next) => {
-                        if (!expiresAt || typeof expiresAt !== "string")
-                            return next("Invalid expire date.");
-                        let date = new Date();
-                        switch (expiresAt) {
-                            case "1h":
-                                expiresAt = date.setHours(date.getHours() + 1);
-                                break;
-                            case "12h":
-                                expiresAt = date.setHours(date.getHours() + 12);
-                                break;
-                            case "1d":
-                                expiresAt = date.setDate(date.getDate() + 1);
-                                break;
-                            case "1w":
-                                expiresAt = date.setDate(date.getDate() + 7);
-                                break;
-                            case "1m":
-                                expiresAt = date.setMonth(date.getMonth() + 1);
-                                break;
-                            case "3m":
-                                expiresAt = date.setMonth(date.getMonth() + 3);
-                                break;
-                            case "6m":
-                                expiresAt = date.setMonth(date.getMonth() + 6);
-                                break;
-                            case "1y":
-                                expiresAt = date.setFullYear(
-                                    date.getFullYear() + 1
-                                );
-                                break;
-                            case "never":
-                                expiresAt = new Date(3093527980800000);
-                                break;
-                            default:
-                                return next("Invalid expire date.");
-                        }
-
-                        next();
-                    },
-
-                    (next) => {
-                        punishments
-                            .runJob("ADD_PUNISHMENT", {
-                                type: "banUserId",
-                                value: userId,
-                                reason,
-                                expiresAt,
-                                punishedBy,
-                            })
-                            .then((punishment) => next(null, punishment))
-                            .catch(next);
-                    },
-
-                    (punishment, next) => {
-                        cache.runJob("PUB", {
-                            channel: "user.ban",
-                            value: { userId, punishment },
-                        });
-                        next();
-                    },
-                ],
-                async (err) => {
-                    if (err && err !== true) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        console.log(
-                            "ERROR",
-                            "BAN_USER_BY_ID",
-                            `User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`
-                        );
-                        cb({ status: "failure", message: err });
-                    } else {
-                        console.log(
-                            "SUCCESS",
-                            "BAN_USER_BY_ID",
-                            `User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`
-                        );
-                        cb({
-                            status: "success",
-                            message: "Successfully banned user.",
-                        });
-                    }
-                }
-            );
-        }
-    ),
-
-    getFavoriteStations: hooks.loginRequired(async (session, cb) => {
-        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
-        async.waterfall(
-            [
-                (next) => {
-                    userModel.findOne({ _id: session.userId }, next);
-                },
-
-                (user, next) => {
-                    if (!user) return next("User not found.");
-                    next(null, user);
-                },
-            ],
-            async (err, user) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "GET_FAVORITE_STATIONS",
-                        `User ${session.userId} failed to get favorite stations. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "GET_FAVORITE_STATIONS",
-                        `User ${session.userId} got favorite stations.`
-                    );
-                    cb({
-                        status: "success",
-                        favoriteStations: user.favoriteStations,
-                    });
-                }
-            }
-        );
-    }),
+CacheModule.runJob("SUB", {
+	channel: "user.ban",
+	cb: data => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:user.banned", { data: { ban: data.punishment } });
+				socket.disconnect(true);
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.favoritedStation",
+	cb: data => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.station.favorited", { data: { stationId: data.stationId } });
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.unfavoritedStation",
+	cb: data => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.station.unfavorited", { data: { stationId: data.stationId } });
+			});
+		});
+	}
+});
+
+export default {
+	/**
+	 * Lists all Users
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.find({}).exec(next);
+				}
+			],
+			async (err, users) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "USER_INDEX", `Indexing users failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "USER_INDEX", `Indexing users successful.`);
+				const filteredUsers = [];
+				users.forEach(user => {
+					filteredUsers.push({
+						_id: user._id,
+						name: user.name,
+						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
+						},
+						avatar: {
+							type: user.avatar.type,
+							url: user.avatar.url,
+							color: user.avatar.color
+						},
+						hasPassword: !!user.services.password,
+						services: { github: user.services.github }
+					});
+				});
+				return cb({ status: "success", data: { users: filteredUsers } });
+			}
+		);
+	}),
+
+	/**
+	 * Removes all data held on a user, including their ability to login
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	remove: isLoginRequired(async function remove(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
+		const songsToAdjustRatings = [];
+
+		async.waterfall(
+			[
+				// activities related to the user
+				next => {
+					activityModel.deleteMany({ userId: session.userId }, next);
+				},
+
+				// user's stations
+				(res, next) => {
+					stationModel.find({ owner: session.userId }, (err, stations) => {
+						if (err) return next(err);
+
+						return async.each(
+							stations,
+							(station, callback) => {
+								// delete the station
+								stationModel.deleteOne({ _id: station._id }, err => {
+									if (err) return callback(err);
+
+									CacheModule.runJob("HDEL", { table: "stations", key: station._id });
+
+									// if applicable, delete the corresponding playlist for the station
+									if (station.playlist)
+										return PlaylistsModule.runJob("DELETE_PLAYLIST", {
+											playlistId: station.playlist
+										})
+											.then(() => callback())
+											.catch(callback);
+
+									return callback();
+								});
+							},
+							err => next(err)
+						);
+					});
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: session.userId, displayName: "Liked Songs" }, next);
+				},
+
+				// get all liked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: session.userId, displayName: "Disliked Songs" }, next);
+				},
+
+				// get all disliked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				// user's playlists
+				next => {
+					playlistModel.deleteMany({ createdBy: session.userId }, next);
+				},
+
+				(res, next) => {
+					async.each(
+						songsToAdjustRatings,
+						(song, next) => {
+							const { songId, youtubeId } = song;
+
+							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+								.then(() => next())
+								.catch(next);
+						},
+						err => next(err)
+					);
+				},
+
+				// user object
+				next => {
+					userModel.deleteMany({ _id: session.userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId: session.userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, session.userId, "remove", err => next(err));
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_REMOVE",
+						`Removing data and account for user "${session.userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_REMOVE",
+					`Successfully removed data and account for user "${session.userId}"`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully removed data and account."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Logs user in
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} identifier - the email of the user
+	 * @param {string} password - the plaintext of the user
+	 * @param {Function} cb - gets called with the result
+	 */
+	async login(session, identifier, password, cb) {
+		identifier = identifier.toLowerCase();
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const sessionSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "session" }, this);
+
+		async.waterfall(
+			[
+				// check if a user with the requested identifier exists
+				next => {
+					userModel.findOne(
+						{
+							$or: [{ "email.address": identifier }]
+						},
+						next
+					);
+				},
+
+				// if the user doesn't exist, respond with a failure
+				// otherwise compare the requested password and the actual users password
+				(user, next) => {
+					if (!user) return next("User not found");
+					if (!user.services.password || !user.services.password.password)
+						return next("The account you are trying to access uses GitHub to log in.");
+
+					return bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
+						if (err) return next(err);
+						if (!match) return next("Incorrect password");
+						return next(null, user);
+					});
+				},
+
+				(user, next) => {
+					UtilsModule.runJob("GUID", {}, this).then(sessionId => {
+						next(null, user, sessionId);
+					});
+				},
+
+				(user, sessionId, next) => {
+					CacheModule.runJob(
+						"HSET",
+						{
+							table: "sessions",
+							key: sessionId,
+							value: sessionSchema(sessionId, user._id)
+						},
+						this
+					)
+						.then(() => next(null, sessionId))
+						.catch(next);
+				}
+			],
+			async (err, sessionId) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_PASSWORD_LOGIN",
+						`Login failed with password for user "${identifier}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_PASSWORD_LOGIN", `Login successful with password for user "${identifier}"`);
+
+				return cb({
+					status: "success",
+					message: "Login successful",
+					data: { SID: sessionId }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Registers a new user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @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
+	 */
+	async register(session, username, email, password, recaptcha, cb) {
+		email = email.toLowerCase();
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (config.get("registrationDisabled") === true)
+						return next("Registration is not allowed at this time.");
+					return next();
+				},
+
+				next => {
+					if (!DBModule.passwordValid(password))
+						return next("Invalid password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				// verify the request with google recaptcha
+				next => {
+					if (config.get("apis.recaptcha.enabled") === true)
+						axios
+							.post("https://www.google.com/recaptcha/api/siteverify", {
+								data: {
+									secret: config.get("apis").recaptcha.secret,
+									response: recaptcha
+								}
+							})
+							.then(res => next(null, res.data))
+							.catch(err => next(err));
+					else next(null, null);
+				},
+
+				// check if the response from Google recaptcha is successful
+				// if it is, we check if a user with the requested username already exists
+				(body, next) => {
+					if (config.get("apis.recaptcha.enabled") === true)
+						if (body.success !== true) return next("Response from recaptcha was not successful.");
+
+					return userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
+				},
+
+				// if the user already exists, respond with that
+				// otherwise check if a user with the requested email already exists
+				(user, next) => {
+					if (user) return next("A user with that username already exists.");
+					return userModel.findOne({ "email.address": email }, next);
+				},
+
+				// if the user already exists, respond with that
+				// otherwise, generate a salt to use with hashing the new users password
+				(user, next) => {
+					if (user) return next("A user with that email already exists.");
+					return bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(password), salt, next);
+				},
+
+				(hash, next) => {
+					UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 12 }, this).then(_id => {
+						next(null, hash, _id);
+					});
+				},
+
+				// create the user object
+				(hash, _id, next) => {
+					next(null, {
+						_id,
+						name: username,
+						username,
+						email: {
+							address: email,
+							verificationToken
+						},
+						services: {
+							password: {
+								password: hash
+							}
+						}
+					});
+				},
+
+				// generate the url for gravatar avatar
+				(user, next) => {
+					UtilsModule.runJob("CREATE_GRAVATAR", { email: user.email.address }, this).then(url => {
+						const avatarColors = ["blue", "orange", "green", "purple", "teal"];
+						user.avatar = {
+							type: "initials",
+							color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
+							url
+						};
+						next(null, user);
+					});
+				},
+
+				// save the new user to the database
+				(user, next) => {
+					userModel.create(user, next);
+				},
+
+				// respond with the new user
+				(user, next) => {
+					verifyEmailSchema(email, username, verificationToken, err => {
+						next(err, user._id);
+					});
+				},
+
+				// create a liked songs playlist for the new user
+				(userId, next) => {
+					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+						userId,
+						displayName: "Liked Songs",
+						type: "user"
+					})
+						.then(likedSongsPlaylist => {
+							next(null, likedSongsPlaylist, userId);
+						})
+						.catch(err => next(err));
+				},
+
+				// create a disliked songs playlist for the new user
+				(likedSongsPlaylist, userId, next) => {
+					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+						userId,
+						displayName: "Disliked Songs",
+						type: "user"
+					})
+						.then(dislikedSongsPlaylist => {
+							next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
+						})
+						.catch(err => next(err));
+				},
+
+				// associate liked + disliked songs playlist to the user object
+				({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
+					userModel.updateOne(
+						{ _id: userId },
+						{ $set: { likedSongsPlaylist, dislikedSongsPlaylist } },
+						{ runValidators: true },
+						err => {
+							if (err) return next(err);
+							return next(null, userId);
+						}
+					);
+				}
+			],
+			async (err, userId) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_PASSWORD_REGISTER",
+						`Register failed with password for user "${username}"."${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId,
+					type: "user__joined",
+					payload: { message: "Welcome to Musare!" }
+				});
+
+				this.log(
+					"SUCCESS",
+					"USER_PASSWORD_REGISTER",
+					`Register successful with password for user "${username}".`
+				);
+
+				const res = await this.module.runJob(
+					"RUN_ACTION2",
+					{
+						session,
+						namespace: "users",
+						action: "login",
+						args: [email, password]
+					},
+					this
+				);
+
+				const obj = {
+					status: "success",
+					message: "Successfully registered."
+				};
+
+				if (res.status === "success") {
+					obj.SID = res.data.SID;
+				}
+
+				return cb(obj);
+			}
+		);
+	},
+
+	/**
+	 * Logs out a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	logout(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }, this)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+
+				(session, next) => {
+					if (!session) return next("Session not found");
+					return next(null, session);
+				},
+
+				(session, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.removeSessions",
+						value: session.userId
+					});
+
+					// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
+					setTimeout(() => {
+						CacheModule.runJob("HDEL", { table: "sessions", key: session.sessionId }, this)
+							.then(() => next())
+							.catch(next);
+					}, 50);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "USER_LOGOUT", `Logout failed. "${err}" `);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_LOGOUT", `Logout successful.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully logged out."
+				});
+			}
+		);
+	},
+
+	/**
+	 * Checks if user's password is correct (e.g. before a sensitive action)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} password - the password the user entered that we need to validate
+	 * @param {Function} cb - gets called with the result
+	 */
+	confirmPasswordMatch: isLoginRequired(async function confirmPasswordMatch(session, password, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		return async.waterfall(
+			[
+				next => {
+					if (!password || password === "") return next("Please provide a valid password.");
+					return next();
+				},
+
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) =>
+						next(err, user.services.password.password)
+					);
+				},
+
+				(passwordHash, next) => {
+					if (!passwordHash) return next("Your account doesn't have a password linked.");
+
+					return bcrypt.compare(sha256(password), passwordHash, (err, match) => {
+						if (err) return next(err);
+						if (!match) return next(null, false);
+						return next(null, true);
+					});
+				}
+			],
+			async (err, match) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_CONFIRM_PASSWORD",
+						`Couldn't confirm password for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				if (match) {
+					this.log(
+						"SUCCESS",
+						"USER_CONFIRM_PASSWORD",
+						`Successfully checked for password match (it matched) for user "${session.userId}".`
+					);
+
+					return cb({
+						status: "success",
+						message: "Your password matches."
+					});
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_CONFIRM_PASSWORD",
+					`Successfully checked for password match (it didn't match) for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "error",
+					message: "Unfortunately your password doesn't match."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Checks if user's github access token has expired or not (ie. if their github account is still linked)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	confirmGithubLink: isLoginRequired(async function confirmGithubLink(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		return async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) => next(err, user));
+				},
+
+				(user, next) => {
+					if (!user.services.github) return next("You don't have GitHub linked to your account.");
+
+					return axios
+						.get(`https://api.github.com/user/emails`, {
+							headers: {
+								"User-Agent": "request",
+								Authorization: `token ${user.services.github.access_token}`
+							}
+						})
+						.then(res => next(null, res))
+						.catch(err => next(err));
+				},
+
+				(res, next) => next(null, res.status === 200)
+			],
+			async (err, linked) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_CONFIRM_GITHUB_LINK",
+						`Couldn't confirm github link for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_CONFIRM_GITHUB_LINK",
+					`GitHub is ${linked ? "linked" : "not linked"} for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { linked },
+					message: "Successfully checked if GitHub accounty was linked."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes all sessions for a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the id of the user we are trying to delete the sessions of
+	 * @param {Function} cb - gets called with the result
+	 */
+	removeSessions: isLoginRequired(async function removeSessions(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) => {
+						if (err) return next(err);
+						if (user.role !== "admin" && session.userId !== userId)
+							return next("Only admins and the owner of the account can remove their sessions.");
+						return next();
+					});
+				},
+
+				next => {
+					CacheModule.runJob("HGETALL", { table: "sessions" }, this)
+						.then(sessions => {
+							next(null, sessions);
+						})
+						.catch(next);
+				},
+
+				(sessions, next) => {
+					if (!sessions) return next("There are no sessions for this user to remove.");
+
+					const keys = Object.keys(sessions);
+
+					return next(null, keys, sessions);
+				},
+
+				(keys, sessions, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.removeSessions",
+						value: userId
+					});
+
+					// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
+					setTimeout(
+						() =>
+							async.each(
+								keys,
+								(sessionId, callback) => {
+									const session = sessions[sessionId];
+
+									if (session.userId === userId) {
+										// TODO Also maybe add this to this runJob
+										CacheModule.runJob("HDEL", {
+											table: "sessions",
+											key: sessionId
+										})
+											.then(() => callback(null))
+											.catch(callback);
+									}
+								},
+								err => {
+									next(err);
+								}
+							),
+						50
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REMOVE_SESSIONS_FOR_USER",
+						`Couldn't remove all sessions for user "${userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "REMOVE_SESSIONS_FOR_USER", `Removed all sessions for user "${userId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully removed all sessions."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates the order of a user's favorite stations
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} favoriteStations - array of station ids (with a specific order)
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateOrderOfFavoriteStations: isLoginRequired(async function updateOrderOfFavoriteStations(
+		session,
+		favoriteStations,
+		cb
+	) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $set: { favoriteStations } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+						`Couldn't update order of favorite stations for user "${session.userId}" to "${favoriteStations}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateOrderOfFavoriteStations",
+					value: {
+						favoriteStations,
+						userId: session.userId
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+					`Updated order of favorite stations for user "${session.userId}" to "${favoriteStations}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Order of favorite stations successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates the order of a user's playlists
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} orderOfPlaylists - array of playlist ids (with a specific order)
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateOrderOfPlaylists: isLoginRequired(async function updateOrderOfPlaylists(session, orderOfPlaylists, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $set: { "preferences.orderOfPlaylists": orderOfPlaylists } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ORDER_OF_USER_PLAYLISTS",
+						`Couldn't update order of playlists for user "${session.userId}" to "${orderOfPlaylists}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateOrderOfPlaylists",
+					value: {
+						orderOfPlaylists,
+						userId: session.userId
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_ORDER_OF_USER_PLAYLISTS",
+					`Updated order of playlists for user "${session.userId}" to "${orderOfPlaylists}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Order of playlists successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's preferences
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} preferences - object containing preferences
+	 * @param {boolean} preferences.nightmode - whether or not the user is using the night mode theme
+	 * @param {boolean} preferences.autoSkipDisliked - whether to automatically skip disliked songs
+	 * @param {boolean} preferences.activityLogPublic - whether or not a user's activity log can be publicly viewed
+	 * @param {boolean} preferences.anonymousSongRequests - whether or not a user's requested songs will be anonymous
+	 * @param {boolean} preferences.activityWatch - whether or not a user is using the ActivityWatch integration
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePreferences: isLoginRequired(async function updatePreferences(session, preferences, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					const $set = {};
+
+					Object.keys(preferences).forEach(preference => {
+						$set[`preferences.${preference}`] = preferences[preference];
+					});
+
+					return next(null, $set);
+				},
+
+				($set, next) => {
+					userModel.findByIdAndUpdate(session.userId, { $set }, { new: false, upsert: true }, next);
+				}
+			],
+			async (err, user) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_USER_PREFERENCES",
+						`Couldn't update preferences for user "${session.userId}" to "${JSON.stringify(
+							preferences
+						)}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updatePreferences",
+					value: {
+						preferences,
+						userId: session.userId
+					}
+				});
+
+				if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "user__toggle_nightmode",
+						payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
+					});
+
+				if (
+					preferences.autoSkipDisliked !== undefined &&
+					preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
+				)
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "user__toggle_autoskip_disliked_songs",
+						payload: {
+							message: preferences.autoSkipDisliked
+								? "Enabled the autoskipping of disliked songs"
+								: "Disabled the autoskipping of disliked songs"
+						}
+					});
+
+				if (
+					preferences.activityWatch !== undefined &&
+					preferences.activityWatch !== user.preferences.activityWatch
+				)
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "user__toggle_activity_watch",
+						payload: {
+							message: preferences.activityWatch
+								? "Enabled ActivityWatch integration"
+								: "Disabled ActivityWatch integration"
+						}
+					});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_USER_PREFERENCES",
+					`Updated preferences for user "${session.userId}" to "${JSON.stringify(preferences)}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Preferences successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Retrieves a user's preferences
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPreferences: isLoginRequired(async function updatePreferences(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findById(session.userId).select({ preferences: -1 }).exec(next);
+				}
+			],
+			async (err, { preferences }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"GET_USER_PREFERENCES",
+						`Couldn't retrieve preferences for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"GET_USER_PREFERENCES",
+					`Successfully obtained preferences for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Preferences successfully retrieved",
+					data: { preferences }
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets user object from username (only a few properties)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} username - the username of the user we are trying to find
+	 * @param {Function} cb - gets called with the result
+	 */
+	findByUsername: async function findByUsername(session, username, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
+				},
+
+				(account, next) => {
+					if (!account) return next("User not found.");
+					return next(null, account);
+				}
+			],
+			async (err, account) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "FIND_BY_USERNAME", `User found for username "${username}".`);
+
+				return cb({
+					status: "success",
+					data: {
+						_id: account._id,
+						name: account.name,
+						username: account.username,
+						location: account.location,
+						bio: account.bio,
+						role: account.role,
+						avatar: account.avatar,
+						createdAt: account.createdAt
+					}
+				});
+			}
+		);
+	},
+
+	/**
+	 * Gets a username from an userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the userId of the person we are trying to get the username from
+	 * @param {Function} cb - gets called with the result
+	 */
+	async getUsernameFromId(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		userModel
+			.findById(userId)
+			.then(user => {
+				if (user) {
+					this.log("SUCCESS", "GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+
+					return cb({
+						status: "success",
+						data: { username: user.username }
+					});
+				}
+
+				this.log(
+					"ERROR",
+					"GET_USERNAME_FROM_ID",
+					`Getting the username from userId "${userId}" failed. User not found.`
+				);
+
+				return cb({
+					status: "error",
+					message: "Couldn't find the user."
+				});
+			})
+			.catch(async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_USERNAME_FROM_ID",
+						`Getting the username from userId "${userId}" failed. "${err}"`
+					);
+					cb({ status: "error", message: err });
+				}
+			});
+	},
+
+	/**
+	 * Gets a user from a userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the userId of the person we are trying to get the username from
+	 * @param {Function} cb - gets called with the result
+	 */
+	getUserFromId: isAdminRequired(async function getUserFromId(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		userModel
+			.findById(userId)
+			.then(user => {
+				if (user) {
+					this.log("SUCCESS", "GET_USER_FROM_ID", `Found user for userId "${userId}".`);
+
+					return cb({
+						status: "success",
+						data: {
+							_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 }
+						}
+					});
+				}
+
+				this.log(
+					"ERROR",
+					"GET_USER_FROM_ID",
+					`Getting the user from userId "${userId}" failed. User not found.`
+				);
+
+				return cb({
+					status: "error",
+					message: "Couldn't find the user."
+				});
+			})
+			.catch(async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_USER_FROM_ID", `Getting the user from userId "${userId}" failed. "${err}"`);
+					cb({ status: "error", message: err });
+				}
+			});
+	}),
+
+	/**
+	 * Gets user info from session
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	findBySession: isLoginRequired(async function findBySession(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+
+				(session, next) => {
+					if (!session) return next("Session not found.");
+					return next(null, session);
+				},
+
+				(session, next) => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return next(null, user);
+				}
+			],
+			async (err, user) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "FIND_BY_SESSION", `User not found. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				const sanitisedUser = {
+					email: {
+						address: user.email.address
+					},
+					avatar: user.avatar,
+					username: user.username,
+					name: user.name,
+					location: user.location,
+					bio: user.bio
+				};
+
+				if (user.services.password && user.services.password.password) sanitisedUser.password = true;
+				if (user.services.github && user.services.github.id) sanitisedUser.github = true;
+
+				this.log("SUCCESS", "FIND_BY_SESSION", `User found. "${user.username}".`);
+				return cb({
+					status: "success",
+					data: { user: sanitisedUser }
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's username
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newUsername - the new username
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateUsername: isLoginRequired(async function updateUsername(session, updatingUserId, newUsername, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.username === newUsername)
+						return next("New username can't be the same as the old username.");
+					return next(null);
+				},
+
+				next => {
+					userModel.findOne({ username: new RegExp(`^${newUsername}$`, "i") }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next();
+					if (user._id === updatingUserId) return next();
+					return next("That username is already in use.");
+				},
+
+				next => {
+					userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { username: newUsername } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_USERNAME",
+						`Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateUsername",
+					value: {
+						username: newUsername,
+						_id: updatingUserId
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_USERNAME",
+					`Updated username for user "${updatingUserId}" to username "${newUsername}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Username updated successfully"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's email
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newEmail - the new email
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateEmail: isLoginRequired(async function updateEmail(session, updatingUserId, newEmail, cb) {
+		newEmail = newEmail.toLowerCase();
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.email.address === newEmail)
+						return next("New email can't be the same as your the old email.");
+					return next();
+				},
+
+				next => {
+					userModel.findOne({ "email.address": newEmail }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next();
+					if (user._id === updatingUserId) return next();
+					return next("That email is already in use.");
+				},
+
+				// regenerate the url for gravatar avatar
+				next => {
+					UtilsModule.runJob("CREATE_GRAVATAR", { email: newEmail }, this).then(url => {
+						next(null, url);
+					});
+				},
+
+				(newAvatarUrl, next) => {
+					userModel.updateOne(
+						{ _id: updatingUserId },
+						{
+							$set: {
+								"avatar.url": newAvatarUrl,
+								"email.address": newEmail,
+								"email.verified": false,
+								"email.verificationToken": verificationToken
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					verifyEmailSchema(newEmail, user.username, verificationToken, err => {
+						next(err);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_EMAIL",
+						`Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_EMAIL",
+					`Updated email for user "${updatingUserId}" to email "${newEmail}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Email updated successfully."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's name
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newBio - the new name
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateName: isLoginRequired(async function updateName(session, updatingUserId, newName, cb) {
+		const userModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "user"
+			},
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { name: newName } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_NAME",
+						`Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_name",
+					payload: { message: `Changed name to ${newName}` }
+				});
+
+				this.log("SUCCESS", "UPDATE_NAME", `Updated name for user "${updatingUserId}" to name "${newName}".`);
+
+				return cb({
+					status: "success",
+					message: "Name updated successfully"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's location
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newLocation - the new location
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateLocation: isLoginRequired(async function updateLocation(session, updatingUserId, newLocation, cb) {
+		const userModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "user"
+			},
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { location: newLocation } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_LOCATION",
+						`Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_location",
+					payload: { message: `Changed location to ${newLocation}` }
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_LOCATION",
+					`Updated location for user "${updatingUserId}" to location "${newLocation}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Location updated successfully"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's bio
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newBio - the new bio
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateBio: isLoginRequired(async function updateBio(session, updatingUserId, newBio, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { bio: newBio } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_BIO",
+						`Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_bio",
+					payload: { message: `Changed bio to ${newBio}` }
+				});
+
+				this.log("SUCCESS", "UPDATE_BIO", `Updated bio for user "${updatingUserId}" to bio "${newBio}".`);
+
+				return cb({
+					status: "success",
+					message: "Bio updated successfully"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's avatar
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newAvatar - the new avatar object
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateAvatar: isLoginRequired(async function updateAvatarType(session, updatingUserId, newAvatar, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.findOneAndUpdate(
+						{ _id: updatingUserId },
+						{ $set: { "avatar.type": newAvatar.type, "avatar.color": newAvatar.color } },
+						{ new: true, runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_AVATAR",
+						`Couldn't update avatar for user "${updatingUserId}" to type "${newAvatar.type}" and color "${newAvatar.color}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_avatar",
+					payload: { message: `Changed avatar to use ${newAvatar.type} and ${newAvatar.color}` }
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_AVATAR",
+					`Updated avatar for user "${updatingUserId}" to type "${newAvatar.type} and color ${newAvatar.color}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Avatar updated successfully"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's role
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newRole - the new role
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateRole: isAdminRequired(async function updateRole(session, updatingUserId, newRole, cb) {
+		newRole = newRole.toLowerCase();
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.role === newRole) return next("New role can't be the same as the old role.");
+					return next();
+				},
+				next => {
+					userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { role: newRole } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ROLE",
+						`User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_ROLE",
+					`User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Role successfully updated."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's password
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} previousPassword - the previous password
+	 * @param {string} newPassword - the new password
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePassword: isLoginRequired(async function updatePassword(session, previousPassword, newPassword, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user.services.password) return next("This account does not have a password set.");
+					return next(null, user.services.password.password);
+				},
+
+				(storedPassword, next) => {
+					bcrypt.compare(sha256(previousPassword), storedPassword).then(res => {
+						if (res) return next();
+						return next("Please enter the correct previous password.");
+					});
+				},
+
+				next => {
+					if (!DBModule.passwordValid(newPassword))
+						return next("Invalid new password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				next => {
+					bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(newPassword), salt, next);
+				},
+
+				(hashedPassword, next) => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{
+							$set: {
+								"services.password.password": hashedPassword
+							}
+						},
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_PASSWORD",
+						`Failed updating user password of user '${session.userId}'. '${err}'.`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UPDATE_PASSWORD", `User '${session.userId}' updated their password.`);
+
+				return cb({
+					status: "success",
+					message: "Password successfully updated."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Requests a password for a session
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} email - the email of the user that requests a password reset
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestPassword: isLoginRequired(async function requestPassword(session, cb) {
+		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
+		const passwordRequestSchema = await MailModule.runJob(
+			"GET_SCHEMA",
+			{
+				schemaName: "passwordRequest"
+			},
+			this
+		);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.services.password && user.services.password.password)
+						return next("You already have a password set.");
+					return next(null, user);
+				},
+
+				(user, next) => {
+					const expires = new Date();
+					expires.setDate(expires.getDate() + 1);
+					userModel.findOneAndUpdate(
+						{ "email.address": user.email.address },
+						{
+							$set: {
+								"services.password": {
+									set: { code, expires }
+								}
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(user, next) => {
+					passwordRequestSchema(user.email.address, user.username, code, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"REQUEST_PASSWORD",
+						`UserId '${session.userId}' failed to request password. '${err}'`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"REQUEST_PASSWORD",
+					`UserId '${session.userId}' successfully requested a password.`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully requested password."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Verifies a password code
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password code
+	 * @param {Function} cb - gets called with the result
+	 */
+	verifyPasswordCode: isLoginRequired(async function verifyPasswordCode(session, code, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne(
+						{
+							"services.password.set.code": code,
+							_id: session.userId
+						},
+						next
+					);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (user.services.password.set.expires < new Date()) return next("That code has expired.");
+					return next(null);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "VERIFY_PASSWORD_CODE", `Code '${code}' successfully verified.`);
+					cb({
+						status: "success",
+						message: "Successfully verified password code."
+					});
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Adds a password to a user with a code
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password code
+	 * @param {string} newPassword - the new password code
+	 * @param {Function} cb - gets called with the result
+	 */
+	changePasswordWithCode: isLoginRequired(async function changePasswordWithCode(session, code, newPassword, cb) {
+		const userModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "user"
+			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne({ "services.password.set.code": code }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (!user.services.password.set.expires > new Date()) return next("That code has expired.");
+					return next();
+				},
+
+				next => {
+					if (!DBModule.passwordValid(newPassword))
+						return next("Invalid password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				next => {
+					bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(newPassword), salt, next);
+				},
+
+				(hashedPassword, next) => {
+					userModel.updateOne(
+						{ "services.password.set.code": code },
+						{
+							$set: {
+								"services.password.password": hashedPassword
+							},
+							$unset: { "services.password.set": "" }
+						},
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.linkPassword",
+					value: session.userId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully added password."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Unlinks password from user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	unlinkPassword: isLoginRequired(async function unlinkPassword(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Not logged in.");
+					if (!user.services.github || !user.services.github.id)
+						return next("You can't remove password login without having GitHub login.");
+					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.password": "" } }, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UNLINK_PASSWORD",
+						`Unlinking password failed for userId '${session.userId}'. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UNLINK_PASSWORD", `Unlinking password successful for userId '${session.userId}'.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.unlinkPassword",
+					value: session.userId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully unlinked password."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Unlinks GitHub from user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	unlinkGitHub: isLoginRequired(async function unlinkGitHub(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Not logged in.");
+					if (!user.services.password || !user.services.password.password)
+						return next("You can't remove GitHub login without having password login.");
+					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.github": "" } }, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UNLINK_GITHUB",
+						`Unlinking GitHub failed for userId '${session.userId}'. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UNLINK_GITHUB", `Unlinking GitHub successful for userId '${session.userId}'.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.unlinkGithub",
+					value: session.userId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully unlinked GitHub."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Requests a password reset for an email
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} email - the email of the user that requests a password reset
+	 * @param {Function} cb - gets called with the result
+	 */
+	async requestPasswordReset(session, email, cb) {
+		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const resetPasswordRequestSchema = await MailModule.runJob(
+			"GET_SCHEMA",
+			{ schemaName: "resetPasswordRequest" },
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					if (!email || typeof email !== "string") return next("Invalid email.");
+					email = email.toLowerCase();
+					return userModel.findOne({ "email.address": email }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (!user.services.password || !user.services.password.password)
+						return next("User does not have a password set, and probably uses GitHub to log in.");
+					return next(null, user);
+				},
+
+				(user, next) => {
+					const expires = new Date();
+					expires.setDate(expires.getDate() + 1);
+					userModel.findOneAndUpdate(
+						{ "email.address": email },
+						{
+							$set: {
+								"services.password.reset": {
+									code,
+									expires
+								}
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(user, next) => {
+					resetPasswordRequestSchema(user.email.address, user.username, code, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_PASSWORD_RESET",
+						`Email '${email}' failed to request password reset. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"REQUEST_PASSWORD_RESET",
+					`Email '${email}' successfully requested a password reset.`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully requested password reset."
+				});
+			}
+		);
+	},
+
+	/**
+	 * Verifies a reset code
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password reset code
+	 * @param {Function} cb - gets called with the result
+	 */
+	async verifyPasswordResetCode(session, code, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne({ "services.password.reset.code": code }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (!user.services.password.reset.expires > new Date()) return next("That code has expired.");
+					return next(null);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
+
+				return 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 the websocket
+	 * @param {string} code - the password reset code
+	 * @param {string} newPassword - the new password reset code
+	 * @param {Function} cb - gets called with the result
+	 */
+	async changePasswordWithResetCode(session, code, newPassword, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne({ "services.password.reset.code": code }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (!user.services.password.reset.expires > new Date()) return next("That code has expired.");
+					return next();
+				},
+
+				next => {
+					if (!DBModule.passwordValid(newPassword))
+						return next("Invalid password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				next => {
+					bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(newPassword), salt, next);
+				},
+
+				(hashedPassword, next) => {
+					userModel.updateOne(
+						{ "services.password.reset.code": code },
+						{
+							$set: {
+								"services.password.password": hashedPassword
+							},
+							$unset: { "services.password.reset": "" }
+						},
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"CHANGE_PASSWORD_WITH_RESET_CODE",
+						`Code '${code}' failed to change password. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully changed password."
+				});
+			}
+		);
+	},
+
+	/**
+	 * Bans a user by userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} value - the user id that is going to be banned
+	 * @param {string} reason - the reason for the ban
+	 * @param {string} expiresAt - the time the ban expires
+	 * @param {Function} cb - gets called with the result
+	 */
+	banUserById: isAdminRequired(function banUserById(session, userId, reason, expiresAt, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!userId) return next("You must provide a userId to ban.");
+					if (!reason) return next("You must provide a reason for the ban.");
+					return next();
+				},
+
+				next => {
+					if (!expiresAt || typeof expiresAt !== "string") return next("Invalid expire date.");
+					const date = new Date();
+					switch (expiresAt) {
+						case "1h":
+							expiresAt = date.setHours(date.getHours() + 1);
+							break;
+						case "12h":
+							expiresAt = date.setHours(date.getHours() + 12);
+							break;
+						case "1d":
+							expiresAt = date.setDate(date.getDate() + 1);
+							break;
+						case "1w":
+							expiresAt = date.setDate(date.getDate() + 7);
+							break;
+						case "1m":
+							expiresAt = date.setMonth(date.getMonth() + 1);
+							break;
+						case "3m":
+							expiresAt = date.setMonth(date.getMonth() + 3);
+							break;
+						case "6m":
+							expiresAt = date.setMonth(date.getMonth() + 6);
+							break;
+						case "1y":
+							expiresAt = date.setFullYear(date.getFullYear() + 1);
+							break;
+						case "never":
+							expiresAt = new Date(3093527980800000);
+							break;
+						default:
+							return next("Invalid expire date.");
+					}
+
+					return next();
+				},
+
+				next => {
+					PunishmentsModule.runJob(
+						"ADD_PUNISHMENT",
+						{
+							type: "banUserId",
+							value: userId,
+							reason,
+							expiresAt,
+							punishedBy: session.userId
+						},
+						this
+					)
+						.then(punishment => next(null, punishment))
+						.catch(next);
+				},
+
+				(punishment, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.ban",
+						value: { userId, punishment }
+					});
+					next();
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"BAN_USER_BY_ID",
+						`User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"BAN_USER_BY_ID",
+					`User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully banned user."
+				});
+			}
+		);
+	})
 };
 };

+ 89 - 90
backend/logic/actions/utils.js

@@ -1,98 +1,97 @@
-"use strict";
+import async from "async";
 
 
-const async = require("async");
+import { isAdminRequired } from "./hooks";
 
 
-const hooks = require("./hooks");
+import moduleManager from "../../index";
 
 
-const utils = require("../utils");
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
 
 
-module.exports = {
-    getModules: hooks.adminRequired((session, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    next(null, utils.moduleManager.modules);
-                },
+export default {
+	getModules: isAdminRequired(function getModules(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					next(null, UtilsModule.moduleManager.modules);
+				},
 
 
-                (modules, next) => {
-                    // console.log(modules, next);
-                    next(
-                        null,
-                        Object.keys(modules).map((moduleName) => {
-                            const module = modules[moduleName];
-                            return {
-                                name: module.name,
-                                status: module.status,
-                                stage: module.stage,
-                                jobsInQueue: module.jobQueue.length(),
-                                jobsInProgress: module.jobQueue.running(),
-                                concurrency: module.jobQueue.concurrency,
-                            };
-                        })
-                    );
-                },
-            ],
-            async (err, modules) => {
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "GET_MODULES",
-                        `User ${session.userId} failed to get modules. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "GET_MODULES",
-                        `User ${session.userId} has successfully got the modules info.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully got modules.",
-                        modules,
-                    });
-                }
-            }
-        );
-    }),
+				(modules, next) => {
+					next(
+						null,
+						Object.keys(modules).map(moduleName => {
+							const module = modules[moduleName];
+							return {
+								name: module.name,
+								status: module.status,
+								stage: module.stage,
+								jobsInQueue: module.jobQueue.lengthQueue(),
+								jobsInProgress: module.jobQueue.lengthRunning(),
+								jobsPaused: module.jobQueue.lengthPaused(),
+								concurrency: module.jobQueue.concurrency
+							};
+						})
+					);
+				}
+			],
+			async (err, modules) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_MODULES", `User ${session.userId} failed to get modules. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_MODULES", `User ${session.userId} has successfully got the modules info.`);
+					cb({
+						status: "success",
+						message: "Successfully got modules.",
+						data: { modules }
+					});
+				}
+			}
+		);
+	}),
 
 
-    getModule: hooks.adminRequired((session, moduleName, cb) => {
-        async.waterfall(
-            [
-                (next) => {
-                    next(null, utils.moduleManager.modules[moduleName]);
-                },
-            ],
-            async (err, module) => {
-                const jobsInQueue = module.jobQueue._tasks.heap.map((task) => {
-                    return task.data;
-                });
+	getModule: isAdminRequired(function getModule(session, moduleName, cb) {
+		async.waterfall(
+			[
+				next => {
+					next(null, UtilsModule.moduleManager.modules[moduleName]);
+				}
+			],
+			async (err, module) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_MODULE", `User ${session.userId} failed to get module. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_MODULE", `User ${session.userId} has successfully got the module info.`);
+					cb({
+						status: "success",
+						message: "Successfully got module info.",
+						data: {
+							jobStatistics: module.jobStatistics
+						}
+					});
+				}
+			}
+		);
+	}),
 
 
-                // console.log(module.runningJobs);
-                if (err && err !== true) {
-                    err = await utils.runJob("GET_ERROR", { error: err });
-                    console.log(
-                        "ERROR",
-                        "GET_MODULE",
-                        `User ${session.userId} failed to get module. '${err}'`
-                    );
-                    cb({ status: "failure", message: err });
-                } else {
-                    console.log(
-                        "SUCCESS",
-                        "GET_MODULE",
-                        `User ${session.userId} has successfully got the module info.`
-                    );
-                    cb({
-                        status: "success",
-                        message: "Successfully got module info.",
-                        runningJobs: module.runningJobs,
-                        jobStatistics: module.jobStatistics,
-                        jobsInQueue,
-                    });
-                }
-            }
-        );
-    }),
+	getRooms(session, cb) {
+		WSModule.runJob("GET_ROOMS_FOR_SOCKET", { socketId: session.socketId })
+			.then(response => {
+				this.log("SUCCESS", "GET_ROOMS", `User ${session.userId} has successfully got the module info.`);
+				cb({
+					status: "success",
+					message: "Successfully got rooms.",
+					data: {
+						rooms: response
+					}
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "GET_ROOMS", `Failed to get rooms. '${err}'`);
+				cb({ status: "error", message: err });
+			});
+	}
 };
 };

+ 466 - 78
backend/logic/activities.js

@@ -1,80 +1,468 @@
-const CoreClass = require("../core.js");
-
-const async = require("async");
-const mongoose = require("mongoose");
-
-class ActivitiesModule extends CoreClass {
-    constructor() {
-        super("activities");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            this.db = this.moduleManager.modules["db"];
-            this.io = this.moduleManager.modules["io"];
-            this.utils = this.moduleManager.modules["utils"];
-
-            resolve();
-        });
-    }
-
-    // TODO: Migrate
-    ADD_ACTIVITY(payload) {
-        //userId, activityType, payload
-        return new Promise((resolve, reject) => {
-            async.waterfall(
-                [
-                    (next) => {
-                        this.db
-                            .runJob("GET_MODEL", { modelName: "activity" })
-                            .then((res) => {
-                                next(null, res);
-                            })
-                            .catch(next);
-                    },
-                    (activityModel, next) => {
-                        const activity = new activityModel({
-                            userId: payload.userId,
-                            activityType: payload.activityType,
-                            payload: payload.payload,
-                        });
-
-                        activity.save((err, activity) => {
-                            if (err) return next(err);
-                            next(null, activity);
-                        });
-                    },
-
-                    (activity, next) => {
-                        this.utils
-                            .runJob("SOCKETS_FROM_USER", {
-                                userId: activity.userId,
-                            })
-                            .then((response) => {
-                                response.sockets.forEach((socket) => {
-                                    socket.emit(
-                                        "event:activity.create",
-                                        activity
-                                    );
-                                });
-                                next();
-                            })
-                            .catch(next);
-                    },
-                ],
-                async (err, activity) => {
-                    if (err) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else {
-                        resolve({ activity });
-                    }
-                }
-            );
-        });
-    }
+import async from "async";
+
+import CoreClass from "../core";
+
+let ActivitiesModule;
+let DBModule;
+let CacheModule;
+let UtilsModule;
+let WSModule;
+let PlaylistsModule;
+
+class _ActivitiesModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("activities");
+
+		ActivitiesModule = this;
+	}
+
+	/**
+	 * Initialises the activities module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			DBModule = this.moduleManager.modules.db;
+			CacheModule = this.moduleManager.modules.cache;
+			UtilsModule = this.moduleManager.modules.utils;
+			WSModule = this.moduleManager.modules.ws;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Adds a new activity to the database
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user who's activity is to be added
+	 * @param {string} payload.type - the type of activity (enum specified in schema)
+	 * @param {object} payload.payload - the details of the activity e.g. an array of songs that were added
+	 * @param {string} payload.payload.message - the main message describing the activity e.g. 50 songs added to playlist 'playlist name'
+	 * @param {string} payload.payload.thumbnail - url to a thumbnail e.g. song album art to be used when display an activity
+	 * @param {string} payload.payload.youtubeId - (optional) if relevant, the youtube id of the song related to the activity
+	 * @param {string} payload.payload.reportId - (optional) if relevant, the id of the report related to the activity
+	 * @param {string} payload.payload.playlistId - (optional) if relevant, the id of the playlist related to the activity
+	 * @param {string} payload.payload.stationId - (optional) if relevant, the id of the station related to the activity
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	ADD_ACTIVITY(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "activity" }, this)
+							.then(res => next(null, res))
+							.catch(next);
+					},
+
+					(ActivityModel, next) => {
+						const { userId, type } = payload;
+
+						const activity = new ActivityModel({
+							userId,
+							type,
+							payload: payload.payload
+						});
+
+						activity.save(next);
+					},
+
+					(activity, next) => {
+						WSModule.runJob("SOCKETS_FROM_USER", { userId: activity.userId }, this)
+							.then(sockets => {
+								sockets.forEach(socket =>
+									socket.dispatch("event:activity.created", { data: { activity } })
+								);
+								next(null, activity);
+							})
+							.catch(next);
+					},
+
+					(activity, next) => {
+						const mergeableActivities = ["playlist__remove_song", "playlist__add_song"];
+
+						const spammableActivities = [
+							"user__toggle_nightmode",
+							"user__toggle_autoskip_disliked_songs",
+							"user__toggle_activity_watch",
+							"song__like",
+							"song__unlike",
+							"song__dislike",
+							"song__undislike"
+						];
+
+						CacheModule.runJob("HGET", { table: "recentActivities", key: activity.userId })
+							.then(recentActivity => {
+								if (recentActivity) {
+									const timeDifference = mins =>
+										new Date() - new Date(recentActivity.createdAt) < mins * 60 * 1000;
+
+									// if both activities have the same type, if within last 15 mins and if activity is within the spammableActivities array
+									if (
+										recentActivity.type === activity.type &&
+										!!timeDifference(15) &&
+										spammableActivities.includes(activity.type)
+									)
+										return ActivitiesModule.runJob(
+											"CHECK_FOR_ACTIVITY_SPAM_TO_HIDE",
+											{ userId: activity.userId, type: activity.type },
+											this
+										)
+											.then(() => next(null, activity))
+											.catch(next);
+
+									// if activity is within the mergeableActivities array, if both activities are about removing/adding and if within last 5 mins
+									if (
+										mergeableActivities.includes(activity.type) &&
+										recentActivity.type === activity.type &&
+										!!timeDifference(5)
+									) {
+										return PlaylistsModule.runJob("GET_PLAYLIST", {
+											playlistId: activity.payload.playlistId
+										})
+											.then(playlist =>
+												ActivitiesModule.runJob(
+													"CHECK_FOR_ACTIVITY_SPAM_TO_MERGE",
+													{
+														userId: activity.userId,
+														type: activity.type,
+														playlist: {
+															playlistId: playlist._id,
+															displayName: playlist.displayName
+														}
+													},
+													this
+												)
+													.then(() => next(null, activity))
+													.catch(next)
+											)
+											.catch(next);
+									}
+									return next(null, activity);
+								}
+
+								return next(null, activity);
+							})
+							.catch(next);
+					},
+
+					// store most recent activity in cache to be quickly accessible
+					(activity, next) =>
+						CacheModule.runJob(
+							"HSET",
+							{
+								table: "recentActivities",
+								key: activity.userId,
+								value: { createdAt: activity.createdAt, type: activity.type }
+							},
+							this
+						)
+							.then(() => next(null))
+							.catch(next)
+				],
+				async (err, activity) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve(activity);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Merges activities about adding/removing songs from a playlist within a 5-minute period to prevent spam
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user to check for duplicates
+	 * @param {object} payload.playlist - object that contains info about the relevant playlist
+	 * @param {string} payload.playlist.playlistId - the id of the playlist
+	 * @param {string} payload.playlist.displayName - the display name of the playlist
+	 * @param {string} payload.type - the type of activity to check for duplicates
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async CHECK_FOR_ACTIVITY_SPAM_TO_MERGE(payload) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					// find all activities of this type from the last 5 minutes
+					next => {
+						activityModel
+							.find(
+								{
+									userId: payload.userId,
+									type: { $in: [payload.type, `${payload.type}s`] },
+									hidden: false,
+									createdAt: {
+										$gte: new Date(new Date() - 5 * 60 * 1000)
+									},
+									"payload.playlistId": payload.playlist.playlistId
+								},
+								["_id", "type", "payload.message"]
+							)
+							.sort({ createdAt: -1 })
+							.exec(next);
+					},
+
+					// hide these activities, emit to socket listeners and count number of songs in each
+					(activities, next) => {
+						let howManySongs = 0; // how many songs added/removed
+
+						activities.forEach(activity => {
+							activityModel.updateOne({ _id: activity._id }, { $set: { hidden: true } }).catch(next);
+
+							WSModule.runJob("SOCKETS_FROM_USER", { userId: payload.userId }, this)
+								.then(sockets =>
+									sockets.forEach(socket =>
+										socket.dispatch("event:activity.hidden", { data: { activityId: activity._id } })
+									)
+								)
+								.catch(next);
+
+							if (activity.type === payload.type) howManySongs += 1;
+							else if (activity.type === `${payload.type}s`)
+								howManySongs += parseInt(
+									activity.payload.message.replace(
+										/(?:Removed|Added)\s(?<songs>\d+)\ssongs.+/g,
+										"$<songs>"
+									)
+								);
+						});
+
+						return next(null, howManySongs);
+					},
+
+					// // delete in cache the most recent activity to avoid issues when adding a new activity
+					(howManySongs, next) => {
+						CacheModule.runJob("HDEL", { table: "recentActivities", key: payload.userId }, this)
+							.then(() => next(null, howManySongs))
+							.catch(next);
+					},
+
+					// add a new activity that merges the activities together
+					(howManySongs, next) => {
+						const activity = {
+							userId: payload.userId,
+							type: "",
+							payload: {
+								message: "",
+								playlistId: payload.playlist.playlistId
+							}
+						};
+
+						if (payload.type === "playlist__remove_song" || payload.type === "playlist__remove_songs") {
+							activity.payload.message = `Removed ${howManySongs} songs from playlist <playlistId>${payload.playlist.displayName}</playlistId>`;
+							activity.type = "playlist__remove_songs";
+						} else if (payload.type === "playlist__add_song" || payload.type === "playlist__add_songs") {
+							activity.payload.message = `Added ${howManySongs} songs to playlist <playlistId>${payload.playlist.displayName}</playlistId>`;
+							activity.type = "playlist__add_songs";
+						}
+
+						ActivitiesModule.runJob("ADD_ACTIVITY", activity, this)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes any references to a station, playlist or song in activities
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.type - type of reference. enum: ["youtubeId", "stationId", "playlistId", "playlistId"]
+	 * @param {string} payload.stationId - (optional) the id of a station
+	 * @param {string} payload.reportId - (optional) the id of a report
+	 * @param {string} payload.playlistId - (optional) the id of a playlist
+	 * @param {string} payload.youtubeId - (optional) the id of a song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async REMOVE_ACTIVITY_REFERENCES(payload) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (
+							(payload.type !== "youtubeId" &&
+								payload.type !== "stationId" &&
+								payload.type !== "reportId" &&
+								payload.type !== "playlistId") ||
+							!payload.type
+						)
+							return next("Please use a valid reference type.");
+
+						if (!payload[payload.type]) return next(`Please provide a ${payload.type} in the job payload.`);
+
+						return next();
+					},
+
+					// find all activities that include the reference
+					next => {
+						const query = {};
+						query[`payload.${payload.type}`] = payload[payload.type];
+
+						activityModel
+							.find(query, ["_id", "userId", "payload.message"])
+							.sort({ createdAt: -1 })
+							.exec(next);
+					},
+
+					(activities, next) => {
+						async.eachLimit(
+							activities,
+							1,
+							(activity, next) => {
+								// remove the reference tags
+
+								if (payload.youtubeId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<youtubeId>(.*)<\/youtubeId>/g,
+										"$1"
+									);
+								}
+
+								if (payload.reportId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<reportId>(.*)<\/reportId>/g,
+										"$1"
+									);
+								}
+
+								if (payload.playlistId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<playlistId>(.*)<\/playlistId>/g,
+										`$1`
+									);
+								}
+
+								if (payload.stationId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<stationId>(.*)<\/stationId>/g,
+										`$1`
+									);
+								}
+
+								activityModel
+									.updateOne(
+										{ _id: activity._id },
+										{ $set: { "payload.message": activity.payload.message } }
+									)
+									.then(() => {
+										WSModule.runJob("SOCKETS_FROM_USER", { userId: activity.userId })
+											.then(sockets =>
+												sockets.forEach(socket =>
+													socket.dispatch("event:activity.updated", {
+														data: {
+															activityId: activity._id,
+															message: activity.payload.message
+														}
+													})
+												)
+											)
+											.catch(next);
+
+										return next();
+									})
+									.catch(next);
+							},
+							err => next(err)
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Hides any activities of the same type within a 15-minute period to prevent spam
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user to check for duplicates
+	 * @param {string} payload.type - the type of activity to check for duplicates
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async CHECK_FOR_ACTIVITY_SPAM_TO_HIDE(payload) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					// find all activities of this type from the last 15 minutes
+					next => {
+						activityModel
+							.find(
+								{
+									userId: payload.userId,
+									type: payload.type,
+									hidden: false,
+									createdAt: {
+										$gte: new Date(new Date() - 15 * 60 * 1000)
+									}
+								},
+								"_id"
+							)
+							.sort({ createdAt: -1 })
+							.skip(1)
+							.exec(next);
+					},
+
+					// hide these activities and emit to socket listeners
+					(activities, next) => {
+						activities.forEach(activity => {
+							activityModel.updateOne({ _id: activity._id }, { $set: { hidden: true } }).catch(next);
+
+							WSModule.runJob("SOCKETS_FROM_USER", { userId: payload.userId })
+								.then(sockets => {
+									sockets.forEach(socket =>
+										socket.dispatch("event:activity.hidden", {
+											data: { activityId: activity._id }
+										})
+									);
+								})
+								.catch(next);
+						});
+
+						return next();
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
 }
 }
 
 
-module.exports = new ActivitiesModule();
+export default new _ActivitiesModule();

+ 283 - 43
backend/logic/api.js

@@ -1,45 +1,285 @@
-const CoreClass = require("../core.js");
-
-class APIModule extends CoreClass {
-    constructor() {
-        super("api");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            const app = this.moduleManager.modules["app"];
-
-            const actions = require("./actions");
-
-            app.runJob("GET_APP", {})
-                .then((response) => {
-                    response.app.get("/", (req, res) => {
-                        res.json({
-                            status: "success",
-                            message: "Coming Soon",
-                        });
-                    });
-
-                    // Object.keys(actions).forEach(namespace => {
-                    //     Object.keys(actions[namespace]).forEach(action => {
-                    //         let name = `/${namespace}/${action}`;
-
-                    //         response.app.get(name, (req, res) => {
-                    //             actions[namespace][action](null, result => {
-                    //                 if (typeof cb === "function")
-                    //                     return res.json(result);
-                    //             });
-                    //         });
-                    //     });
-                    // });
-
-                    resolve();
-                })
-                .catch((err) => {
-                    reject(err);
-                });
-        });
-    }
+import config from "config";
+
+import async from "async";
+import crypto from "crypto";
+
+import CoreClass from "../core";
+
+let AppModule;
+let DBModule;
+let PlaylistsModule;
+let UtilsModule;
+let PunishmentsModule;
+let CacheModule;
+let NotificationsModule;
+
+class _APIModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("api");
+	}
+
+	/**
+	 * Initialises the api module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			AppModule = this.moduleManager.modules.app;
+			DBModule = this.moduleManager.modules.db;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+			UtilsModule = this.moduleManager.modules.utils;
+			PunishmentsModule = this.moduleManager.modules.punishments;
+			CacheModule = this.moduleManager.modules.cache;
+			NotificationsModule = this.moduleManager.modules.notifications;
+
+			const SIDname = config.get("cookie.SIDname");
+
+			const isLoggedIn = (req, res, next) => {
+				let SID;
+				async.waterfall(
+					[
+						next => {
+							UtilsModule.runJob("PARSE_COOKIES", {
+								cookieString: req.headers.cookie
+							})
+								.then(res => {
+									SID = res[SIDname];
+									next(null);
+								})
+								.catch(next);
+						},
+
+						next => {
+							if (!SID) return next("No SID.");
+							return next();
+						},
+
+						next => {
+							CacheModule.runJob("HGET", { table: "sessions", key: SID }).then(session =>
+								next(null, session)
+							);
+						},
+
+						(session, next) => {
+							if (!session) return next("No session found.");
+
+							session.refreshDate = Date.now();
+
+							req.session = session;
+
+							return CacheModule.runJob("HSET", {
+								table: "sessions",
+								key: SID,
+								value: session
+							}).then(session => {
+								next(null, session);
+							});
+						},
+
+						(res, next) => {
+							// check if a session's user / IP is banned
+							PunishmentsModule.runJob("GET_PUNISHMENTS", {})
+								.then(punishments => {
+									const isLoggedIn = !!(req.session && req.session.refreshDate);
+									const userId = isLoggedIn ? req.session.userId : null;
+
+									const banishment = { banned: false, ban: 0 };
+
+									punishments.forEach(punishment => {
+										if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
+										if (
+											punishment.type === "banUserId" &&
+											isLoggedIn &&
+											punishment.value === userId
+										)
+											banishment.banned = true;
+										if (punishment.type === "banUserIp" && punishment.value === req.ip)
+											banishment.banned = true;
+									});
+
+									req.banishment = banishment;
+
+									next();
+								})
+								.catch(() => {
+									next();
+								});
+						}
+					],
+					err => {
+						if (err) return res.json({ status: "error", message: "You are not logged in" });
+						return next();
+					}
+				);
+			};
+
+			AppModule.runJob("GET_APP", {})
+				.then(response => {
+					response.app.get("/", (req, res) => {
+						res.json({
+							status: "success",
+							message: "Coming Soon"
+						});
+					});
+
+					response.app.get("/export/playlist/:playlistId", async (req, res) => {
+						const { playlistId } = req.params;
+
+						const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId })
+							.then(playlist => {
+								if (playlist.privacy === "public") res.json({ status: "success", playlist });
+								else {
+									isLoggedIn(req, res, () => {
+										if (playlist.createdBy === req.session.userId)
+											res.json({ status: "success", playlist });
+										else {
+											userModel.findOne({ _id: req.session.userId }, (err, user) => {
+												if (err) res.json({ status: "error", message: err.message });
+												else if (user.role === "admin")
+													res.json({ status: "success", playlist });
+												else
+													res.json({
+														status: "error",
+														message: "You're not allowed to download this playlist."
+													});
+											});
+										}
+									});
+								}
+							})
+							.catch(err => {
+								res.json({ status: "error", message: err.message });
+							});
+					});
+
+					if (config.get("debug.stationIssue")) {
+						response.app.get("/debug_station", async (req, res) => {
+							const responseObject = {};
+
+							const stationModel = await DBModule.runJob("GET_MODEL", {
+								modelName: "station"
+							});
+
+							async.waterfall(
+								[
+									next => {
+										stationModel.find({}, next);
+									},
+
+									(stations, next) => {
+										responseObject.mongo = {
+											stations
+										};
+										next();
+									},
+
+									next => {
+										CacheModule.runJob("HGETALL", { table: "stations" })
+											.then(stations => {
+												next(null, stations);
+											})
+											.catch(err => {
+												console.log(err);
+												next(err);
+											});
+									},
+
+									(stations, next) => {
+										responseObject.redis = {
+											stations
+										};
+										next();
+									},
+
+									next => {
+										responseObject.cryptoExamples = {};
+										responseObject.mongo.stations.forEach(station => {
+											const payloadName = `stations.nextSong?id=${station._id}`;
+											responseObject.cryptoExamples[station._id] = crypto
+												.createHash("md5")
+												.update(`_notification:${payloadName}_`)
+												.digest("hex");
+										});
+										next();
+									},
+
+									next => {
+										NotificationsModule.pub.keys("*", next);
+									},
+
+									(redisKeys, next) => {
+										responseObject.redis = {
+											...redisKeys,
+											ttl: {}
+										};
+										async.eachLimit(
+											redisKeys,
+											1,
+											(redisKey, next) => {
+												NotificationsModule.pub.ttl(redisKey, (err, ttl) => {
+													responseObject.redis.ttl[redisKey] = ttl;
+													next(err);
+												});
+											},
+											next
+										);
+									},
+
+									next => {
+										responseObject.debugLogs = this.moduleManager.debugLogs.stationIssue;
+										next();
+									},
+
+									next => {
+										responseObject.debugJobs = this.moduleManager.debugJobs;
+										next();
+									}
+								],
+								err => {
+									if (err) {
+										console.log(err);
+										return res.json({
+											error: err,
+											objectSoFar: responseObject
+										});
+									}
+
+									const responseJson = JSON.stringify(responseObject, (key, value) => {
+										if (
+											key === "module" ||
+											key === "task" ||
+											key === "onFinish" ||
+											key === "server" ||
+											key === "nsp" ||
+											key === "socket" ||
+											key === "res" ||
+											key === "client" ||
+											key === "_idleNext" ||
+											key === "_idlePrev"
+										) {
+											return undefined;
+										}
+										if (key === "parentJob" && value) return value.toString();
+										return value;
+									});
+
+									return res.end(responseJson);
+								}
+							);
+						});
+					}
+
+					resolve();
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
 }
 }
 
 
-module.exports = new APIModule();
+export default new _APIModule();

+ 517 - 538
backend/logic/app.js

@@ -1,540 +1,519 @@
-const CoreClass = require("../core.js");
-
-const express = require("express");
-const bodyParser = require("body-parser");
-const cookieParser = require("cookie-parser");
-const cors = require("cors");
-const config = require("config");
-const async = require("async");
-const request = require("request");
-const OAuth2 = require("oauth").OAuth2;
-
-class AppModule extends CoreClass {
-    constructor() {
-        super("app");
-    }
-
-    initialize() {
-        return new Promise(async (resolve, reject) => {
-            const mail = this.moduleManager.modules["mail"],
-                cache = this.moduleManager.modules["cache"],
-                db = this.moduleManager.modules["db"],
-                activities = this.moduleManager.modules["activities"];
-
-            this.utils = this.moduleManager.modules["utils"];
-
-            let app = (this.app = express());
-            const SIDname = config.get("cookie.SIDname");
-            this.server = app.listen(config.get("serverPort"));
-
-            app.use(cookieParser());
-
-            app.use(bodyParser.json());
-            app.use(bodyParser.urlencoded({ extended: true }));
-
-            const userModel = await db.runJob("GET_MODEL", {
-                modelName: "user",
-            });
-
-            let corsOptions = Object.assign({}, config.get("cors"));
-
-            app.use(cors(corsOptions));
-            app.options("*", cors(corsOptions));
-
-            let oauth2 = new OAuth2(
-                config.get("apis.github.client"),
-                config.get("apis.github.secret"),
-                "https://github.com/",
-                "login/oauth/authorize",
-                "login/oauth/access_token",
-                null
-            );
-
-            let redirect_uri =
-                config.get("serverDomain") + "/auth/github/authorize/callback";
-
-            app.get("/auth/github/authorize", async (req, res) => {
-                if (this.getStatus() !== "READY") {
-                    this.log(
-                        "INFO",
-                        "APP_REJECTED_GITHUB_AUTHORIZE",
-                        `A user tried to use github authorize, but the APP module is currently not ready.`
-                    );
-                    return redirectOnErr(
-                        res,
-                        "Something went wrong on our end. Please try again later."
-                    );
-                }
-
-                let params = [
-                    `client_id=${config.get("apis.github.client")}`,
-                    `redirect_uri=${config.get(
-                        "serverDomain"
-                    )}/auth/github/authorize/callback`,
-                    `scope=user:email`,
-                ].join("&");
-                res.redirect(
-                    `https://github.com/login/oauth/authorize?${params}`
-                );
-            });
-
-            app.get("/auth/github/link", async (req, res) => {
-                if (this.getStatus() !== "READY") {
-                    this.log(
-                        "INFO",
-                        "APP_REJECTED_GITHUB_AUTHORIZE",
-                        `A user tried to use github authorize, but the APP module is currently not ready.`
-                    );
-                    return redirectOnErr(
-                        res,
-                        "Something went wrong on our end. Please try again later."
-                    );
-                }
-                let params = [
-                    `client_id=${config.get("apis.github.client")}`,
-                    `redirect_uri=${config.get(
-                        "serverDomain"
-                    )}/auth/github/authorize/callback`,
-                    `scope=user:email`,
-                    `state=${req.cookies[SIDname]}`,
-                ].join("&");
-                res.redirect(
-                    `https://github.com/login/oauth/authorize?${params}`
-                );
-            });
-
-            function redirectOnErr(res, err) {
-                return res.redirect(
-                    `${config.get("domain")}/?err=${encodeURIComponent(err)}`
-                );
-            }
-
-            app.get("/auth/github/authorize/callback", async (req, res) => {
-                if (this.getStatus() !== "READY") {
-                    this.log(
-                        "INFO",
-                        "APP_REJECTED_GITHUB_AUTHORIZE",
-                        `A user tried to use github authorize, but the APP module is currently not ready.`
-                    );
-                    return redirectOnErr(
-                        res,
-                        "Something went wrong on our end. Please try again later."
-                    );
-                }
-
-                let code = req.query.code;
-                let access_token;
-                let body;
-                let address;
-
-                const state = req.query.state;
-
-                const verificationToken = await this.utils.runJob(
-                    "GENERATE_RANDOM_STRING",
-                    { length: 64 }
-                );
-
-                async.waterfall(
-                    [
-                        (next) => {
-                            if (req.query.error)
-                                return next(req.query.error_description);
-                            next();
-                        },
-
-                        (next) => {
-                            oauth2.getOAuthAccessToken(
-                                code,
-                                { redirect_uri },
-                                next
-                            );
-                        },
-
-                        (_access_token, refresh_token, results, next) => {
-                            if (results.error)
-                                return next(results.error_description);
-                            access_token = _access_token;
-                            request.get(
-                                {
-                                    url: `https://api.github.com/user`,
-                                    headers: {
-                                        "User-Agent": "request",
-                                        Authorization: `token ${access_token}`,
-                                    },
-                                },
-                                next
-                            );
-                        },
-
-                        (httpResponse, _body, next) => {
-                            body = _body = JSON.parse(_body);
-                            if (httpResponse.statusCode !== 200)
-                                return next(body.message);
-                            if (state) {
-                                return async.waterfall(
-                                    [
-                                        (next) => {
-                                            cache
-                                                .runJob("HGET", {
-                                                    table: "sessions",
-                                                    key: state,
-                                                })
-                                                .then((session) =>
-                                                    next(null, session)
-                                                )
-                                                .catch(next);
-                                        },
-
-                                        (session, next) => {
-                                            if (!session)
-                                                return next("Invalid session.");
-                                            userModel.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."
-                                                );
-                                            userModel.updateOne(
-                                                { _id: user._id },
-                                                {
-                                                    $set: {
-                                                        "services.github": {
-                                                            id: body.id,
-                                                            access_token,
-                                                        },
-                                                    },
-                                                },
-                                                { runValidators: true },
-                                                (err) => {
-                                                    if (err) return next(err);
-                                                    next(null, user, body);
-                                                }
-                                            );
-                                        },
-
-                                        (user) => {
-                                            cache.runJob("PUB", {
-                                                channel: "user.linkGithub",
-                                                value: user._id,
-                                            });
-                                            res.redirect(
-                                                `${config.get(
-                                                    "domain"
-                                                )}/settings`
-                                            );
-                                        },
-                                    ],
-                                    next
-                                );
-                            }
-
-                            if (!body.id)
-                                return next("Something went wrong, no id.");
-
-                            userModel.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);
-                                });
-                            }
-
-                            userModel.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`,
-                                    headers: {
-                                        "User-Agent": "request",
-                                        Authorization: `token ${access_token}`,
-                                    },
-                                },
-                                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();
-                            });
-
-                            userModel.findOne(
-                                { "email.address": address },
-                                next
-                            );
-                        },
-
-                        (user, next) => {
-                            this.utils
-                                .runJob("GENERATE_RANDOM_STRING", {
-                                    length: 12,
-                                })
-                                .then((_id) => {
-                                    next(null, user, _id);
-                                });
-                        },
-
-                        (user, _id, next) => {
-                            if (user) {
-                                if (Object.keys(JSON.parse(user.services.github)).length === 0)
-                                    return next(`An account with that email address exists, but is not linked to GitHub.`)    
-                                else
-                                    return next(`An account with that email address already exists.`);
-                            }
-
-                            next(null, {
-                                _id, //TODO Check if exists
-                                username: body.login,
-                                name: body.name,
-                                location: body.location,
-                                bio: body.bio,
-                                email: {
-                                    address,
-                                    verificationToken,
-                                },
-                                services: {
-                                    github: { id: body.id, access_token },
-                                },
-                            });
-                        },
-
-                        // generate the url for gravatar avatar
-                        (user, next) => {
-                            this.utils
-                                .runJob("CREATE_GRAVATAR", {
-                                    email: user.email.address,
-                                })
-                                .then((url) => {
-                                    user.avatar = { type: "gravatar", url };
-                                    next(null, user);
-                                });
-                        },
-
-                        // save the new user to the database
-                        (user, next) => {
-                            userModel.create(user, next);
-                        },
-
-                        // add the activity of account creation
-                        (user, next) => {
-                            activities.runJob("ADD_ACTIVITY", {
-                                userId: user._id,
-                                activityType: "created_account",
-                            });
-                            next(null, user);
-                        },
-
-                        (user, next) => {
-                            mail.runJob("GET_SCHEMA", {
-                                schemaName: "verifyEmail",
-                            }).then((verifyEmailSchema) => {
-                                verifyEmailSchema(
-                                    address,
-                                    body.login,
-                                    user.email.verificationToken
-                                );
-                                next(null, user._id);
-                            });
-                        },
-                    ],
-                    async (err, userId) => {
-                        if (err && err !== true) {
-                            err = await this.utils.runJob("GET_ERROR", {
-                                error: err,
-                            });
-
-                            this.log(
-                                "ERROR",
-                                "AUTH_GITHUB_AUTHORIZE_CALLBACK",
-                                `Failed to authorize with GitHub. "${err}"`
-                            );
-
-                            return redirectOnErr(res, err);
-                        }
-
-                        const sessionId = await this.utils.runJob("GUID", {});
-                        const sessionSchema = await cache.runJob("GET_SCHEMA", {
-                            schemaName: "session",
-                        });
-                        cache
-                            .runJob("HSET", {
-                                table: "sessions",
-                                key: sessionId,
-                                value: sessionSchema(sessionId, userId),
-                            })
-                            .then(() => {
-                                let date = new Date();
-                                date.setTime(
-                                    new Date().getTime() +
-                                        2 * 365 * 24 * 60 * 60 * 1000
-                                );
-
-                                res.cookie(SIDname, sessionId, {
-                                    expires: date,
-                                    secure: config.get("cookie.secure"),
-                                    path: "/",
-                                    domain: config.get("cookie.domain"),
-                                });
-
-                                this.log(
-                                    "INFO",
-                                    "AUTH_GITHUB_AUTHORIZE_CALLBACK",
-                                    `User "${userId}" successfully authorized with GitHub.`
-                                );
-
-                                res.redirect(`${config.get("domain")}/`);
-                            })
-                            .catch((err) => {
-                                return redirectOnErr(res, err.message);
-                            });
-                    }
-                );
-            });
-
-            app.get("/auth/verify_email", async (req, res) => {
-                if (this.getStatus() !== "READY") {
-                    this.log(
-                        "INFO",
-                        "APP_REJECTED_GITHUB_AUTHORIZE",
-                        `A user tried to use github authorize, but the APP module is currently not ready.`
-                    );
-                    return redirectOnErr(
-                        res,
-                        "Something went wrong on our end. Please try again later."
-                    );
-                }
-
-                let code = req.query.code;
-
-                async.waterfall(
-                    [
-                        (next) => {
-                            if (!code) return next("Invalid code.");
-                            next();
-                        },
-
-                        (next) => {
-                            userModel.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.");
-
-                            userModel.updateOne(
-                                { "email.verificationToken": code },
-                                {
-                                    $set: { "email.verified": true },
-                                    $unset: { "email.verificationToken": "" },
-                                },
-                                { runValidators: true },
-                                next
-                            );
-                        },
-                    ],
-                    (err) => {
-                        if (err) {
-                            let error = "An error occurred.";
-
-                            if (typeof err === "string") error = err;
-                            else if (err.message) error = err.message;
-
-                            this.log(
-                                "ERROR",
-                                "VERIFY_EMAIL",
-                                `Verifying email failed. "${error}"`
-                            );
-
-                            return res.json({
-                                status: "failure",
-                                message: error,
-                            });
-                        }
-
-                        this.log(
-                            "INFO",
-                            "VERIFY_EMAIL",
-                            `Successfully verified email.`
-                        );
-
-                        res.redirect(
-                            `${config.get(
-                                "domain"
-                            )}?msg=Thank you for verifying your email`
-                        );
-                    }
-                );
-            });
-
-            resolve();
-        });
-    }
-
-    SERVER(payload) {
-        return new Promise((resolve, reject) => {
-            resolve(this.server);
-        });
-    }
-
-    GET_APP(payload) {
-        return new Promise((resolve, reject) => {
-            resolve({ app: this.app });
-        });
-    }
-
-    EXAMPLE_JOB(payload) {
-        return new Promise((resolve, reject) => {
-            if (true) {
-                resolve({});
-            } else {
-                reject(new Error("Nothing changed."));
-            }
-        });
-    }
+import config from "config";
+import axios from "axios";
+import async from "async";
+import cors from "cors";
+import cookieParser from "cookie-parser";
+import bodyParser from "body-parser";
+import express from "express";
+import oauth from "oauth";
+import http from "http";
+import CoreClass from "../core";
+
+const { OAuth2 } = oauth;
+
+let AppModule;
+let MailModule;
+let CacheModule;
+let DBModule;
+let ActivitiesModule;
+let PlaylistsModule;
+let UtilsModule;
+
+class _AppModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("app");
+
+		AppModule = this;
+	}
+
+	/**
+	 * Initialises the app module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			MailModule = this.moduleManager.modules.mail;
+			CacheModule = this.moduleManager.modules.cache;
+			DBModule = this.moduleManager.modules.db;
+			ActivitiesModule = this.moduleManager.modules.activities;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+			UtilsModule = this.moduleManager.modules.utils;
+
+			const app = (this.app = express());
+			const SIDname = config.get("cookie.SIDname");
+			this.server = http.createServer(app).listen(config.get("serverPort"));
+
+			app.use(cookieParser());
+
+			app.use(bodyParser.json());
+			app.use(bodyParser.urlencoded({ extended: true }));
+
+			let userModel;
+			DBModule.runJob("GET_MODEL", { modelName: "user" })
+				.then(model => {
+					userModel = model;
+				})
+				.catch(console.error);
+
+			const corsOptions = { ...config.get("cors"), credentials: true };
+
+			app.use(cors(corsOptions));
+			app.options("*", cors(corsOptions));
+
+			const oauth2 = new OAuth2(
+				config.get("apis.github.client"),
+				config.get("apis.github.secret"),
+				"https://github.com/",
+				"login/oauth/authorize",
+				"login/oauth/access_token",
+				null
+			);
+
+			const redirectUri = `${config.get("serverDomain")}/auth/github/authorize/callback`;
+
+			/**
+			 * @param {object} res - response object from Express
+			 * @param {string} err - custom error message
+			 */
+			function redirectOnErr(res, err) {
+				res.redirect(`${config.get("domain")}?err=${encodeURIComponent(err)}`);
+			}
+
+			app.get("/auth/github/authorize", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
+
+				const params = [
+					`client_id=${config.get("apis.github.client")}`,
+					`redirect_uri=${config.get("serverDomain")}/auth/github/authorize/callback`,
+					`scope=user:email`
+				].join("&");
+				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
+
+			app.get("/auth/github/link", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
+
+				const params = [
+					`client_id=${config.get("apis.github.client")}`,
+					`redirect_uri=${config.get("serverDomain")}/auth/github/authorize/callback`,
+					`scope=user:email`,
+					`state=${req.cookies[SIDname]}`
+				].join("&");
+				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
+
+			app.get("/auth/github/authorize/callback", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
+
+				const { code } = req.query;
+				let accessToken;
+				let body;
+				let address;
+
+				const { state } = req.query;
+
+				const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
+
+				return async.waterfall(
+					[
+						next => {
+							if (req.query.error) return next(req.query.error_description);
+							return next();
+						},
+
+						next => {
+							oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
+						},
+
+						(_accessToken, refreshToken, results, next) => {
+							if (results.error) return next(results.error_description);
+
+							accessToken = _accessToken;
+
+							const options = {
+								headers: {
+									"User-Agent": "request",
+									Authorization: `token ${accessToken}`
+								}
+							};
+
+							return axios
+								.get("https://api.github.com/user", options)
+								.then(github => next(null, github))
+								.catch(err => next(err));
+						},
+
+						(github, next) => {
+							if (github.status !== 200) return next(github.data.message);
+
+							if (state) {
+								return async.waterfall(
+									[
+										next => {
+											CacheModule.runJob("HGET", {
+												table: "sessions",
+												key: state
+											})
+												.then(session => next(null, session))
+												.catch(next);
+										},
+
+										(session, next) => {
+											if (!session) return next("Invalid session.");
+											return userModel.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.");
+
+											return userModel.updateOne(
+												{ _id: user._id },
+												{
+													$set: {
+														"services.github": {
+															id: github.data.id,
+															access_token: accessToken
+														}
+													}
+												},
+												{ runValidators: true },
+												err => {
+													if (err) return next(err);
+													return next(null, user, github.data);
+												}
+											);
+										},
+
+										user => {
+											CacheModule.runJob("PUB", {
+												channel: "user.linkGithub",
+												value: user._id
+											});
+
+											res.redirect(`${config.get("domain")}/settings?tab=security`);
+										}
+									],
+									next
+								);
+							}
+
+							if (!github.data.id) return next("Something went wrong, no id.");
+
+							return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
+								next(err, user, github.data);
+							});
+						},
+
+						(user, _body, next) => {
+							body = _body;
+
+							if (user) {
+								user.services.github.access_token = accessToken;
+								return user.save(() => next(true, user._id));
+							}
+
+							return userModel.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.`);
+
+							return axios
+								.get("https://api.github.com/user/emails", {
+									headers: {
+										"User-Agent": "request",
+										Authorization: `token ${accessToken}`
+									}
+								})
+								.then(res => next(null, res.data))
+								.catch(err => next(err));
+						},
+
+						(body, next) => {
+							if (!Array.isArray(body)) return next(body.message);
+
+							body.forEach(email => {
+								if (email.primary) address = email.email.toLowerCase();
+							});
+
+							return userModel.findOne({ "email.address": address }, next);
+						},
+
+						(user, next) => {
+							UtilsModule.runJob("GENERATE_RANDOM_STRING", {
+								length: 12
+							}).then(_id => next(null, user, _id));
+						},
+
+						(user, _id, next) => {
+							if (user) {
+								if (Object.keys(JSON.parse(user.services.github)).length === 0)
+									return next(
+										`An account with that email address exists, but is not linked to GitHub.`
+									);
+								return next(`An account with that email address already exists.`);
+							}
+
+							return next(null, {
+								_id,
+								username: body.login,
+								name: body.name,
+								location: body.location,
+								bio: body.bio,
+								email: {
+									address,
+									verificationToken
+								},
+								services: {
+									github: { id: body.id, access_token: accessToken }
+								}
+							});
+						},
+
+						// generate the url for gravatar avatar
+						(user, next) => {
+							UtilsModule.runJob("CREATE_GRAVATAR", {
+								email: user.email.address
+							}).then(url => {
+								user.avatar = { type: "gravatar", url };
+								next(null, user);
+							});
+						},
+
+						// save the new user to the database
+						(user, next) => {
+							userModel.create(user, next);
+						},
+
+						(user, next) => {
+							MailModule.runJob("GET_SCHEMA", {
+								schemaName: "verifyEmail"
+							}).then(verifyEmailSchema => {
+								verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
+									next(err, user._id);
+								});
+							});
+						},
+
+						// create a liked songs playlist for the new user
+						(userId, next) => {
+							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+								userId,
+								displayName: "Liked Songs",
+								type: "user"
+							})
+								.then(likedSongsPlaylist => {
+									next(null, likedSongsPlaylist, userId);
+								})
+								.catch(err => next(err));
+						},
+
+						// create a disliked songs playlist for the new user
+						(likedSongsPlaylist, userId, next) => {
+							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+								userId,
+								displayName: "Disliked Songs",
+								type: "user"
+							})
+								.then(dislikedSongsPlaylist => {
+									next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
+								})
+								.catch(err => next(err));
+						},
+
+						// associate liked + disliked songs playlist to the user object
+						({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
+							userModel.updateOne(
+								{ _id: userId },
+								{ $set: { likedSongsPlaylist, dislikedSongsPlaylist } },
+								{ runValidators: true },
+								err => {
+									if (err) return next(err);
+									return next(null, userId);
+								}
+							);
+						},
+
+						// add the activity of account creation
+						(userId, next) => {
+							ActivitiesModule.runJob("ADD_ACTIVITY", {
+								userId,
+								type: "user__joined",
+								payload: { message: "Welcome to Musare!" }
+							});
+
+							next(null, userId);
+						}
+					],
+					async (err, userId) => {
+						if (err && err !== true) {
+							err = await UtilsModule.runJob("GET_ERROR", {
+								error: err
+							});
+
+							this.log(
+								"ERROR",
+								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+								`Failed to authorize with GitHub. "${err}"`
+							);
+
+							return redirectOnErr(res, err);
+						}
+
+						const sessionId = await UtilsModule.runJob("GUID", {});
+						const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
+							schemaName: "session"
+						});
+
+						return CacheModule.runJob("HSET", {
+							table: "sessions",
+							key: sessionId,
+							value: sessionSchema(sessionId, userId)
+						})
+							.then(() => {
+								const date = new Date();
+								date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
+
+								res.cookie(SIDname, sessionId, {
+									expires: date,
+									secure: config.get("cookie.secure"),
+									path: "/",
+									domain: config.get("cookie.domain")
+								});
+
+								this.log(
+									"INFO",
+									"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+									`User "${userId}" successfully authorized with GitHub.`
+								);
+
+								res.redirect(`${config.get("domain")}/`);
+							})
+							.catch(err => redirectOnErr(res, err.message));
+					}
+				);
+			});
+
+			app.get("/auth/verify_email", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
+
+				const { code } = req.query;
+
+				return async.waterfall(
+					[
+						next => {
+							if (!code) return next("Invalid code.");
+							return next();
+						},
+
+						next => {
+							userModel.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.");
+
+							return userModel.updateOne(
+								{ "email.verificationToken": code },
+								{
+									$set: { "email.verified": true },
+									$unset: { "email.verificationToken": "" }
+								},
+								{ runValidators: true },
+								next
+							);
+						}
+					],
+					err => {
+						if (err) {
+							let error = "An error occurred.";
+
+							if (typeof err === "string") error = err;
+							else if (err.message) error = err.message;
+
+							this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${error}"`);
+
+							return res.json({
+								status: "error",
+								message: error
+							});
+						}
+
+						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
+
+						return res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
+					}
+				);
+			});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns the express server
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SERVER() {
+		return new Promise(resolve => {
+			resolve(AppModule.server);
+		});
+	}
+
+	/**
+	 * Returns the app object
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_APP() {
+		return new Promise(resolve => {
+			resolve({ app: AppModule.app });
+		});
+	}
+
+	// EXAMPLE_JOB() {
+	// 	return new Promise((resolve, reject) => {
+	// 		if (true) resolve({});
+	// 		else reject(new Error("Nothing changed."));
+	// 	});
+	// }
 }
 }
 
 
-module.exports = new AppModule();
+export default new _AppModule();

+ 279 - 260
backend/logic/cache/index.js

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

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

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

+ 3 - 7
backend/logic/cache/schemas/playlist.js

@@ -1,13 +1,9 @@
-'use strict';
-
 /**
 /**
  * Schema for a playlist stored / cached in redis,
  * Schema for a playlist stored / cached in redis,
  * gets created when a playlist is in use
  * gets created when a playlist is in use
  * and therefore is put into the redis cache
  * and therefore is put into the redis cache
  *
  *
- * @param playlist
- * @returns {Object}
+ * @param {object} playlist - object containing the playlist
+ * @returns {object} - returns same object
  */
  */
-module.exports = (playlist) => {
-	return playlist;
-};
+export default playlist => playlist;

+ 7 - 5
backend/logic/cache/schemas/punishment.js

@@ -1,5 +1,7 @@
-'use strict';
-
-module.exports = (punishment, punishmentId) => {
-	return { type: punishment.type, value: punishment.value, reason: punishment.reason, expiresAt: new Date(punishment.expiresAt).getTime(), punishmentId };
-};
+export default (punishment, punishmentId) => ({
+	type: punishment.type,
+	value: punishment.value,
+	reason: punishment.reason,
+	expiresAt: new Date(punishment.expiresAt).getTime(),
+	punishmentId
+});

+ 4 - 0
backend/logic/cache/schemas/recentActivity.js

@@ -0,0 +1,4 @@
+export default activity => ({
+	type: activity.type,
+	createdAt: activity.createdAt
+});

+ 6 - 10
backend/logic/cache/schemas/session.js

@@ -1,10 +1,6 @@
-'use strict';
-
-module.exports = (sessionId, userId) => {
-	return {
-		sessionId: sessionId,
-		userId: userId,
-		refreshDate: Date.now(),
-		created: Date.now()
-	};
-};
+export default (sessionId, userId) => ({
+	sessionId,
+	userId,
+	refreshDate: Date.now(),
+	created: Date.now()
+});

+ 1 - 5
backend/logic/cache/schemas/song.js

@@ -1,5 +1 @@
-'use strict';
-
-module.exports = (song) => {
-	return song;
-};
+export default song => song;

+ 3 - 7
backend/logic/cache/schemas/station.js

@@ -1,13 +1,9 @@
-'use strict';
-
 /**
 /**
  * Schema for a station stored / cached in redis,
  * Schema for a station stored / cached in redis,
  * gets created when a station is in use
  * gets created when a station is in use
  * and therefore is put into the redis cache
  * and therefore is put into the redis cache
  *
  *
- * @param station
- * @returns {Object}
+ * @param {object} station -  object containing the station
+ * @returns {object} - returns same object
  */
  */
-module.exports = (station) => {
-	return station;
-};
+export default station => station;

+ 306 - 362
backend/logic/db/index.js

@@ -1,372 +1,316 @@
-const CoreClass = require("../../core.js");
-
-const mongoose = require("mongoose");
-const config = require("config");
-
-const regex = {
-    azAZ09_: /^[A-Za-z0-9_]+$/,
-    az09_: /^[a-z0-9_]+$/,
-    emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
-    ascii: /^[\x00-\x7F]+$/,
-    custom: (regex) => new RegExp(`^[${regex}]+$`),
+import config from "config";
+import mongoose from "mongoose";
+import bluebird from "bluebird";
+import async from "async";
+
+import CoreClass from "../../core";
+
+const REQUIRED_DOCUMENT_VERSIONS = {
+	activity: 2,
+	news: 2,
+	playlist: 4,
+	punishment: 1,
+	queueSong: 1,
+	report: 5,
+	song: 5,
+	station: 6,
+	user: 3
 };
 };
 
 
-const isLength = (string, min, max) => {
-    return !(
-        typeof string !== "string" ||
-        string.length < min ||
-        string.length > max
-    );
+const regex = {
+	azAZ09_: /^[A-Za-z0-9_]+$/,
+	az09_: /^[a-z0-9_]+$/,
+	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
+	ascii: /^[\x00-\x7F]+$/,
+	name: /^[\p{L}0-9 .'_-]+$/u,
+	custom: regex => new RegExp(`^[${regex}]+$`)
 };
 };
 
 
-const bluebird = require("bluebird");
+const isLength = (string, min, max) => !(typeof string !== "string" || string.length < min || string.length > max);
 
 
 mongoose.Promise = bluebird;
 mongoose.Promise = bluebird;
 
 
-class DBModule extends CoreClass {
-    constructor() {
-        super("db");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            this.schemas = {};
-            this.models = {};
-
-            const mongoUrl = config.get("mongo").url;
-
-            mongoose
-                .connect(mongoUrl, {
-                    useNewUrlParser: true,
-                    useCreateIndex: true,
-                    reconnectInterval: 3000,
-                    reconnectTries: 10,
-                })
-                .then(() => {
-                    this.schemas = {
-                        song: new mongoose.Schema(require(`./schemas/song`)),
-                        queueSong: new mongoose.Schema(
-                            require(`./schemas/queueSong`)
-                        ),
-                        station: new mongoose.Schema(
-                            require(`./schemas/station`)
-                        ),
-                        user: new mongoose.Schema(require(`./schemas/user`)),
-                        activity: new mongoose.Schema(
-                            require(`./schemas/activity`)
-                        ),
-                        playlist: new mongoose.Schema(
-                            require(`./schemas/playlist`)
-                        ),
-                        news: new mongoose.Schema(require(`./schemas/news`)),
-                        report: new mongoose.Schema(
-                            require(`./schemas/report`)
-                        ),
-                        punishment: new mongoose.Schema(
-                            require(`./schemas/punishment`)
-                        ),
-                    };
-
-                    this.models = {
-                        song: mongoose.model("song", this.schemas.song),
-                        queueSong: mongoose.model(
-                            "queueSong",
-                            this.schemas.queueSong
-                        ),
-                        station: mongoose.model(
-                            "station",
-                            this.schemas.station
-                        ),
-                        user: mongoose.model("user", this.schemas.user),
-                        activity: mongoose.model(
-                            "activity",
-                            this.schemas.activity
-                        ),
-                        playlist: mongoose.model(
-                            "playlist",
-                            this.schemas.playlist
-                        ),
-                        news: mongoose.model("news", this.schemas.news),
-                        report: mongoose.model("report", this.schemas.report),
-                        punishment: mongoose.model(
-                            "punishment",
-                            this.schemas.punishment
-                        ),
-                    };
-
-                    mongoose.connection.on("error", (err) => {
-                        this.log("ERROR", err);
-                    });
-
-                    mongoose.connection.on("disconnected", () => {
-                        this.log(
-                            "ERROR",
-                            "Disconnected, going to try to reconnect..."
-                        );
-                        this.setStatus("RECONNECTING");
-                    });
-
-                    mongoose.connection.on("reconnected", () => {
-                        this.log("INFO", "Reconnected.");
-                        this.setStatus("READY");
-                    });
-
-                    mongoose.connection.on("reconnectFailed", () => {
-                        this.log(
-                            "INFO",
-                            "Reconnect failed, stopping reconnecting."
-                        );
-                        // this.failed = true;
-                        // this._lockdown();
-                        this.setStatus("FAILED");
-                    });
-
-                    // User
-                    this.schemas.user.path("username").validate((username) => {
-                        return (
-                            isLength(username, 2, 32) &&
-                            regex.custom("a-zA-Z0-9_-").test(username)
-                        );
-                    }, "Invalid username.");
-
-                    this.schemas.user
-                        .path("email.address")
-                        .validate((email) => {
-                            if (!isLength(email, 3, 254)) return false;
-                            if (email.indexOf("@") !== email.lastIndexOf("@"))
-                                return false;
-                            return (
-                                regex.emailSimple.test(email) &&
-                                regex.ascii.test(email)
-                            );
-                        }, "Invalid email.");
-
-                    // Station
-                    this.schemas.station.path("name").validate((id) => {
-                        return isLength(id, 2, 16) && regex.az09_.test(id);
-                    }, "Invalid station name.");
-
-                    this.schemas.station
-                        .path("displayName")
-                        .validate((displayName) => {
-                            return (
-                                isLength(displayName, 2, 32) &&
-                                regex.ascii.test(displayName)
-                            );
-                        }, "Invalid display name.");
-
-                    this.schemas.station
-                        .path("description")
-                        .validate((description) => {
-                            if (!isLength(description, 2, 200)) return false;
-                            let characters = description.split("");
-                            return (
-                                characters.filter((character) => {
-                                    return character.charCodeAt(0) === 21328;
-                                }).length === 0
-                            );
-                        }, "Invalid display name.");
-
-                    this.schemas.station.path("owner").validate({
-                        validator: (owner) => {
-                            return new Promise((resolve, reject) => {
-                                this.models.station.countDocuments(
-                                    { owner: owner },
-                                    (err, c) => {
-                                        if (err)
-                                            reject(
-                                                new Error(
-                                                    "A mongo error happened."
-                                                )
-                                            );
-                                        else if (c >= 3)
-                                            reject(
-                                                new Error(
-                                                    "User already has 3 stations."
-                                                )
-                                            );
-                                        else resolve();
-                                    }
-                                );
-                            });
-                        },
-                        message: "User already has 3 stations.",
-                    });
-
-                    /*
-					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
-						let totalDuration = 0;
-						queue.forEach((song) => {
-							totalDuration += song.duration;
+let DBModule;
+
+class _DBModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("db");
+
+		DBModule = this;
+	}
+
+	/**
+	 * Initialises the database module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.schemas = {};
+			this.models = {};
+
+			const mongoUrl = config.get("mongo").url;
+
+			mongoose
+				.connect(mongoUrl, {
+					useNewUrlParser: true,
+					useUnifiedTopology: true
+				})
+				.then(async () => {
+					this.schemas = {
+						song: {},
+						queueSong: {},
+						station: {},
+						user: {},
+						dataRequest: {},
+						activity: {},
+						playlist: {},
+						news: {},
+						report: {},
+						punishment: {}
+					};
+
+					const importSchema = schemaName =>
+						new Promise(resolve => {
+							import(`./schemas/${schemaName}`).then(schema => {
+								this.schemas[schemaName] = new mongoose.Schema(schema.default);
+								return resolve();
+							});
 						});
 						});
-						return callback(totalDuration <= 3600 * 3);
-					}, 'The max length of the queue is 3 hours.');
-		
-					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
-						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.');
-		
-					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
-						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.');
-					*/
-
-                    // Song
-                    let songTitle = (title) => {
-                        return isLength(title, 1, 100);
-                    };
-                    this.schemas.song
-                        .path("title")
-                        .validate(songTitle, "Invalid title.");
-                    this.schemas.queueSong
-                        .path("title")
-                        .validate(songTitle, "Invalid title.");
-
-                    this.schemas.song.path("artists").validate((artists) => {
-                        return !(artists.length < 1 || artists.length > 10);
-                    }, "Invalid artists.");
-                    this.schemas.queueSong
-                        .path("artists")
-                        .validate((artists) => {
-                            return !(artists.length < 0 || artists.length > 10);
-                        }, "Invalid artists.");
-
-                    let songArtists = (artists) => {
-                        return (
-                            artists.filter((artist) => {
-                                return (
-                                    isLength(artist, 1, 64) && artist !== "NONE"
-                                );
-                            }).length === artists.length
-                        );
-                    };
-                    this.schemas.song
-                        .path("artists")
-                        .validate(songArtists, "Invalid artists.");
-                    this.schemas.queueSong
-                        .path("artists")
-                        .validate(songArtists, "Invalid artists.");
-
-                    let songGenres = (genres) => {
-                        if (genres.length < 1 || genres.length > 16)
-                            return false;
-                        return (
-                            genres.filter((genre) => {
-                                return (
-                                    isLength(genre, 1, 32) &&
-                                    regex.ascii.test(genre)
-                                );
-                            }).length === genres.length
-                        );
-                    };
-                    this.schemas.song
-                        .path("genres")
-                        .validate(songGenres, "Invalid genres.");
-                    this.schemas.queueSong
-                        .path("genres")
-                        .validate(songGenres, "Invalid genres.");
-
-                    let songThumbnail = (thumbnail) => {
-                        if (!isLength(thumbnail, 1, 256)) return false;
-                        if (config.get("cookie.secure") === true)
-                            return thumbnail.startsWith("https://");
-                        else
-                            return (
-                                thumbnail.startsWith("http://") ||
-                                thumbnail.startsWith("https://")
-                            );
-                    };
-                    this.schemas.song
-                        .path("thumbnail")
-                        .validate(songThumbnail, "Invalid thumbnail.");
-                    this.schemas.queueSong
-                        .path("thumbnail")
-                        .validate(songThumbnail, "Invalid thumbnail.");
-
-                    // Playlist
-                    this.schemas.playlist
-                        .path("displayName")
-                        .validate((displayName) => {
-                            return (
-                                isLength(displayName, 1, 32) &&
-                                regex.ascii.test(displayName)
-                            );
-                        }, "Invalid display name.");
-
-                    this.schemas.playlist
-                        .path("createdBy")
-                        .validate((createdBy) => {
-                            this.models.playlist.countDocuments(
-                                { createdBy: createdBy },
-                                (err, c) => {
-                                    return !(err || c >= 10);
-                                }
-                            );
-                        }, "Max 10 playlists per user.");
-
-                    this.schemas.playlist.path("songs").validate((songs) => {
-                        return songs.length <= 5000;
-                    }, "Max 5000 songs per playlist.");
-
-                    this.schemas.playlist.path("songs").validate((songs) => {
-                        if (songs.length === 0) return true;
-                        return songs[0].duration <= 10800;
-                    }, "Max 3 hours per song.");
-
-                    // Report
-                    this.schemas.report
-                        .path("description")
-                        .validate((description) => {
-                            return (
-                                !description ||
-                                (isLength(description, 0, 400) &&
-                                    regex.ascii.test(description))
-                            );
-                        }, "Invalid description.");
-
-                    resolve();
-                })
-                .catch((err) => {
-                    this.log("ERROR", err);
-                    reject(err);
-                });
-        });
-    }
-
-    GET_MODEL(payload) {
-        return new Promise((resolve, reject) => {
-            resolve(this.models[payload.modelName]);
-        });
-    }
-
-    GET_SCHEMA(payload) {
-        return new Promise((resolve, reject) => {
-            resolve(this.schemas[payload.schemaName]);
-        });
-    }
-
-    passwordValid(password) {
-        return isLength(password, 6, 200);
-    }
+
+					await importSchema("song");
+					await importSchema("queueSong");
+					await importSchema("station");
+					await importSchema("user");
+					await importSchema("dataRequest");
+					await importSchema("activity");
+					await importSchema("playlist");
+					await importSchema("news");
+					await importSchema("report");
+					await importSchema("punishment");
+
+					this.models = {
+						song: mongoose.model("song", this.schemas.song),
+						queueSong: mongoose.model("queueSong", this.schemas.queueSong),
+						station: mongoose.model("station", this.schemas.station),
+						user: mongoose.model("user", this.schemas.user),
+						dataRequest: mongoose.model("dataRequest", this.schemas.dataRequest),
+						activity: mongoose.model("activity", this.schemas.activity),
+						playlist: mongoose.model("playlist", this.schemas.playlist),
+						news: mongoose.model("news", this.schemas.news),
+						report: mongoose.model("report", this.schemas.report),
+						punishment: mongoose.model("punishment", this.schemas.punishment)
+					};
+
+					mongoose.connection.on("error", err => {
+						this.log("ERROR", err);
+					});
+
+					mongoose.connection.on("disconnected", () => {
+						this.log("ERROR", "Disconnected, going to try to reconnect...");
+						this.setStatus("RECONNECTING");
+					});
+
+					mongoose.connection.on("reconnected", () => {
+						this.log("INFO", "Reconnected.");
+						this.setStatus("READY");
+					});
+
+					mongoose.connection.on("reconnectFailed", () => {
+						this.log("INFO", "Reconnect failed, stopping reconnecting.");
+						this.setStatus("FAILED");
+					});
+
+					// User
+					this.schemas.user
+						.path("username")
+						.validate(
+							username =>
+								isLength(username, 2, 32) &&
+								regex.custom("a-zA-Z0-9_-").test(username) &&
+								username.replaceAll(/[_]/g, "").length > 0,
+							"Invalid username."
+						);
+
+					this.schemas.user.path("email.address").validate(email => {
+						if (!isLength(email, 3, 254)) return false;
+						if (email.indexOf("@") !== email.lastIndexOf("@")) return false;
+						return regex.emailSimple.test(email) && regex.ascii.test(email);
+					}, "Invalid email.");
+
+					this.schemas.user
+						.path("name")
+						.validate(
+							name =>
+								isLength(name, 1, 64) &&
+								regex.name.test(name) &&
+								name.replaceAll(/[ .'_-]/g, "").length > 0,
+							"Invalid name."
+						);
+
+					// Station
+					this.schemas.station
+						.path("name")
+						.validate(id => isLength(id, 2, 16) && regex.az09_.test(id), "Invalid station name.");
+
+					this.schemas.station
+						.path("displayName")
+						.validate(
+							displayName => isLength(displayName, 2, 32) && regex.ascii.test(displayName),
+							"Invalid display name."
+						);
+
+					this.schemas.station.path("description").validate(description => {
+						if (!isLength(description, 2, 200)) return false;
+						const characters = description.split("");
+						return characters.filter(character => character.charCodeAt(0) === 21328).length === 0;
+					}, "Invalid display name.");
+
+					this.schemas.station.path("owner").validate({
+						validator: owner =>
+							new Promise((resolve, reject) => {
+								this.models.station.countDocuments({ owner }, (err, c) => {
+									if (err) reject(new Error("A mongo error happened."));
+									else if (c >= 25) reject(new Error("User already has 25 stations."));
+									else resolve();
+								});
+							}),
+						message: "User already has 25 stations."
+					});
+
+					// Song
+					const songTitle = title => isLength(title, 1, 100);
+					this.schemas.song.path("title").validate(songTitle, "Invalid title.");
+
+					this.schemas.song.path("artists").validate(artists => artists.length <= 10, "Invalid artists.");
+
+					const songArtists = artists =>
+						artists.filter(artist => isLength(artist, 1, 64) && artist !== "NONE").length ===
+						artists.length;
+					this.schemas.song.path("artists").validate(songArtists, "Invalid artists.");
+
+					const songGenres = genres => {
+						if (genres.length > 16) return false;
+						return (
+							genres.filter(genre => isLength(genre, 1, 32) && regex.ascii.test(genre)).length ===
+							genres.length
+						);
+					};
+					this.schemas.song.path("genres").validate(songGenres, "Invalid genres.");
+
+					const songThumbnail = thumbnail => {
+						if (!isLength(thumbnail, 1, 256)) return false;
+						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
+						return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
+					};
+					this.schemas.song.path("thumbnail").validate(songThumbnail, "Invalid thumbnail.");
+
+					// Playlist
+					this.schemas.playlist
+						.path("displayName")
+						.validate(
+							displayName => isLength(displayName, 1, 32) && regex.ascii.test(displayName),
+							"Invalid display name."
+						);
+
+					this.schemas.playlist.path("createdBy").validate(createdBy => {
+						this.models.playlist.countDocuments({ createdBy }, (err, c) => !(err || c >= 100));
+					}, "Max 100 playlists per user.");
+
+					this.schemas.playlist
+						.path("songs")
+						.validate(songs => songs.length <= 10000, "Max 10000 songs per playlist.");
+
+					// this.schemas.playlist.path("songs").validate(songs => {
+					// 	if (songs.length === 0) return true;
+					// 	return songs[0].duration <= 10800;
+					// }, "Max 3 hours per song.");
+
+					this.schemas.playlist.index({ createdFor: 1, type: 1 }, { unique: true });
+
+					if (config.get("skipDbDocumentsVersionCheck")) resolve();
+					else {
+						this.runJob("CHECK_DOCUMENT_VERSIONS", {}, null, -1)
+							.then(() => {
+								resolve();
+							})
+							.catch(err => {
+								reject(err);
+							});
+					}
+				})
+				.catch(err => {
+					this.log("ERROR", err);
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Checks if all documents have the correct document version
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CHECK_DOCUMENT_VERSIONS() {
+		return new Promise((resolve, reject) => {
+			async.each(
+				Object.keys(REQUIRED_DOCUMENT_VERSIONS),
+				(modelName, next) => {
+					const model = DBModule.models[modelName];
+					const requiredDocumentVersion = REQUIRED_DOCUMENT_VERSIONS[modelName];
+					model.countDocuments({ documentVersion: { $ne: requiredDocumentVersion } }, (err, count) => {
+						if (err) next(err);
+						else if (count > 0)
+							next(
+								`Collection "${modelName}" has ${count} documents with a wrong document version. Run migration.`
+							);
+						else next();
+					});
+				},
+				err => {
+					if (err) reject(new Error(err));
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns a database model
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.modelName - name of the model to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MODEL(payload) {
+		return new Promise(resolve => {
+			resolve(DBModule.models[payload.modelName]);
+		});
+	}
+
+	/**
+	 * Returns a database schema
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.schemaName - name of the schema to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SCHEMA(payload) {
+		return new Promise(resolve => {
+			resolve(DBModule.schemas[payload.schemaName]);
+		});
+	}
+
+	/**
+	 * Checks if a password to be stored in the database has a valid length
+	 *
+	 * @param {object} password - the password itself
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	passwordValid(password) {
+		return isLength(password, 6, 200);
+	}
 }
 }
 
 
-module.exports = new DBModule();
+export default new _DBModule();

+ 54 - 13
backend/logic/db/schemas/activity.js

@@ -1,16 +1,57 @@
-module.exports = {
+export default {
 	createdAt: { type: Date, default: Date.now, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
 	hidden: { type: Boolean, default: false, required: true },
 	hidden: { type: Boolean, default: false, required: true },
 	userId: { type: String, required: true },
 	userId: { type: String, required: true },
-	activityType: { type: String, enum: [
-		"created_account",
-		"created_station",
-		"deleted_station",
-		"created_playlist",
-		"deleted_playlist",
-		"liked_song",
-		"added_song_to_playlist",
-		"added_songs_to_playlist"
-	], required: true },
-	payload: { type: Array, required: true }
-}
+	type: {
+		type: String,
+		enum: [
+			/** User */
+			"user__joined",
+			"user__edit_bio",
+			"user__edit_avatar",
+			"user__edit_name",
+			"user__edit_location",
+			"user__toggle_nightmode",
+			"user__toggle_autoskip_disliked_songs",
+			"user__toggle_activity_watch",
+			/** Songs */
+			"song__report",
+			"song__like",
+			"song__dislike",
+			"song__unlike",
+			"song__undislike",
+			/** Stations */
+			"station__favorite",
+			"station__unfavorite",
+			"station__create",
+			"station__remove",
+			"station__edit_name",
+			"station__edit_display_name",
+			"station__edit_description",
+			"station__edit_theme",
+			"station__edit_privacy",
+			"station__edit_genres",
+			"station__edit_blacklisted_genres",
+			/** Playlists */
+			"playlist__create",
+			"playlist__remove",
+			"playlist__remove_song",
+			"playlist__remove_songs",
+			"playlist__add_song",
+			"playlist__add_songs",
+			"playlist__edit_privacy",
+			"playlist__edit_display_name",
+			"playlist__import_playlist"
+		],
+		required: true
+	},
+	payload: {
+		message: { type: String, default: "", required: true },
+		thumbnail: { type: String, required: false },
+		youtubeId: { type: String, required: false },
+		stationId: { type: String, required: false },
+		playlistId: { type: String, required: false },
+		reportId: { type: String, required: false }
+	},
+	documentVersion: { type: Number, default: 2, required: true }
+};

+ 7 - 0
backend/logic/db/schemas/dataRequest.js

@@ -0,0 +1,7 @@
+export default {
+	userId: { type: String, required: true },
+	createdAt: { type: Date, default: Date.now, required: true },
+	type: { type: String, required: true, enum: ["remove"] },
+	resolved: { type: Boolean, default: false },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 5 - 7
backend/logic/db/schemas/news.js

@@ -1,10 +1,8 @@
-module.exports = {
+export default {
 	title: { type: String, required: true },
 	title: { type: String, required: true },
-	description: { type: String, required: true },
-	bugs: [{ type: String }],
-	features: [{ type: String }],
-	improvements: [{ type: String }],
-	upcoming: [{ type: String }],
+	markdown: { type: String, required: true },
+	status: { type: String, enum: ["draft", "published", "archived"], required: true, default: "published" },
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Number, default: Date.now, required: true }
+	createdAt: { type: Number, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 2, required: true }
 };
 };

+ 20 - 3
backend/logic/db/schemas/playlist.js

@@ -1,6 +1,23 @@
-module.exports = {
+import mongoose from "mongoose";
+
+export default {
 	displayName: { type: String, min: 2, max: 32, required: true },
 	displayName: { type: String, min: 2, max: 32, required: true },
-	songs: { type: Array },
+	isUserModifiable: { type: Boolean, default: true, required: true },
+	songs: [
+		{
+			_id: { type: mongoose.Schema.Types.ObjectId, required: false },
+			youtubeId: { type: String },
+			title: { type: String },
+			duration: { type: Number },
+			thumbnail: { type: String, required: false },
+			artists: { type: Array, required: false },
+			status: { type: String }
+		}
+	],
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Date, default: Date.now, required: true }
+	createdAt: { type: Date, default: Date.now, required: true },
+	createdFor: { type: String },
+	privacy: { type: String, enum: ["public", "private"], default: "private" },
+	type: { type: String, enum: ["user", "genre", "station"], required: true },
+	documentVersion: { type: Number, default: 4, required: true }
 };
 };

+ 4 - 3
backend/logic/db/schemas/punishment.js

@@ -1,9 +1,10 @@
-module.exports = {
+export default {
 	type: { type: String, enum: ["banUserId", "banUserIp"], required: true },
 	type: { type: String, enum: ["banUserId", "banUserIp"], required: true },
 	value: { type: String, required: true },
 	value: { type: String, required: true },
-	reason: { type: String, required: true, default: 'Unknown' },
+	reason: { type: String, required: true, default: "Unknown" },
 	active: { type: Boolean, required: true, default: true },
 	active: { type: Boolean, required: true, default: true },
 	expiresAt: { type: Date, required: true },
 	expiresAt: { type: Date, required: true },
 	punishedAt: { type: Date, default: Date.now, required: true },
 	punishedAt: { type: Date, default: Date.now, required: true },
-	punishedBy: { type: String, required: true }
+	punishedBy: { type: String, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
 };
 };

+ 3 - 2
backend/logic/db/schemas/queueSong.js

@@ -1,4 +1,4 @@
-module.exports = {
+export default {
 	songId: { type: String, min: 11, max: 11, required: true, index: 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 }],
@@ -9,5 +9,6 @@ module.exports = {
 	explicit: { type: Boolean, required: true },
 	explicit: { type: Boolean, required: true },
 	requestedBy: { type: String, required: true },
 	requestedBy: { type: String, required: true },
 	requestedAt: { type: Date, default: Date.now, required: true },
 	requestedAt: { type: Date, default: Date.now, required: true },
-	discogs: { type: Object }
+	discogs: { type: Object },
+	documentVersion: { type: Number, default: 1, required: true }
 };
 };

+ 16 - 8
backend/logic/db/schemas/report.js

@@ -1,14 +1,22 @@
-module.exports = {
+export default {
 	resolved: { type: Boolean, default: false, required: true },
 	resolved: { type: Boolean, default: false, required: true },
 	song: {
 	song: {
 		_id: { type: String, required: true },
 		_id: { type: String, required: true },
-		songId: { type: String, required: true },
+		youtubeId: { type: String, required: true }
 	},
 	},
-	description: { type: String },
-	issues: [{
-		name: String,
-		reasons: Array
-	}],
+	issues: [
+		{
+			category: {
+				type: String,
+				enum: ["custom", "video", "title", "duration", "artists", "thumbnail"],
+				required: true
+			},
+			title: { type: String, required: true },
+			description: { type: String, required: false },
+			resolved: { type: Boolean, default: false, required: true }
+		}
+	],
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Date, default: Date.now, required: true }
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 5, required: true }
 };
 };

+ 16 - 14
backend/logic/db/schemas/song.js

@@ -1,17 +1,19 @@
-module.exports = {
-	songId: { type: String, min: 11, max: 11, required: true, index: true },
+export default {
+	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
 	title: { type: String, required: true },
 	title: { type: String, required: true },
-	artists: [{ type: String }],
-	genres: [{ type: String }],
-	duration: { type: Number, required: true },
-	skipDuration: { type: Number, required: true },
-	thumbnail: { type: String, required: true },
+	artists: [{ type: String, default: [] }],
+	genres: [{ type: String, default: [] }],
+	duration: { type: Number, min: 1, required: true },
+	skipDuration: { type: Number, required: true, default: 0 },
+	thumbnail: { type: String },
 	likes: { type: Number, default: 0, required: true },
 	likes: { type: Number, default: 0, required: true },
 	dislikes: { type: Number, default: 0, required: true },
 	dislikes: { type: Number, default: 0, required: true },
-	explicit: { type: Boolean, default: false, required: true },
-	requestedBy: { type: String, required: true },
-	requestedAt: { type: Date, required: true },
-	acceptedBy: { type: String, required: true },
-	acceptedAt: { type: Date, default: Date.now, required: true },
-	discogs: { type: Object }
-};
+	explicit: { type: Boolean },
+	requestedBy: { type: String },
+	requestedAt: { type: Date },
+	verifiedBy: { type: String },
+	verifiedAt: { type: Date },
+	discogs: { type: Object },
+	status: { type: String, required: true, default: "hidden", enum: ["hidden", "unverified", "verified"] },
+	documentVersion: { type: Number, default: 5, required: true }
+};

+ 27 - 21
backend/logic/db/schemas/station.js

@@ -1,43 +1,49 @@
-const mongoose = require('mongoose');
+import mongoose from "mongoose";
 
 
-module.exports = {
+export default {
 	name: { 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: {
-		songId: { type: String },
+		_id: { type: String },
+		youtubeId: { type: String },
 		title: { type: String },
 		title: { type: String },
 		artists: [{ type: String }],
 		artists: [{ type: String }],
 		duration: { type: Number },
 		duration: { type: Number },
 		skipDuration: { type: Number },
 		skipDuration: { type: Number },
 		thumbnail: { type: String },
 		thumbnail: { type: String },
-		likes: { type: Number, default: -1 },
-		dislikes: { type: Number, default: -1 },
 		skipVotes: [{ type: String }],
 		skipVotes: [{ type: String }],
+		requestedBy: { type: String },
+		requestedAt: { type: Date },
+		status: { type: String }
 	},
 	},
 	currentSongIndex: { type: Number, default: 0, required: true },
 	currentSongIndex: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
 	pausedAt: { type: Number, default: 0, required: true },
 	pausedAt: { type: Number, default: 0, required: true },
 	startedAt: { type: Number, default: 0, required: true },
 	startedAt: { type: Number, default: 0, required: true },
-	playlist: { type: Array },
-	genres: [{ type: String }],
-	blacklistedGenres: [{ type: String }],
+	playlist: { type: mongoose.Schema.Types.ObjectId, required: true },
 	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: [{
-		songId: { type: String, required: true },
-		title: { type: String },
-		artists: [{ type: String }],
-		duration: { type: Number },
-		skipDuration: { type: Number },
-		thumbnail: { type: String },
-		likes: { type: Number, default: -1 },
-		dislikes: { type: Number, default: -1 },
-		requestedBy: { type: String, required: true }
-	}],
+	queue: [
+		{
+			youtubeId: { type: String, required: true },
+			title: { type: String },
+			artists: [{ type: String }],
+			duration: { type: Number },
+			skipDuration: { type: Number },
+			thumbnail: { type: String },
+			requestedBy: { type: String },
+			requestedAt: { type: Date },
+			status: { type: String }
+		}
+	],
 	owner: { type: String },
 	owner: { type: String },
-	privatePlaylist: { type: mongoose.Schema.Types.ObjectId },
-	partyMode: { type: Boolean }
+	partyMode: { type: Boolean },
+	playMode: { type: String, enum: ["random", "sequential"], default: "random" },
+	theme: { type: String, enum: ["blue", "purple", "teal", "orange"], default: "blue" },
+	includedPlaylists: [{ type: String }],
+	excludedPlaylists: [{ type: String }],
+	documentVersion: { type: Number, default: 6, required: true }
 };
 };

+ 20 - 8
backend/logic/db/schemas/user.js

@@ -1,14 +1,17 @@
-module.exports = {
+import mongoose from "mongoose";
+
+export default {
 	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: {
 		verified: { type: Boolean, default: false, required: true },
 		verified: { type: Boolean, default: false, required: true },
 		verificationToken: String,
 		verificationToken: String,
 		address: String
 		address: String
 	},
 	},
 	avatar: {
 	avatar: {
-		type: { type: String, enum: ["gravatar", "initials"] },
-		url: { type: String, required: false }
+		type: { type: String, enum: ["gravatar", "initials"], required: true },
+		url: { type: String, required: false },
+		color: { type: String, enum: ["blue", "orange", "green", "purple", "teal"], required: false }
 	},
 	},
 	services: {
 	services: {
 		password: {
 		password: {
@@ -30,11 +33,20 @@ module.exports = {
 	statistics: {
 	statistics: {
 		songsRequested: { type: Number, default: 0, required: true }
 		songsRequested: { type: Number, default: 0, required: true }
 	},
 	},
-	liked: [{ type: String }],
-	disliked: [{ type: String }],
+	likedSongsPlaylist: { type: mongoose.Schema.Types.ObjectId },
+	dislikedSongsPlaylist: { type: mongoose.Schema.Types.ObjectId },
 	favoriteStations: [{ type: String }],
 	favoriteStations: [{ type: String }],
-	name: { type: String, default: "" },
+	name: { type: String, required: true },
 	location: { type: String, default: "" },
 	location: { type: String, default: "" },
 	bio: { type: String, default: "" },
 	bio: { type: String, default: "" },
-	createdAt: { type: Date, default: Date.now }
+	createdAt: { type: Date, default: Date.now },
+	preferences: {
+		orderOfPlaylists: [{ type: mongoose.Schema.Types.ObjectId }],
+		nightmode: { type: Boolean, default: false, required: true },
+		autoSkipDisliked: { type: Boolean, default: true, required: true },
+		activityLogPublic: { type: Boolean, default: false, required: true },
+		anonymousSongRequests: { type: Boolean, default: false, required: true },
+		activityWatch: { type: Boolean, default: false, required: true }
+	},
+	documentVersion: { type: Number, default: 3, required: true }
 };
 };

+ 0 - 113
backend/logic/discord.js

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

+ 0 - 382
backend/logic/io.js

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

+ 88 - 48
backend/logic/mail/index.js

@@ -1,50 +1,90 @@
-const CoreClass = require("../../core.js");
-
-const config = require("config");
-
-let mailgun = null;
-
-class MailModule extends CoreClass {
-    constructor() {
-        super("mail");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            this.schemas = {
-                verifyEmail: require("./schemas/verifyEmail"),
-                resetPasswordRequest: require("./schemas/resetPasswordRequest"),
-                passwordRequest: require("./schemas/passwordRequest"),
-            };
-
-            this.enabled = config.get("apis.mailgun.enabled");
-
-            if (this.enabled)
-                mailgun = require("mailgun-js")({
-                    apiKey: config.get("apis.mailgun.key"),
-                    domain: config.get("apis.mailgun.domain"),
-                });
-
-            resolve();
-        });
-    }
-
-    SEND_MAIL(payload) {
-        //data, cb
-        return new Promise((resolve, reject) => {
-            if (this.enabled)
-                mailgun.messages().send(payload.data, () => {
-                    resolve();
-                });
-            else resolve();
-        });
-    }
-
-    GET_SCHEMA(payload) {
-        return new Promise((resolve, reject) => {
-            resolve(this.schemas[payload.schemaName]);
-        });
-    }
+/* eslint-disable global-require */
+import config from "config";
+import nodemailer from "nodemailer";
+
+import CoreClass from "../../core";
+
+let MailModule;
+
+class _MailModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("mail");
+
+		MailModule = this;
+	}
+
+	/**
+	 * Initialises the mail module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		const importSchema = schemaName =>
+			new Promise(resolve => {
+				import(`./schemas/${schemaName}`).then(schema => resolve(schema.default));
+			});
+
+		this.schemas = {
+			verifyEmail: await importSchema("verifyEmail"),
+			resetPasswordRequest: await importSchema("resetPasswordRequest"),
+			passwordRequest: await importSchema("passwordRequest"),
+			dataRequest: await importSchema("dataRequest")
+		};
+
+		this.enabled = config.get("smtp.enabled");
+
+		if (this.enabled)
+			this.transporter = nodemailer.createTransport({
+				host: config.get("smtp.host"),
+				port: config.get("smtp.port"),
+				secure: config.get("smtp.secure"),
+				auth: {
+					user: config.get("smtp.auth.user"),
+					pass: config.get("smtp.auth.pass")
+				}
+			});
+
+		return new Promise(resolve => resolve());
+	}
+
+	/**
+	 * Sends an email
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.data - information such as to, from in order to send the email
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEND_MAIL(payload) {
+		return new Promise((resolve, reject) => {
+			if (MailModule.enabled)
+				return MailModule.transporter
+					.sendMail(payload.data)
+					.then(info => {
+						MailModule.log("SUCCESS", "MAIL_SEND", `Successfully sent email ${info.messageId}`);
+						return resolve();
+					})
+					.catch(err => {
+						MailModule.log("ERROR", "MAIL_SEND", `Failed to send email. ${err}`);
+						return reject();
+					});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns an email schema
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.schemaName - name of the schema to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SCHEMA(payload) {
+		return new Promise(resolve => {
+			resolve(MailModule.schemas[payload.schemaName]);
+		});
+	}
 }
 }
 
 
-module.exports = new MailModule();
+export default new _MailModule();

+ 34 - 0
backend/logic/mail/schemas/dataRequest.js

@@ -0,0 +1,34 @@
+import config from "config";
+
+import mail from "../index";
+
+/**
+ * Sends an email to all admins that a user has submitted a data request
+ *
+ * @param {string} to - an array of email addresses of admins
+ * @param {string} userId - the id of the user the data request is for
+ * @param {string} type - the type of data request e.g. remove
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+export default (to, userId, type, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: `Data Request - ${type}`,
+		html: `
+				Hello,
+				<br>
+				<br>
+				User ${userId} has requested to ${type} the data for their account on Musare.
+				<br>
+				<br>
+				This request can be viewed and resolved in the <a href="${config.get(
+					"domain"
+				)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
+			`
+	};
+
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

+ 14 - 22
backend/logic/mail/schemas/passwordRequest.js

@@ -1,23 +1,19 @@
-const config = require("config");
-
-// const moduleManager = require('../../../index');
-
-const mail = require("../index");
+import mail from "../index";
 
 
 /**
 /**
  * Sends a request password email
  * 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 {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
  * @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: `
+export default (to, username, code, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: "Password request",
+		html: `
 				Hello there ${username},
 				Hello there ${username},
 				<br>
 				<br>
 				<br>
 				<br>
@@ -26,13 +22,9 @@ module.exports = function(to, username, code, cb) {
 				<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.
 				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.runJob("SEND_MAIL", { data })
-        .then(() => {
-            cb();
-        })
-        .catch(err => {
-            cb(err);
-        });
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
 };
 };

+ 14 - 22
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,23 +1,19 @@
-const config = require("config");
-
-// const moduleManager = require('../../../index');
-
-const mail = require("../index");
+import mail from "../index";
 
 
 /**
 /**
  * Sends a request password reset email
  * 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 {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
  * @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: `
+export default (to, username, code, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: "Password reset request",
+		html: `
 				Hello there ${username},
 				Hello there ${username},
 				<br>
 				<br>
 				<br>
 				<br>
@@ -26,13 +22,9 @@ module.exports = function(to, username, code, cb) {
 				<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.
 				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.runJob("SEND_MAIL", { data })
-        .then(() => {
-            cb();
-        })
-        .catch(err => {
-            cb(err);
-        });
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
 };
 };

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

@@ -1,39 +1,30 @@
-const config = require("config");
-
-// const moduleManager = require('../../../index');
-
-const mail = require("../index");
+import config from "config";
+import mail from "../index";
 
 
 /**
 /**
  * Sends a verify email email
  * 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 {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
  * @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: `
+export default (to, username, code, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: "Please verify your email",
+		html: `
 				Hello there ${username},
 				Hello there ${username},
 				<br>
 				<br>
 				<br>
 				<br>
-				To verify your email, please visit <a href="${config.get(
-                    "serverDomain"
-                )}/auth/verify_email?code=${code}">${config.get(
-            "serverDomain"
-        )}/auth/verify_email?code=${code}</a>.
+				To verify your email, please visit <a href="${config.get("serverDomain")}/auth/verify_email?code=${code}">${config.get(
+			"serverDomain"
+		)}/auth/verify_email?code=${code}</a>.
 			`
 			`
-    };
+	};
 
 
-    mail.runJob("SEND_MAIL", { data })
-        .then(() => {
-            cb();
-        })
-        .catch(err => {
-            cb(err);
-        });
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
 };
 };

+ 136 - 0
backend/logic/migration/index.js

@@ -0,0 +1,136 @@
+import async from "async";
+import config from "config";
+import mongoose from "mongoose";
+import bluebird from "bluebird";
+import fs from "fs";
+
+import { fileURLToPath } from "url";
+import path from "path";
+
+import CoreClass from "../../core";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+let MigrationModule;
+
+mongoose.Promise = bluebird;
+
+class _MigrationModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("migration");
+
+		MigrationModule = this;
+	}
+
+	/**
+	 * Initialises the migration module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.models = {};
+
+			const mongoUrl = config.get("mongo").url;
+
+			mongoose
+				.connect(mongoUrl, {
+					useNewUrlParser: true,
+					useUnifiedTopology: true
+				})
+				.then(async () => {
+					mongoose.connection.on("error", err => this.log("ERROR", err));
+
+					mongoose.connection.on("disconnected", () => {
+						this.log("ERROR", "Disconnected, going to try to reconnect...");
+						this.setStatus("RECONNECTING");
+					});
+
+					mongoose.connection.on("reconnected", () => {
+						this.log("INFO", "Reconnected.");
+						this.setStatus("READY");
+					});
+
+					mongoose.connection.on("reconnectFailed", () => {
+						this.log("INFO", "Reconnect failed, stopping reconnecting.");
+						this.setStatus("FAILED");
+					});
+
+					this.models = {
+						song: mongoose.model("song", new mongoose.Schema({}, { strict: false })),
+						queueSong: mongoose.model("queueSong", new mongoose.Schema({}, { strict: false })),
+						station: mongoose.model("station", new mongoose.Schema({}, { strict: false })),
+						user: mongoose.model("user", new mongoose.Schema({}, { strict: false })),
+						activity: mongoose.model("activity", new mongoose.Schema({}, { strict: false })),
+						playlist: mongoose.model("playlist", new mongoose.Schema({}, { strict: false })),
+						news: mongoose.model("news", new mongoose.Schema({}, { strict: false })),
+						report: mongoose.model("report", new mongoose.Schema({}, { strict: false })),
+						punishment: mongoose.model("punishment", new mongoose.Schema({}, { strict: false }))
+					};
+
+					const files = fs.readdirSync(path.join(__dirname, "migrations"));
+					const migrations = files.length;
+
+					async.timesLimit(
+						migrations,
+						1,
+						(index, next) => {
+							MigrationModule.runJob("RUN_MIGRATION", { index: index + 1 }, null, -1)
+								.then(() => next())
+								.catch(err => next(err));
+						},
+						err => {
+							if (err) console.log("Migration error", err);
+							else console.log("Migration completed");
+						}
+					);
+
+					resolve();
+				})
+				.catch(err => {
+					this.log("ERROR", err);
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Returns a database model
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.modelName - name of the model to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MODEL(payload) {
+		return new Promise(resolve => {
+			resolve(MigrationModule.models[payload.modelName]);
+		});
+	}
+
+	/**
+	 * Runs migrations
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.index - migration index
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RUN_MIGRATION(payload) {
+		return new Promise((resolve, reject) => {
+			import(`./migrations/migration${payload.index}`).then(module => {
+				this.log("INFO", `Running migration ${payload.index}`);
+				module.default
+					.apply(this, [MigrationModule])
+					.then(response => {
+						resolve(response);
+					})
+					.catch(err => {
+						reject(err);
+					});
+			});
+		});
+	}
+}
+
+export default new _MigrationModule();

+ 176 - 0
backend/logic/migration/migrations/migration1.js

@@ -0,0 +1,176 @@
+import async from "async";
+
+/**
+ * Migration 1
+ *
+ * This migration is used to set the documentVersion to 1 for all documents that don't have a documentVersion yet, meaning they were created before the migration system
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const activityModel = await MigrationModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+	const newsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "news" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const punishmentModel = await MigrationModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
+	const queueSongModel = await MigrationModule.runJob("GET_MODEL", { modelName: "queueSong" }, this);
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					activityModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (activity). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					newsModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (news). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					playlistModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (playlist). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					punishmentModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (punishment). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					queueSongModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (queueSong). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					reportModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (report). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					songModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (song). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					stationModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (station). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					userModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (user). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 62 - 0
backend/logic/migration/migrations/migration10.js

@@ -0,0 +1,62 @@
+import async from "async";
+
+/**
+ * Migration 10
+ *
+ * Migration for changes in how the order of songs in a playlist is handled
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 10. Finding playlists with document version 3.`);
+					playlistModel.find({ documentVersion: 3 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlisti => playlisti._doc),
+								1,
+								(playlisti, next) => {
+									// sort playlists by the position property
+									playlisti.songs.sort((song1, song2) => song1.position - song2.position);
+
+									// delete the position property for each song
+									playlisti.songs.forEach(song => delete song.position);
+
+									// update the database
+									playlistModel.updateOne(
+										{ _id: playlisti._id },
+										{
+											$set: {
+												songs: playlisti.songs,
+												documentVersion: 4
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 10. Playlists found: ${playlists.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 59 - 0
backend/logic/migration/migrations/migration11.js

@@ -0,0 +1,59 @@
+import async from "async";
+
+/**
+ * Migration 11
+ *
+ * Migration for changing language of verifying a song from 'accepted' to 'verified' for songs
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 11. Finding songs with document version 4.`);
+					songModel.find({ documentVersion: 4 }, (err, songs) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								songs.map(songi => songi._doc),
+								1,
+								(songi, next) =>
+									songModel.updateOne(
+										{ _id: songi._id },
+										{
+											$set: {
+												verifiedBy: songi.acceptedBy,
+												verifiedAt: songi.acceptedAt,
+												documentVersion: 5
+											},
+											$unset: {
+												acceptedBy: "",
+												acceptedAt: ""
+											}
+										},
+										next
+									),
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 11. Songs found: ${songs.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 67 - 0
backend/logic/migration/migrations/migration12.js

@@ -0,0 +1,67 @@
+import async from "async";
+
+/**
+ * Migration 12
+ *
+ * Migration for updated style of reports
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 12. Finding reports with document version 2.`);
+					reportModel.find({ documentVersion: 2 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const issues = [];
+
+									if (reporti.description !== "")
+										issues.push({ category: "custom", info: reporti.description });
+
+									reporti.issues.forEach(category =>
+										category.reasons.forEach(info => issues.push({ category: category.name, info }))
+									);
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 4,
+												issues
+											},
+											$unset: {
+												description: ""
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 12. Reports found: ${reports.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 60 - 0
backend/logic/migration/migrations/migration13.js

@@ -0,0 +1,60 @@
+import async from "async";
+
+/**
+ * Migration 13
+ *
+ * Migration for allowing titles, descriptions and individual resolving for report issues
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 13. Finding reports with document version 4.`);
+					reportModel.find({ documentVersion: 4 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const { issues } = reporti;
+
+									issues.forEach(issue => {
+										issue.title = issue.info;
+										issue.resolved = reporti.resolved;
+										delete issue.info;
+									});
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 5,
+												issues
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 13. Reports found: ${reports.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 65 - 0
backend/logic/migration/migrations/migration14.js

@@ -0,0 +1,65 @@
+import async from "async";
+
+/**
+ * Migration 14
+ *
+ * Migration for removing some data from stations
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 14. Finding stations with document version 5.`);
+					stationModel.find({ documentVersion: 5 }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations.map(station => station._doc),
+								1,
+								(station, next) => {
+									const { queue, currentSong } = station;
+
+									if (currentSong && currentSong.likes) {
+										delete currentSong.likes;
+										delete currentSong.dislikes;
+									}
+
+									queue.forEach(song => {
+										delete song.likes;
+										delete song.dislikes;
+									});
+
+									stationModel.updateOne(
+										{ _id: station._id },
+										{
+											$set: {
+												documentVersion: 6,
+												queue,
+												currentSong
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 14. Stations found: ${stations.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 46 - 0
backend/logic/migration/migrations/migration15.js

@@ -0,0 +1,46 @@
+import async from "async";
+
+/**
+ * Migration 15
+ *
+ * Migration for setting user name to username if not set
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 15. Finding users with document version 3.`);
+					userModel.find({ documentVersion: 3, name: { $in: [null, ""] } }, (err, users) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								users.map(user => user._doc),
+								1,
+								(user, next) => {
+									userModel.updateOne({ _id: user._id }, { $set: { name: user.username } }, next);
+								},
+								err => {
+									this.log("INFO", `Migration 15. Users found: ${users.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 91 - 0
backend/logic/migration/migrations/migration2.js

@@ -0,0 +1,91 @@
+import async from "async";
+
+/**
+ * Migration 2
+ *
+ * Updates the document version 1 stations to add the includedPlaylists and excludedPlaylists properties, and to create a station playlist and link that playlist with the playlist2 property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 2. Finding stations with document version 1.`);
+					stationModel.find({ documentVersion: 1 }, (err, stations) => {
+						this.log("INFO", `Migration 2. Found ${stations.length} stations with document version 1.`);
+
+						next(
+							null,
+							stations.map(station => station._doc)
+						);
+					});
+				},
+
+				(stations, next) => {
+					async.eachLimit(
+						stations,
+						1,
+						(station, next) => {
+							this.log("INFO", `Migration 2. Creating station playlist for station ${station._id}.`);
+							playlistModel.create(
+								{
+									isUserModifiable: false,
+									displayName: `Station - ${station.displayName}`,
+									songs: [],
+									createdBy: station.type === "official" ? "Musare" : station.createdBy,
+									createdFor: `${station._id}`,
+									createdAt: Date.now(),
+									type: "station",
+									documentVersion: 1
+								},
+								(err, playlist2) => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 2. Updating station ${station._id}.`);
+										stationModel.updateOne(
+											{ _id: station._id },
+											{
+												$set: {
+													playlist2: playlist2._id,
+													includedPlaylists: [],
+													excludedPlaylists: [],
+													playlist: station.type === "official" ? [] : station.playlist,
+													genres: [],
+													documentVersion: 2
+												}
+											},
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 2. Updating station ${station._id} done. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+												}
+											}
+										);
+										next();
+									}
+								}
+							);
+						},
+						next
+					);
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 75 - 0
backend/logic/migration/migrations/migration3.js

@@ -0,0 +1,75 @@
+import async from "async";
+
+/**
+ * Migration 3
+ *
+ * Clean up station object from playlist2 property (replacing old playlist property with playlist2 property), adding a playMode property and removing genres/blacklisted genres
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 3. Finding stations with document version 2.`);
+					stationModel.find({ documentVersion: 2 }, (err, stations) => {
+						this.log("INFO", `Migration 3. Found ${stations.length} stations with document version 2.`);
+
+						next(
+							null,
+							stations.map(station => station._doc)
+						);
+					});
+				},
+
+				(stations, next) => {
+					async.eachLimit(
+						stations,
+						1,
+						(station, next) => {
+							this.log("INFO", `Migration 3. Updating station ${station._id}.`);
+							stationModel.updateOne(
+								{ _id: station._id },
+								{
+									$set: {
+										playlist: station.playlist2,
+										playMode: "random",
+										documentVersion: 3
+									},
+									$unset: {
+										genres: "",
+										blacklistedGenres: "",
+										playlist2: "",
+										privatePlaylist: ""
+									}
+								},
+								(err, res) => {
+									if (err) next(err);
+									else {
+										this.log(
+											"INFO",
+											`Migration 3. Updating station ${station._id} done. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+										);
+										next();
+									}
+								}
+							);
+						},
+						next
+					);
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 99 - 0
backend/logic/migration/migrations/migration4.js

@@ -0,0 +1,99 @@
+import async from "async";
+
+/**
+ * Migration 4
+ *
+ * Migration for song merging. Merges queueSongs into songs database, and adds verified property to all songs.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const queueSongModel = await MigrationModule.runJob("GET_MODEL", { modelName: "queueSong" }, this);
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 4. Finding songs with document version 1.`);
+					songModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2, verified: true } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 4 (song). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 4. Finding queue songs.`);
+					queueSongModel.find({}, next);
+				},
+
+				(queueSongs, next) => {
+					this.log("INFO", `Migration 4. Found ${queueSongs.length} queue songs.`);
+					async.eachLimit(
+						queueSongs,
+						1,
+						(_queueSong, next) => {
+							const queueSong = JSON.parse(JSON.stringify(_queueSong));
+
+							songModel.findOne({ songId: queueSong.songId }, (err, song) => {
+								if (err) next(err);
+								else if (song) {
+									this.log(
+										"INFO",
+										`Migration 4. Skipping creating song for queue song ${queueSong.songId} (${queueSong._id}) since it already exists.`
+									);
+									next(null, song);
+								} else {
+									this.log(
+										"INFO",
+										`Migration 4. Creating song for queue song ${queueSong.songId} (${queueSong._id}).`
+									);
+									queueSong.verified = false;
+									queueSong.documentVersion = 2;
+									delete queueSong._id;
+									songModel.create(queueSong, next);
+								}
+							});
+						},
+						err => {
+							if (err) next(err);
+							else {
+								this.log("INFO", `Migration 4. Deleting queue songs.`);
+								queueSongModel.deleteMany({}, (err, res) => {
+									if (err) next(err);
+									else {
+										this.log(
+											"INFO",
+											`Migration 4 (queueSong). Matched: ${res.n}, deleted: ${res.deletedCount}, ok: ${res.ok}.`
+										);
+
+										next();
+									}
+								});
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 152 - 0
backend/logic/migration/migrations/migration5.js

@@ -0,0 +1,152 @@
+import async from "async";
+
+/**
+ * Migration 5
+ *
+ * Migration for song status property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 5. Finding unverified songs with document version 2.`);
+					songModel.updateMany(
+						{ documentVersion: 2, verified: false },
+						{ $set: { documentVersion: 3, status: "unverified" }, $unset: { verified: "" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 5 (unverified songs). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 5. Finding verified songs with document version 2.`);
+					songModel.updateMany(
+						{ documentVersion: 2, verified: true },
+						{ $set: { documentVersion: 3, status: "verified" }, $unset: { verified: "" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 5 (verified songs). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 5. Updating playlist songs and queue songs.`);
+					songModel.find({ documentVersion: 3 }, (err, songs) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								songs.map(song => song._doc),
+								1,
+								(song, next) => {
+									const { _id, songId, title, artists, thumbnail, duration, status } = song;
+									const trimmedSong = {
+										_id,
+										songId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										status
+									};
+									async.waterfall(
+										[
+											next => {
+												playlistModel.updateMany(
+													{ "songs._id": song._id, documentVersion: 1 },
+													{ $set: { "songs.$": trimmedSong } },
+													next
+												);
+											},
+
+											(res, next) => {
+												stationModel.updateMany(
+													{ "queue._id": song._id, documentVersion: 3 },
+													{ $set: { "queue.$": trimmedSong } },
+													next
+												);
+											},
+
+											(res, next) => {
+												stationModel.updateMany(
+													{ "currentSong._id": song._id, documentVersion: 3 },
+													{ $set: { currentSong: null } },
+													next
+												);
+											}
+										],
+										err => {
+											next(err);
+										}
+									);
+								},
+								err => {
+									next(err);
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					playlistModel.updateMany({ documentVersion: 1 }, { $set: { documentVersion: 2 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 5 (playlist). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					stationModel.updateMany({ documentVersion: 3 }, { $set: { documentVersion: 4 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 5 (station). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 45 - 0
backend/logic/migration/migrations/migration6.js

@@ -0,0 +1,45 @@
+import async from "async";
+
+/**
+ * Migration 6
+ *
+ * Migration for adding activityWatch preference to user object
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 6. Finding users with document version 1.`);
+					userModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2, "preferences.activityWatch": false } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 6. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 45 - 0
backend/logic/migration/migrations/migration7.js

@@ -0,0 +1,45 @@
+import async from "async";
+
+/**
+ * Migration 7
+ *
+ * Migration for adding anonymous song requests preference to user object
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 7. Finding users with document version 2.`);
+					userModel.updateMany(
+						{ documentVersion: 2 },
+						{ $set: { documentVersion: 3, "preferences.anonymousSongRequests": false } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 7. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 214 - 0
backend/logic/migration/migrations/migration8.js

@@ -0,0 +1,214 @@
+import async from "async";
+
+/**
+ * Migration 8
+ *
+ * Migration for replacing songId with youtubeId whereever it is used, and using songId for any song's _id uses
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const activityModel = await MigrationModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 8. Finding activities with document version 1.`);
+					activityModel.find({ documentVersion: 1 }, (err, activities) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								activities.map(activity => activity._doc),
+								1,
+								(activity, next) => {
+									const { payload } = activity;
+									if (payload.songId) {
+										payload.youtubeId = payload.songId;
+										delete payload.songId;
+									}
+									if (payload.message)
+										payload.message = payload.message
+											.replaceAll("<songId", "<youtubeId")
+											.replaceAll("</songId", "</youtubeId");
+
+									activityModel.updateOne(
+										{ _id: activity._id },
+										{ $set: { payload, documentVersion: 2 } },
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 8. Activities found: ${activities.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding playlists with document version 2.`);
+					playlistModel.find({ documentVersion: 2 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlist => playlist._doc),
+								1,
+								(playlist, next) => {
+									const songs = playlist.songs.map(song => {
+										song.youtubeId = song.songId;
+										delete song.songId;
+										return song;
+									});
+
+									playlistModel.updateOne({ _id: playlist._id }, { $set: { songs } }, next);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										playlistModel.updateMany(
+											{ documentVersion: 2 },
+											{ $set: { documentVersion: 3 } },
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+													next();
+												}
+											}
+										);
+									}
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding reports with document version 1.`);
+					reportModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2 }, $rename: { "song.songId": "song.youtubeId" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Dropping indexes.`);
+					songModel.collection.dropIndexes((err, res) => {
+						if (err) next(err);
+						else {
+							this.log("INFO", `Migration 8. Dropped indexes: ${res}.`);
+							next();
+						}
+					});
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding spmgs with document version 3.`);
+					songModel.updateMany(
+						{ documentVersion: 3 },
+						{ $set: { documentVersion: 4 }, $rename: { songId: "youtubeId" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding stations with document version 3.`);
+					stationModel.find({ documentVersion: 4 }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations.map(station => station._doc),
+								1,
+								(station, next) => {
+									const queue = station.queue.map(song => {
+										song.youtubeId = song.songId;
+										delete song.songId;
+										return song;
+									});
+
+									stationModel.updateOne({ _id: station._id }, { $set: { queue } }, next);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										stationModel.updateMany(
+											{ documentVersion: 4, currentSong: { $ne: null } },
+											{
+												$set: { documentVersion: 5 },
+												$rename: { "currentSong.songId": "currentSong.youtubeId" }
+											},
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+													stationModel.updateMany(
+														{ documentVersion: 4, currentSong: null },
+														{
+															$set: { documentVersion: 5 }
+														},
+														(err, res) => {
+															if (err) next(err);
+															else {
+																this.log(
+																	"INFO",
+																	`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+																);
+																next();
+															}
+														}
+													);
+												}
+											}
+										);
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 85 - 0
backend/logic/migration/migrations/migration9.js

@@ -0,0 +1,85 @@
+import async from "async";
+
+/**
+ * Migration 9
+ *
+ * Migration for news
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const newsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 9. Finding news with document version 1.`);
+					newsModel.find({ documentVersion: 1 }, (err, news) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								news.map(newi => newi._doc),
+								1,
+								(newi, next) => {
+									newi.markdown = `# ${newi.title}\n\n`;
+									newi.markdown += `## ${newi.description}\n\n`;
+
+									if (newi.bugs) {
+										newi.markdown += `**Bugs:**\n\n${newi.bugs.join(", ")}\n\n`;
+									}
+
+									if (newi.features) {
+										newi.markdown += `**Features:**\n\n${newi.features.join(", ")}\n\n`;
+									}
+
+									if (newi.improvements) {
+										newi.markdown += `**Improvements:**\n\n${newi.improvements.join(", ")}\n\n`;
+									}
+
+									if (newi.upcoming) {
+										newi.markdown += `**Upcoming:**\n\n${newi.upcoming.join(", ")}\n`;
+									}
+
+									newsModel.updateOne(
+										{ _id: newi._id },
+										{
+											$set: {
+												markdown: newi.markdown,
+												status: "published",
+												documentVersion: 2
+											},
+											$unset: {
+												description: "",
+												bugs: "",
+												features: "",
+												improvements: "",
+												upcoming: ""
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 9. News found: ${news.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 276 - 246
backend/logic/notifications.js

@@ -1,248 +1,278 @@
-const CoreClass = require("../core.js");
-
-const crypto = require("crypto");
-const redis = require("redis");
-const config = require("config");
-
-const subscriptions = [];
-
-class NotificationsModule extends CoreClass {
-    constructor() {
-        super("notifications");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            const url = (this.url = config.get("redis").url);
-            const password = (this.password = config.get("redis").password);
-
-            this.pub = redis.createClient({
-                url,
-                password,
-                retry_strategy: (options) => {
-                    if (this.getStatus() === "LOCKDOWN") return;
-                    if (this.getStatus() !== "RECONNECTING")
-                        this.setStatus("RECONNECTING");
-
-                    this.log("INFO", `Attempting to reconnect.`);
-
-                    if (options.attempt >= 10) {
-                        this.log("ERROR", `Stopped trying to reconnect.`);
-
-                        this.setStatus("FAILED");
-
-                        // this.failed = true;
-                        // this._lockdown();
-
-                        return undefined;
-                    }
-
-                    return 3000;
-                },
-            });
-            this.sub = redis.createClient({
-                url,
-                password,
-                retry_strategy: (options) => {
-                    if (this.getStatus() === "LOCKDOWN") return;
-                    if (this.getStatus() !== "RECONNECTING")
-                        this.setStatus("RECONNECTING");
-
-                    this.log("INFO", `Attempting to reconnect.`);
-
-                    if (options.attempt >= 10) {
-                        this.log("ERROR", `Stopped trying to reconnect.`);
-
-                        this.setStatus("FAILED");
-
-                        // this.failed = true;
-                        // this._lockdown();
-
-                        return undefined;
-                    }
-
-                    return 3000;
-                },
-            });
-
-            this.sub.on("error", (err) => {
-                if (this.getStatus() === "INITIALIZING") reject(err);
-                if (this.getStatus() === "LOCKDOWN") return;
-
-                this.log("ERROR", `Error ${err.message}.`);
-            });
-
-            this.pub.on("error", (err) => {
-                if (this.getStatus() === "INITIALIZING") reject(err);
-                if (this.getStatus() === "LOCKDOWN") return;
-
-                this.log("ERROR", `Error ${err.message}.`);
-            });
-
-            this.sub.on("connect", () => {
-                this.log("INFO", "Sub connected succesfully.");
-
-                if (this.getStatus() === "INITIALIZING") resolve();
-                else if (
-                    this.getStatus() === "LOCKDOWN" ||
-                    this.getStatus() === "RECONNECTING"
-                )
-                    this.setStatus("READY");
-            });
-
-            this.pub.on("connect", () => {
-                this.log("INFO", "Pub connected succesfully.");
-
-                if (this.getStatus() === "INITIALIZING") resolve();
-                else if (
-                    this.getStatus() === "LOCKDOWN" ||
-                    this.getStatus() === "RECONNECTING"
-                )
-                    this.setStatus("INITIALIZED");
-            });
-
-            this.sub.on("pmessage", (pattern, channel, expiredKey) => {
-                this.log(
-                    "STATION_ISSUE",
-                    `PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`
-                );
-                subscriptions.forEach((sub) => {
-                    this.log(
-                        "STATION_ISSUE",
-                        `PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(
-                            sub.name !== expiredKey
-                        )}`
-                    );
-                    if (sub.name !== expiredKey) return;
-                    sub.cb();
-                });
-            });
-
-            this.sub.psubscribe("__keyevent@0__:expired");
-        });
-    }
-
-    /**
-     * Schedules a notification to be dispatched in a specific amount of milliseconds,
-     * notifications are unique by name, and the first one is always kept, as in
-     * attempting to schedule a notification that already exists won't do anything
-     *
-     * @param {String} name - the name of the notification we want to schedule
-     * @param {Integer} time - how long in milliseconds until the notification should be fired
-     * @param {Function} cb - gets called when the notification has been scheduled
-     */
-    SCHEDULE(payload) {
-        //name, time, cb, station
-        return new Promise((resolve, reject) => {
-            const time = Math.round(payload.time);
-            this.log(
-                "STATION_ISSUE",
-                `SCHEDULE - Time: ${time}; Name: ${payload.name}; Key: ${crypto
-                    .createHash("md5")
-                    .update(`_notification:${payload.name}_`)
-                    .digest("hex")}; StationId: ${
-                    payload.station._id
-                }; StationName: ${payload.station.name}`
-            );
-            this.pub.set(
-                crypto
-                    .createHash("md5")
-                    .update(`_notification:${payload.name}_`)
-                    .digest("hex"),
-                "",
-                "PX",
-                time,
-                "NX",
-                () => {
-                    resolve();
-                }
-            );
-        });
-    }
-
-    /**
-     * Subscribes a callback function to be called when a notification gets called
-     *
-     * @param {String} name - the name of the notification we want to subscribe to
-     * @param {Function} cb - gets called when the subscribed notification gets called
-     * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
-     * @return {Object} - the subscription object
-     */
-    SUBSCRIBE(payload) {
-        //name, cb, unique = false, station
-        return new Promise((resolve, reject) => {
-            this.log(
-                "STATION_ISSUE",
-                `SUBSCRIBE - Name: ${payload.name}; Key: ${crypto
-                    .createHash("md5")
-                    .update(`_notification:${payload.name}_`)
-                    .digest("hex")}, StationId: ${
-                    payload.station._id
-                }; StationName: ${payload.station.name}; Unique: ${
-                    payload.unique
-                }; SubscriptionExists: ${!!subscriptions.find(
-                    (subscription) => subscription.originalName === payload.name
-                )};`
-            );
-            if (
-                payload.unique &&
-                !!subscriptions.find(
-                    (subscription) => subscription.originalName === payload.name
-                )
-            )
-                return resolve({
-                    subscription: subscriptions.find(
-                        (subscription) =>
-                            subscription.originalName === payload.name
-                    ),
-                });
-            let subscription = {
-                originalName: payload.name,
-                name: crypto
-                    .createHash("md5")
-                    .update(`_notification:${payload.name}_`)
-                    .digest("hex"),
-                cb: payload.cb,
-            };
-            subscriptions.push(subscription);
-            resolve({ subscription });
-        });
-    }
-
-    /**
-     * Remove a notification subscription
-     *
-     * @param {Object} subscription - the subscription object returned by {@link subscribe}
-     */
-    REMOVE(payload) {
-        //subscription
-        return new Promise((resolve, reject) => {
-            let index = subscriptions.indexOf(payload.subscription);
-            if (index) subscriptions.splice(index, 1);
-            resolve();
-        });
-    }
-
-    UNSCHEDULE(payload) {
-        //name
-        return new Promise((resolve, reject) => {
-            this.log(
-                "STATION_ISSUE",
-                `UNSCHEDULE - Name: ${payload.name}; Key: ${crypto
-                    .createHash("md5")
-                    .update(`_notification:${payload.name}_`)
-                    .digest("hex")}`
-            );
-            this.pub.del(
-                crypto
-                    .createHash("md5")
-                    .update(`_notification:${payload.name}_`)
-                    .digest("hex")
-            );
-
-            resolve();
-        });
-    }
+import config from "config";
+
+import crypto from "crypto";
+import redis from "redis";
+
+import CoreClass from "../core";
+
+let NotificationsModule;
+let UtilsModule;
+
+class _NotificationsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("notifications");
+
+		this.subscriptions = [];
+
+		NotificationsModule = this;
+	}
+
+	/**
+	 * Initialises the notifications module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			const url = (this.url = config.get("redis").url);
+			const password = (this.password = config.get("redis").password);
+
+			UtilsModule = this.moduleManager.modules.utils;
+
+			this.pub = redis.createClient({
+				url,
+				password,
+				retry_strategy: options => {
+					if (this.getStatus() === "LOCKDOWN") return;
+					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+
+					this.log("INFO", `Attempting to reconnect.`);
+
+					if (options.attempt >= 10) {
+						this.log("ERROR", `Stopped trying to reconnect.`);
+
+						this.setStatus("FAILED");
+					}
+				}
+			});
+			this.sub = redis.createClient({
+				url,
+				password,
+				retry_strategy: options => {
+					if (this.getStatus() === "LOCKDOWN") return;
+					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+
+					this.log("INFO", `Attempting to reconnect.`);
+
+					if (options.attempt >= 10) {
+						this.log("ERROR", `Stopped trying to reconnect.`);
+
+						this.setStatus("FAILED");
+					}
+				}
+			});
+
+			this.sub.on("error", err => {
+				if (this.getStatus() === "INITIALIZING") reject(err);
+				if (this.getStatus() === "LOCKDOWN") return;
+
+				this.log("ERROR", `Error ${err.message}.`);
+			});
+
+			this.pub.on("error", err => {
+				if (this.getStatus() === "INITIALIZING") reject(err);
+				if (this.getStatus() === "LOCKDOWN") return;
+
+				this.log("ERROR", `Error ${err.message}.`);
+			});
+
+			this.sub.on("connect", () => {
+				this.log("INFO", "Sub connected succesfully.");
+
+				if (this.getStatus() === "INITIALIZING") resolve();
+				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
+					this.setStatus("READY");
+			});
+
+			this.pub.on("connect", () => {
+				this.log("INFO", "Pub connected succesfully.");
+
+				this.pub.config("GET", "notify-keyspace-events", async (err, response) => {
+					if (err) {
+						const formattedErr = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						this.log(
+							"ERROR",
+							"NOTIFICATIONS_INITIALIZE",
+							`Getting notify-keyspace-events gave an error. ${formattedErr}`
+						);
+						this.log(
+							"STATION_ISSUE",
+							`Getting notify-keyspace-events gave an error. ${formattedErr}. ${response}`
+						);
+						return;
+					}
+					if (response[1] === "xE") {
+						this.log("INFO", "NOTIFICATIONS_INITIALIZE", `notify-keyspace-events is set correctly`);
+						this.log("STATION_ISSUE", `notify-keyspace-events is set correctly`);
+					} else {
+						this.log(
+							"ERROR",
+							"NOTIFICATIONS_INITIALIZE",
+							`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+						);
+						this.log(
+							"STATION_ISSUE",
+							`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+						);
+					}
+				});
+
+				if (this.getStatus() === "INITIALIZING") resolve();
+				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
+					this.setStatus("INITIALIZED");
+			});
+
+			this.sub.on("pmessage", (pattern, channel, expiredKey) => {
+				this.log(
+					"STATION_ISSUE",
+					`PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`
+				);
+
+				this.subscriptions.forEach(sub => {
+					this.log(
+						"STATION_ISSUE",
+						`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== expiredKey)}`
+					);
+					if (sub.name !== expiredKey) return;
+					sub.cb();
+				});
+			});
+
+			this.sub.psubscribe(`__keyevent@${this.pub.options.db}__:expired`);
+		});
+	}
+
+	/**
+	 * Schedules a notification to be dispatched in a specific amount of milliseconds,
+	 * notifications are unique by name, and the first one is always kept, as in
+	 * attempting to schedule a notification that already exists won't do anything
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.name - the name of the notification we want to schedule
+	 * @param {number} payload.time - how long in milliseconds until the notification should be fired
+	 * @param {object} payload.station - the station object related to the notification
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	SCHEDULE(payload) {
+		return new Promise((resolve, reject) => {
+			const time = Math.round(payload.time);
+			if (time <= 0) reject(new Error("Time has to be higher than 0"));
+			else {
+				NotificationsModule.log(
+					"STATION_ISSUE",
+					`SCHEDULE - Time: ${time}; Name: ${payload.name}; Key: ${crypto
+						.createHash("md5")
+						.update(`_notification:${payload.name}_`)
+						.digest("hex")}; StationId: ${payload.station._id}; StationName: ${payload.station.name}`
+				);
+				NotificationsModule.pub.set(
+					crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+					"",
+					"PX",
+					time,
+					"NX",
+					err => {
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	}
+
+	/**
+	 * Subscribes a callback function to be called when a notification gets called
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.name - the name of the notification we want to subscribe to
+	 * @param {boolean} payload.unique - only subscribe if another subscription with the same name doesn't already exist
+	 * @param {object} payload.station - the station object related to the notification
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	SUBSCRIBE(payload) {
+		return new Promise(resolve => {
+			NotificationsModule.log(
+				"STATION_ISSUE",
+				`SUBSCRIBE - Name: ${payload.name}; Key: ${crypto
+					.createHash("md5")
+					.update(`_notification:${payload.name}_`)
+					.digest("hex")}, StationId: ${payload.station._id}; StationName: ${payload.station.name}; Unique: ${
+					payload.unique
+				}; SubscriptionExists: ${!!NotificationsModule.subscriptions.find(
+					subscription => subscription.originalName === payload.name
+				)};`
+			);
+			if (
+				payload.unique &&
+				!!NotificationsModule.subscriptions.find(subscription => subscription.originalName === payload.name)
+			)
+				return resolve({
+					subscription: NotificationsModule.subscriptions.find(
+						subscription => subscription.originalName === payload.name
+					)
+				});
+
+			const subscription = {
+				originalName: payload.name,
+				name: crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+				cb: payload.cb
+			};
+
+			NotificationsModule.subscriptions.push(subscription);
+
+			return resolve({ subscription });
+		});
+	}
+
+	/**
+	 * Remove a notification subscription
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.subscription - the subscription object returned by {@link subscribe}
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE(payload) {
+		// subscription
+		return new Promise(resolve => {
+			const index = NotificationsModule.subscriptions.indexOf(payload.subscription);
+			if (index) NotificationsModule.subscriptions.splice(index, 1);
+			resolve();
+		});
+	}
+
+	/**
+	 * Unschedules a notification by name (each notification has a unique name)
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.name - the name of the notification we want to schedule
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UNSCHEDULE(payload) {
+		// name
+		return new Promise((resolve, reject) => {
+			NotificationsModule.log(
+				"STATION_ISSUE",
+				`UNSCHEDULE - Name: ${payload.name}; Key: ${crypto
+					.createHash("md5")
+					.update(`_notification:${payload.name}_`)
+					.digest("hex")}`
+			);
+			NotificationsModule.pub.del(
+				crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
 }
 }
 
 
-module.exports = new NotificationsModule();
+export default new _NotificationsModule();

+ 1146 - 279
backend/logic/playlists.js

@@ -1,281 +1,1148 @@
-const CoreClass = require("../core.js");
-
-const async = require("async");
-
-class ExampleModule extends CoreClass {
-    constructor() {
-        super("playlists");
-    }
-
-    initialize() {
-        return new Promise(async (resolve, reject) => {
-            this.setStage(1);
-
-            this.cache = this.moduleManager.modules["cache"];
-            this.db = this.moduleManager.modules["db"];
-            this.utils = this.moduleManager.modules["utils"];
-
-            const playlistModel = await this.db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-
-            const playlistSchema = await this.cache.runJob("GET_SCHEMA", {
-                schemaName: "playlist",
-            });
-
-            this.setStage(2);
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.setStage(3);
-                        this.cache
-                            .runJob("HGETALL", { table: "playlists" })
-                            .then((playlists) => next(null, playlists))
-                            .catch(next);
-                    },
-
-                    (playlists, next) => {
-                        this.setStage(4);
-                        if (!playlists) return next();
-                        let playlistIds = Object.keys(playlists);
-                        async.each(
-                            playlistIds,
-                            (playlistId, next) => {
-                                playlistModel.findOne(
-                                    { _id: playlistId },
-                                    (err, playlist) => {
-                                        if (err) next(err);
-                                        else if (!playlist) {
-                                            this.cache
-                                                .runJob("HDEL", {
-                                                    table: "playlists",
-                                                    key: playlistId,
-                                                })
-                                                .then(() => next())
-                                                .catch(next);
-                                        } else next();
-                                    }
-                                );
-                            },
-                            next
-                        );
-                    },
-
-                    (next) => {
-                        this.setStage(5);
-                        playlistModel.find({}, next);
-                    },
-
-                    (playlists, next) => {
-                        this.setStage(6);
-                        async.each(
-                            playlists,
-                            (playlist, next) => {
-                                this.cache
-                                    .runJob("HSET", {
-                                        table: "playlists",
-                                        key: playlist._id,
-                                        value: playlistSchema(playlist),
-                                    })
-                                    .then(() => {
-                                        next();
-                                    })
-                                    .catch(next);
-                            },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else {
-                        resolve();
-                    }
-                }
-            );
-        });
-    }
-
-    /**
-     * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-     *
-     * @param {String} playlistId - the id of the playlist we are trying to get
-     * @param {Function} cb - gets called once we're done initializing
-     */
-    GET_PLAYLIST(payload) {
-        //playlistId, cb
-        return new Promise(async (resolve, reject) => {
-            const playlistModel = await this.db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.cache
-                            .runJob("HGETALL", { table: "playlists" })
-                            .then((playlists) => next(null, playlists))
-                            .catch(next);
-                    },
-
-                    (playlists, next) => {
-                        if (!playlists) return next();
-                        let playlistIds = Object.keys(playlists);
-                        async.each(
-                            playlistIds,
-                            (playlistId, next) => {
-                                playlistModel.findOne(
-                                    { _id: playlistId },
-                                    (err, playlist) => {
-                                        if (err) next(err);
-                                        else if (!playlist) {
-                                            this.cache
-                                                .runJob("HDEL", {
-                                                    table: "playlists",
-                                                    key: playlistId,
-                                                })
-                                                .then(() => next())
-                                                .catch(next);
-                                        } else next();
-                                    }
-                                );
-                            },
-                            next
-                        );
-                    },
-
-                    (next) => {
-                        this.cache
-                            .runJob("HGET", {
-                                table: "playlists",
-                                key: payload.playlistId,
-                            })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        if (playlist) return next(true, playlist);
-                        playlistModel.findOne(
-                            { _id: payload.playlistId },
-                            next
-                        );
-                    },
-
-                    (playlist, next) => {
-                        if (playlist) {
-                            this.cache
-                                .runJob("HSET", {
-                                    table: "playlists",
-                                    key: payload.playlistId,
-                                    value: playlist,
-                                })
-                                .then((playlist) => next(null, playlist))
-                                .catch(next);
-                        } else next("Playlist not found");
-                    },
-                ],
-                (err, playlist) => {
-                    if (err && err !== true) return reject(new Error(err));
-                    resolve(playlist);
-                }
-            );
-        });
-    }
-
-    /**
-     * Gets a playlist from id from Mongo and updates the cache with it
-     *
-     * @param {String} playlistId - the id of the playlist we are trying to update
-     * @param {Function} cb - gets called when an error occurred or when the operation was successful
-     */
-    UPDATE_PLAYLIST(payload) {
-        //playlistId, cb
-        return new Promise(async (resolve, reject) => {
-            const playlistModel = await this.db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-
-            async.waterfall(
-                [
-                    (next) => {
-                        playlistModel.findOne(
-                            { _id: payload.playlistId },
-                            next
-                        );
-                    },
-
-                    (playlist, next) => {
-                        if (!playlist) {
-                            this.cache.runJob("HDEL", {
-                                table: "playlists",
-                                key: payload.playlistId,
-                            });
-                            return next("Playlist not found");
-                        }
-                        this.cache
-                            .runJob("HSET", {
-                                table: "playlists",
-                                key: payload.playlistId,
-                                value: playlist,
-                            })
-                            .then((playlist) => next(null, playlist))
-                            .catch(next);
-                    },
-                ],
-                (err, playlist) => {
-                    if (err && err !== true) return reject(new Error(err));
-                    resolve(playlist);
-                }
-            );
-        });
-    }
-
-    /**
-     * Deletes playlist from id from Mongo and cache
-     *
-     * @param {String} playlistId - the id of the playlist we are trying to delete
-     * @param {Function} cb - gets called when an error occurred or when the operation was successful
-     */
-    DELETE_PLAYLIST(payload) {
-        //playlistId, cb
-        return new Promise(async (resolve, reject) => {
-            const playlistModel = await this.db.runJob("GET_MODEL", {
-                modelName: "playlist",
-            });
-
-            async.waterfall(
-                [
-                    (next) => {
-                        playlistModel.deleteOne(
-                            { _id: payload.playlistId },
-                            next
-                        );
-                    },
-
-                    (res, next) => {
-                        this.cache
-                            .runJob("HDEL", {
-                                table: "playlists",
-                                key: payload.playlistId,
-                            })
-                            .then(() => next())
-                            .catch(next);
-                    },
-                ],
-                (err) => {
-                    if (err && err !== true) return reject(new Error(err));
-
-                    resolve();
-                }
-            );
-        });
-    }
+import async from "async";
+
+import CoreClass from "../core";
+
+let PlaylistsModule;
+let StationsModule;
+let SongsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+
+class _PlaylistsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("playlists");
+
+		PlaylistsModule = this;
+	}
+
+	/**
+	 * Initialises the playlists module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		StationsModule = this.moduleManager.modules.stations;
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		SongsModule = this.moduleManager.modules.songs;
+
+		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
+		this.playlistSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "playlist" });
+
+		this.setStage(2);
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(3);
+						CacheModule.runJob("HGETALL", { table: "playlists" })
+							.then(playlists => {
+								next(null, playlists);
+							})
+							.catch(next);
+					},
+
+					(playlists, next) => {
+						this.setStage(4);
+
+						if (!playlists) return next();
+
+						const playlistIds = Object.keys(playlists);
+
+						return async.each(
+							playlistIds,
+							(playlistId, next) => {
+								PlaylistsModule.playlistModel.findOne({ _id: playlistId }, (err, playlist) => {
+									if (err) next(err);
+									else if (!playlist) {
+										CacheModule.runJob("HDEL", {
+											table: "playlists",
+											key: playlistId
+										})
+											.then(() => next())
+											.catch(next);
+									} else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(5);
+						PlaylistsModule.playlistModel.find({}, next);
+					},
+
+					(playlists, next) => {
+						this.setStage(6);
+						async.each(
+							playlists,
+							(playlist, cb) => {
+								CacheModule.runJob("HSET", {
+									table: "playlists",
+									key: playlist._id,
+									value: PlaylistsModule.playlistSchemaCache(playlist)
+								})
+									.then(() => cb())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						const formattedErr = await UtilsModule.runJob("GET_ERROR", {
+							error: err
+						});
+						reject(new Error(formattedErr));
+					} else {
+						resolve();
+					}
+				}
+			)
+		);
+	}
+
+	// /**
+	//  * Returns a list of playlists that include a specific song
+	//  *
+	//  * @param {object} payload - object that contains the payload
+	//  * @param {string} payload.songId - the song id
+	//  * @param {string} payload.includeSongs - include the songs
+	//  * @returns {Promise} - returns promise (reject, resolve)
+	//  */
+	// GET_PLAYLISTS_WITH_SONG(payload) {
+	// 	return new Promise((resolve, reject) => {
+	// 		async.waterfall([
+	// 			next => {
+	// 				const includeObject = payload.includeSongs ? null : { songs: false };
+	// 				PlaylistsModule.playlistModel.find({ "songs._id": payload.songId }, includeObject, next);
+	// 			},
+
+	// 			(playlists, next) => {
+	// 				console.log(playlists);
+	// 			}
+	// 		]);
+	// 	});
+	// }
+
+	/**
+	 * Creates a playlist that is not generated or editable by a user e.g. liked songs playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user to create the playlist for
+	 * @param {string} payload.displayName - the display name of the playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_READ_ONLY_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.create(
+				{
+					isUserModifiable: false,
+					displayName: payload.displayName,
+					songs: [],
+					createdBy: payload.userId,
+					createdAt: Date.now(),
+					createdFor: null,
+					type: payload.type
+				},
+				(err, playlist) => {
+					if (err) return reject(new Error(err));
+					return resolve(playlist._id);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Creates a playlist that contains all songs of a specific genre
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.genre - the genre
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre: payload.genre.toLowerCase() }, this)
+				.then(() => {
+					reject(new Error("Playlist already exists"));
+				})
+				.catch(err => {
+					if (err.message === "Playlist not found") {
+						PlaylistsModule.playlistModel.create(
+							{
+								isUserModifiable: false,
+								displayName: `Genre - ${payload.genre}`,
+								songs: [],
+								createdBy: "Musare",
+								createdFor: `${payload.genre.toLowerCase()}`,
+								createdAt: Date.now(),
+								type: "genre"
+							},
+							(err, playlist) => {
+								if (err) return reject(new Error(err));
+								return resolve(playlist._id);
+							}
+						);
+					} else reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Gets all genre playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ALL_GENRE_PLAYLISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.find({ type: "genre" }, includeObject, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else resolve({ playlists });
+			});
+		});
+	}
+
+	/**
+	 * Gets all station playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ALL_STATION_PLAYLISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.find({ type: "station" }, includeObject, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else resolve({ playlists });
+			});
+		});
+	}
+
+	/**
+	 * Gets a genre playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.genre - the genre
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.findOne(
+				{ type: "genre", createdFor: payload.genre },
+				includeObject,
+				(err, playlist) => {
+					if (err) reject(new Error(err));
+					else if (!playlist) reject(new Error("Playlist not found"));
+					else resolve({ playlist });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets all missing genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MISSING_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			SongsModule.runJob("GET_ALL_GENRES", {}, this)
+				.then(response => {
+					const { genres } = response;
+					const missingGenres = [];
+					async.eachLimit(
+						genres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob(
+								"GET_GENRE_PLAYLIST",
+								{ genre: genre.toLowerCase(), includeSongs: false },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found") {
+										missingGenres.push(genre);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve({ genres: missingGenres });
+						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Creates all missing genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_MISSING_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_MISSING_GENRE_PLAYLISTS", {}, this)
+				.then(response => {
+					const { genres } = response;
+					async.eachLimit(
+						genres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre }, this)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve();
+						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Gets a station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.staationId - the station id
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.findOne(
+				{ type: "station", createdFor: payload.stationId },
+				includeObject,
+				(err, playlist) => {
+					if (err) reject(new Error(err));
+					else if (!playlist) reject(new Error("Playlist not found"));
+					else resolve({ playlist });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Adds a song to a playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.song - the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	ADD_SONG_TO_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { _id, youtubeId, title, artists, thumbnail, duration, status } = payload.song;
+			const trimmedSong = {
+				_id,
+				youtubeId,
+				title,
+				artists,
+				thumbnail,
+				duration,
+				status
+			};
+
+			PlaylistsModule.playlistModel.updateOne(
+				{ _id: payload.playlistId },
+				{ $push: { songs: trimmedSong } },
+				{ runValidators: true },
+				err => {
+					if (err) reject(new Error(err));
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
+							.then(() => resolve())
+							.catch(err => {
+								reject(new Error(err));
+							});
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Deletes a song from a playlist based on the youtube id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_SONG_FROM_PLAYLIST_BY_YOUTUBE_ID(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.updateOne(
+				{ _id: payload.playlistId },
+				{ $pull: { songs: { youtubeId: payload.youtubeId } } },
+				err => {
+					if (err) reject(new Error(err));
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
+							.then(() => resolve())
+							.catch(err => {
+								reject(new Error(err));
+							});
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Fills a genre playlist with songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.genre - the genre
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	AUTOFILL_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob(
+							"GET_GENRE_PLAYLIST",
+							{ genre: payload.genre.toLowerCase(), includeSongs: true },
+							this
+						)
+							.then(response => {
+								next(null, response.playlist._id);
+							})
+							.catch(err => {
+								if (err.message === "Playlist not found") {
+									PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre: payload.genre }, this)
+										.then(playlistId => {
+											next(null, playlistId);
+										})
+										.catch(err => {
+											next(err);
+										});
+								} else next(err);
+							});
+					},
+
+					(playlistId, next) => {
+						SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: payload.genre }, this)
+							.then(response => {
+								next(null, playlistId, response.songs);
+							})
+							.catch(err => {
+								console.log(err);
+								next(err);
+							});
+					},
+
+					(playlistId, _songs, next) => {
+						const songs = _songs.map(song => {
+							const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+							return {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								status
+							};
+						});
+
+						PlaylistsModule.playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, err => {
+							next(err, playlistId);
+						});
+					},
+
+					(playlistId, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next(null, playlistId);
+							})
+							.catch(next);
+					},
+
+					(playlistId, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId }, this)
+							.then(response => {
+								async.eachLimit(
+									response.stationIds,
+									1,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
+											.then(() => {
+												next();
+											})
+											.catch(err => {
+												next(err);
+											});
+									},
+									err => {
+										if (err) next(err);
+										else next();
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({});
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets orphaned genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "genre" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: playlist.createdFor }, this)
+								.then(response => {
+									if (response.songs.length === 0) {
+										StationsModule.runJob(
+											"GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST",
+											{ playlistId: playlist._id },
+											this
+										)
+											.then(response => {
+												if (response.stationIds.length === 0) orphanedPlaylists.push(playlist);
+												next();
+											})
+											.catch(next);
+									} else next();
+								})
+								.catch(next);
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_GENRE_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned genre playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Gets a orphaned station playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_STATION_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "station" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							StationsModule.runJob("GET_STATION", { stationId: playlist.createdFor }, this)
+								.then(station => {
+									if (station.playlist !== playlist._id.toString()) {
+										orphanedPlaylists.push(playlist);
+									}
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Station not found") {
+										orphanedPlaylists.push(playlist);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned station playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_STATION_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_STATION_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned station playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Fills a station playlist with songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	AUTOFILL_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			let originalPlaylist = null;
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: station.playlist }, this)
+							.then(playlist => {
+								originalPlaylist = playlist;
+								next(null, station);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(station, next) => {
+						const includedPlaylists = [];
+						async.eachLimit(
+							station.includedPlaylists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										includedPlaylists.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, station, includedPlaylists);
+							}
+						);
+					},
+
+					(station, includedPlaylists, next) => {
+						const excludedPlaylists = [];
+						async.eachLimit(
+							station.excludedPlaylists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										excludedPlaylists.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, station, includedPlaylists, excludedPlaylists);
+							}
+						);
+					},
+
+					(station, includedPlaylists, excludedPlaylists, next) => {
+						const excludedSongs = excludedPlaylists
+							.flatMap(excludedPlaylist => excludedPlaylist.songs)
+							.reduce(
+								(items, item) =>
+									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+								[]
+							);
+						const includedSongs = includedPlaylists
+							.flatMap(includedPlaylist => includedPlaylist.songs)
+							.reduce(
+								(songs, song) =>
+									songs.find(x => x.youtubeId === song.youtubeId) ? [...songs] : [...songs, song],
+								[]
+							)
+							.filter(song => !excludedSongs.find(x => x.youtubeId === song.youtubeId));
+
+						next(null, station, includedSongs);
+					},
+
+					(station, includedSongs, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: station.playlist },
+							{ $set: { songs: includedSongs } },
+							err => {
+								next(err, includedSongs);
+							}
+						);
+					},
+
+					(includedSongs, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: originalPlaylist._id }, this)
+							.then(() => {
+								next(null, includedSongs);
+							})
+							.catch(next);
+					},
+
+					(includedSongs, next) => {
+						if (originalPlaylist.songs.length === 0 && includedSongs.length > 0)
+							StationsModule.runJob("SKIP_STATION", { stationId: payload.stationId, natural: false });
+						next();
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({});
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob(
+							"HGET",
+							{
+								table: "playlists",
+								key: payload.playlistId
+							},
+							this
+						)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist)
+							PlaylistsModule.playlistModel.exists({ _id: payload.playlistId }, (err, exists) => {
+								if (err) next(err);
+								else if (exists) next(null, playlist);
+								else {
+									CacheModule.runJob(
+										"HDEL",
+										{
+											table: "playlists",
+											key: payload.playlistId
+										},
+										this
+									)
+										.then(() => next())
+										.catch(next);
+								}
+							});
+						else PlaylistsModule.playlistModel.findOne({ _id: payload.playlistId }, next);
+					},
+
+					(playlist, next) => {
+						if (playlist) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "playlists",
+									key: payload.playlistId,
+									value: playlist
+								},
+								this
+							)
+								.then(playlist => {
+									next(null, playlist);
+								})
+								.catch(next);
+						} else next("Playlist not found");
+					}
+				],
+				(err, playlist) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(playlist);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets a playlist from id from Mongo and updates the cache with it
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to update
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	UPDATE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.playlistModel.findOne({ _id: payload.playlistId }, next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) {
+							CacheModule.runJob("HDEL", {
+								table: "playlists",
+								key: payload.playlistId
+							});
+
+							return next("Playlist not found");
+						}
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "playlists",
+								key: payload.playlistId,
+								value: playlist
+							},
+							this
+						)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					}
+				],
+				(err, playlist) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(playlist);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Deletes playlist from id from Mongo and cache
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to delete
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.playlistModel.deleteOne({ _id: payload.playlistId }, next);
+					},
+
+					(res, next) => {
+						CacheModule.runJob(
+							"HDEL",
+							{
+								table: "playlists",
+								key: payload.playlistId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next => {
+						StationsModule.runJob(
+							"REMOVE_INCLUDED_OR_EXCLUDED_PLAYLIST_FROM_STATIONS",
+							{ playlistId: payload.playlistId },
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(err => next(err));
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			)
+		);
+	}
+
+	/**
+	 * Searches through playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query
+	 * @param {string} payload.includePrivate - include private playlists
+	 * @param {string} payload.includeStation - include station playlists
+	 * @param {string} payload.includeUser - include user playlists
+	 * @param {string} payload.includeGenre - include genre playlists
+	 * @param {string} payload.includeOwn - include own user playlists
+	 * @param {string} payload.userId - the user id of the person requesting
+	 * @param {string} payload.includeSongs - include songs
+	 * @param {string} payload.page - page (default 1)
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const types = [];
+						if (payload.includeStation) types.push("station");
+						if (payload.includeUser) types.push("user");
+						if (payload.includeGenre) types.push("genre");
+						if (types.length === 0 && !payload.includeOwn) return next("No types have been included.");
+
+						const privacies = ["public"];
+						if (payload.includePrivate) privacies.push("private");
+
+						const includeObject = payload.includeSongs ? null : { songs: false };
+						const filterArray = [
+							{
+								displayName: new RegExp(`${payload.query}`, "i"),
+								privacy: { $in: privacies },
+								type: { $in: types }
+							}
+						];
+
+						if (payload.includeOwn && payload.userId)
+							filterArray.push({
+								displayName: new RegExp(`${payload.query}`, "i"),
+								type: "user",
+								createdBy: payload.userId
+							});
+
+						return next(null, filterArray, includeObject);
+					},
+
+					(filterArray, includeObject, next) => {
+						const page = payload.page ? payload.page : 1;
+						const pageSize = 15;
+						const skipAmount = pageSize * (page - 1);
+
+						PlaylistsModule.playlistModel.find({ $or: filterArray }).count((err, count) => {
+							if (err) next(err);
+							else {
+								PlaylistsModule.playlistModel
+									.find({ $or: filterArray }, includeObject)
+									.skip(skipAmount)
+									.limit(pageSize)
+									.exec((err, playlists) => {
+										if (err) next(err);
+										else {
+											next(null, {
+												playlists,
+												page,
+												pageSize,
+												skipAmount,
+												count
+											});
+										}
+									});
+							}
+						});
+					},
+
+					(data, next) => {
+						if (data.playlists.length > 0) next(null, data);
+						else next("No playlists found");
+					}
+				],
+				(err, data) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(data);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Clears and refills a station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CLEAR_AND_REFILL_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(playlist, next) => {
+						if (playlist.type !== "station") next("This playlist is not a station playlist.");
+						else next(null, playlist.createdFor);
+					},
+
+					(stationId, next) => {
+						PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Clears and refills a genre playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CLEAR_AND_REFILL_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(playlist, next) => {
+						if (playlist.type !== "genre") next("This playlist is not a genre playlist.");
+						else next(null, playlist.createdFor);
+					},
+
+					(genre, next) => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
 }
 }
 
 
-module.exports = new ExampleModule();
+export default new _PlaylistsModule();

+ 279 - 288
backend/logic/punishments.js

@@ -1,315 +1,306 @@
-const CoreClass = require("../core.js");
+import async from "async";
+import mongoose from "mongoose";
+import CoreClass from "../core";
 
 
-const async = require("async");
-const mongoose = require("mongoose");
+let PunishmentsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
 
 
-class PunishmentsModule extends CoreClass {
-    constructor() {
-        super("punishments");
-    }
+class _PunishmentsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("punishments");
 
 
-    initialize() {
-        return new Promise(async (resolve, reject) => {
-            this.setStage(1);
+		PunishmentsModule = this;
+	}
 
 
-            this.cache = this.moduleManager.modules["cache"];
-            this.db = this.moduleManager.modules["db"];
-            this.io = this.moduleManager.modules["io"];
-            this.utils = this.moduleManager.modules["utils"];
+	/**
+	 * Initialises the punishments module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
 
 
-            const punishmentModel = await this.db.runJob("GET_MODEL", {
-                modelName: "punishment",
-            });
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
 
 
-            const punishmentSchema = await this.cache.runJob("GET_SCHEMA", {
-                schemaName: "punishment",
-            });
+		this.punishmentModel = this.PunishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" });
+		this.punishmentSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "punishment" });
 
 
-            async.waterfall(
-                [
-                    (next) => {
-                        this.setStage(2);
-                        this.cache
-                            .runJob("HGETALL", { table: "punishments" })
-                            .then((punishments) => next(null, punishments))
-                            .catch(next);
-                    },
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "punishments" })
+							.then(punishments => {
+								next(null, punishments);
+							})
+							.catch(next);
+					},
 
 
-                    (punishments, next) => {
-                        this.setStage(3);
-                        if (!punishments) return next();
-                        let punishmentIds = Object.keys(punishments);
-                        async.each(
-                            punishmentIds,
-                            (punishmentId, next) => {
-                                punishmentModel.findOne(
-                                    { _id: punishmentId },
-                                    (err, punishment) => {
-                                        if (err) next(err);
-                                        else if (!punishment)
-                                            this.cache
-                                                .runJob("HDEL", {
-                                                    table: "punishments",
-                                                    key: punishmentId,
-                                                })
-                                                .then(() => next())
-                                                .catch(next);
-                                        else next();
-                                    }
-                                );
-                            },
-                            next
-                        );
-                    },
+					(punishments, next) => {
+						this.setStage(3);
 
 
-                    (next) => {
-                        this.setStage(4);
-                        punishmentModel.find({}, next);
-                    },
+						if (!punishments) return next();
 
 
-                    (punishments, next) => {
-                        this.setStage(5);
-                        async.each(
-                            punishments,
-                            (punishment, next) => {
-                                if (
-                                    punishment.active === false ||
-                                    punishment.expiresAt < Date.now()
-                                )
-                                    return next();
-                                this.cache
-                                    .runJob("HSET", {
-                                        table: "punishments",
-                                        key: punishment._id,
-                                        value: punishmentSchema(
-                                            punishment,
-                                            punishment._id
-                                        ),
-                                    })
-                                    .then(() => next())
-                                    .catch(next);
-                            },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await utils.runJob("GET_ERROR", { error: err });
-                        reject(new Error(err));
-                    } else {
-                        resolve();
-                    }
-                }
-            );
-        });
-    }
+						const punishmentIds = Object.keys(punishments);
 
 
-    /**
-     * Gets all punishments in the cache that are active, and removes those that have expired
-     *
-     * @param {Function} cb - gets called once we're done initializing
-     */
-    GET_PUNISHMENTS() {
-        //cb
-        return new Promise((resolve, reject) => {
-            let punishmentsToRemove = [];
-            async.waterfall(
-                [
-                    (next) => {
-                        this.cache
-                            .runJob("HGETALL", { table: "punishments" })
-                            .then((punishmentsObj) =>
-                                next(null, punishmentsObj)
-                            )
-                            .catch(next);
-                    },
+						return async.each(
+							punishmentIds,
+							(punishmentId, cb) => {
+								PunishmentsModule.punishmentModel.findOne({ _id: punishmentId }, (err, punishment) => {
+									if (err) next(err);
+									else if (!punishment)
+										CacheModule.runJob("HDEL", {
+											table: "punishments",
+											key: punishmentId
+										})
+											.then(() => cb())
+											.catch(next);
+									else cb();
+								});
+							},
+							next
+						);
+					},
 
 
-                    (punishmentsObj, next) => {
-                        let punishments = [];
-                        for (let id in punishmentsObj) {
-                            let obj = punishmentsObj[id];
-                            obj.punishmentId = id;
-                            punishments.push(obj);
-                        }
-                        punishments = punishments.filter((punishment) => {
-                            if (punishment.expiresAt < Date.now())
-                                punishmentsToRemove.push(punishment);
-                            return punishment.expiresAt > Date.now();
-                        });
-                        next(null, punishments);
-                    },
+					next => {
+						this.setStage(4);
+						PunishmentsModule.punishmentModel.find({}, next);
+					},
 
 
-                    (punishments, next) => {
-                        async.each(
-                            punishmentsToRemove,
-                            (punishment, next2) => {
-                                this.cache
-                                    .runJob("HDEL", {
-                                        table: "punishments",
-                                        key: punishment.punishmentId,
-                                    })
-                                    .finally(() => next2());
-                            },
-                            () => {
-                                next(null, punishments);
-                            }
-                        );
-                    },
-                ],
-                (err, punishments) => {
-                    if (err && err !== true) return reject(new Error(err));
+					(punishments, next) => {
+						this.setStage(5);
+						async.each(
+							punishments,
+							(punishment, next) => {
+								if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
 
 
-                    resolve(punishments);
-                }
-            );
-        });
-    }
+								return CacheModule.runJob("HSET", {
+									table: "punishments",
+									key: punishment._id,
+									value: PunishmentsModule.punishmentSchemaCache(punishment, punishment._id)
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						const formattedErr = await UtilsModule.runJob("GET_ERROR", { error: err });
+						reject(new Error(formattedErr));
+					} else resolve();
+				}
+			)
+		);
+	}
 
 
-    /**
-     * Gets a punishment by id
-     *
-     * @param {String} id - the id of the punishment we are trying to get
-     * @param {Function} cb - gets called once we're done initializing
-     */
-    GET_PUNISHMENT() {
-        //id, cb
-        return new Promise(async (resolve, reject) => {
-            const punishmentModel = await db.runJob("GET_MODEL", {
-                modelName: "punishment",
-            });
+	/**
+	 * Gets all punishments in the cache that are active, and removes those that have expired
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PUNISHMENTS() {
+		return new Promise((resolve, reject) => {
+			const punishmentsToRemove = [];
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGETALL", { table: "punishments" }, this)
+							.then(punishmentsObj => next(null, punishmentsObj))
+							.catch(next);
+					},
 
 
-            async.waterfall(
-                [
-                    (next) => {
-                        if (!mongoose.Types.ObjectId.isValid(payload.id))
-                            return next("Id is not a valid ObjectId.");
-                        this.cache
-                            .runJob("HGET", {
-                                table: "punishments",
-                                key: payload.id,
-                            })
-                            .then((punishment) => next(null, punishment))
-                            .catch(next);
-                    },
+					(punishmentsObj, next) => {
+						const punishments = Object.keys(punishmentsObj).map(punishmentKey => {
+							const punishment = punishmentsObj[punishmentKey];
+							punishment.punishmentId = punishmentKey;
+							return punishment;
+						});
 
 
-                    (punishment, next) => {
-                        if (punishment) return next(true, punishment);
-                        punishmentModel.findOne({ _id: payload.id }, next);
-                    },
+						const filteredPunishments = punishments.filter(punishment => {
+							if (punishment.expiresAt < Date.now()) punishmentsToRemove.push(punishment);
+							return punishment.expiresAt > Date.now();
+						});
 
 
-                    (punishment, next) => {
-                        if (punishment) {
-                            this.cache
-                                .runJob("HSET", {
-                                    table: "punishments",
-                                    key: payload.id,
-                                    value: punishment,
-                                })
-                                .then((punishment) => next(null, punishment))
-                                .catch(next);
-                        } else next("Punishment not found.");
-                    },
-                ],
-                (err, punishment) => {
-                    if (err && err !== true) return reject(new Error(err));
+						next(null, filteredPunishments);
+					},
 
 
-                    resolve(punishment);
-                }
-            );
-        });
-    }
+					(punishments, next) => {
+						async.each(
+							punishmentsToRemove,
+							(punishment, next2) => {
+								CacheModule.runJob(
+									"HDEL",
+									{
+										table: "punishments",
+										key: punishment.punishmentId
+									},
+									this
+								).finally(() => next2());
+							},
+							() => {
+								next(null, punishments);
+							}
+						);
+					}
+				],
+				(err, punishments) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(punishments);
+				}
+			);
+		});
+	}
 
 
-    /**
-     * Gets all punishments from a userId
-     *
-     * @param {String} userId - the userId of the punishment(s) we are trying to get
-     * @param {Function} cb - gets called once we're done initializing
-     */
-    GET_PUNISHMENTS_FROM_USER_ID(payload) {
-        //userId, cb
-        return new Promise((resolve, reject) => {
-            async.waterfall(
-                [
-                    (next) => {
-                        this.runJob("GET_PUNISHMENTS", {})
-                            .then((punishments) => next(null, punishments))
-                            .catch(next);
-                    },
-                    (punishments, next) => {
-                        punishments = punishments.filter((punishment) => {
-                            return (
-                                punishment.type === "banUserId" &&
-                                punishment.value === payload.userId
-                            );
-                        });
-                        next(null, punishments);
-                    },
-                ],
-                (err, punishments) => {
-                    if (err && err !== true) return reject(new Error(err));
+	/**
+	 * Gets a punishment by id
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.id - the id of the punishment we are trying to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PUNISHMENT(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						if (!mongoose.Types.ObjectId.isValid(payload.id)) return next("Id is not a valid ObjectId.");
+						return CacheModule.runJob(
+							"HGET",
+							{
+								table: "punishments",
+								key: payload.id
+							},
+							this
+						)
+							.then(punishment => next(null, punishment))
+							.catch(next);
+					},
 
 
-                    resolve(punishments);
-                }
-            );
-        });
-    }
+					(punishment, next) => {
+						if (punishment) return next(true, punishment);
+						return PunishmentsModule.punishmentModel.findOne({ _id: payload.id }, next);
+					},
 
 
-    ADD_PUNISHMENT(payload) {
-        //type, value, reason, expiresAt, punishedBy, cb
-        return new Promise(async (resolve, reject) => {
-            const punishmentModel = await db.runJob("GET_MODEL", {
-                modelName: "punishment",
-            });
+					(punishment, next) => {
+						if (punishment) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "punishments",
+									key: payload.id,
+									value: punishment
+								},
+								this
+							)
+								.then(punishment => {
+									next(null, punishment);
+								})
+								.catch(next);
+						} else next("Punishment not found.");
+					}
+				],
+				(err, punishment) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(punishment);
+				}
+			)
+		);
+	}
 
 
-            const punishmentSchema = await cache.runJob("GET_SCHEMA", {
-                schemaName: "punishment",
-            });
+	/**
+	 * Gets all punishments from a userId
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.userId - the userId of the punishment(s) we are trying to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PUNISHMENTS_FROM_USER_ID(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						PunishmentsModule.runJob("GET_PUNISHMENTS", {}, this)
+							.then(punishments => {
+								next(null, punishments);
+							})
+							.catch(next);
+					},
+					(punishments, next) => {
+						const filteredPunishments = punishments.filter(
+							punishment => punishment.type === "banUserId" && punishment.value === payload.userId
+						);
+						next(null, filteredPunishments);
+					}
+				],
+				(err, punishments) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(punishments);
+				}
+			);
+		});
+	}
 
 
-            async.waterfall(
-                [
-                    (next) => {
-                        const punishment = new punishmentModel({
-                            type: payload.type,
-                            value: payload.value,
-                            reason: payload.reason,
-                            active: true,
-                            expiresAt: payload.expiresAt,
-                            punishedAt: Date.now(),
-                            punishedBy: payload.punishedBy,
-                        });
-                        punishment.save((err, punishment) => {
-                            if (err) return next(err);
-                            next(null, punishment);
-                        });
-                    },
+	/**
+	 * Adds a new punishment to the database
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.reason - the reason for the punishment e.g. spam
+	 * @param {string} payload.type - the type of punishment (enum: ["banUserId", "banUserIp"])
+	 * @param {string} payload.value - the user id/ip address for the ban (depends on punishment type)
+	 * @param {Date} payload.expiresAt - the date at which the punishment expires at
+	 * @param {string} payload.punishedBy - the userId of the who initiated the punishment
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	ADD_PUNISHMENT(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const punishment = new PunishmentsModule.PunishmentModel({
+							type: payload.type,
+							value: payload.value,
+							reason: payload.reason,
+							active: true,
+							expiresAt: payload.expiresAt,
+							punishedAt: Date.now(),
+							punishedBy: payload.punishedBy
+						});
+						punishment.save((err, punishment) => {
+							if (err) return next(err);
+							return next(null, punishment);
+						});
+					},
 
 
-                    (punishment, next) => {
-                        this.cache
-                            .runJob("HSET", {
-                                table: "punishments",
-                                key: punishment._id,
-                                value: punishmentSchema(
-                                    punishment,
-                                    punishment._id
-                                ),
-                            })
-                            .then(() => next())
-                            .catch(next);
-                    },
-
-                    (punishment, next) => {
-                        // DISCORD MESSAGE
-                        next(null, punishment);
-                    },
-                ],
-                (err, punishment) => {
-                    if (err) return reject(new Error(err));
-                    resolve(punishment);
-                }
-            );
-        });
-    }
+					(punishment, next) => {
+						CacheModule.runJob(
+							"HSET",
+							{
+								table: "punishments",
+								key: punishment._id,
+								value: PunishmentsModule.punishmentSchemaCache(punishment, punishment._id)
+							},
+							this
+						)
+							.then(() => next(null, punishment))
+							.catch(next);
+					}
+				],
+				(err, punishment) => {
+					if (err) return reject(new Error(err));
+					return resolve(punishment);
+				}
+			)
+		);
+	}
 }
 }
 
 
-module.exports = new PunishmentsModule();
+export default new _PunishmentsModule();

+ 1183 - 257
backend/logic/songs.js

@@ -1,259 +1,1185 @@
-const CoreClass = require("../core.js");
-
-const async = require("async");
-const mongoose = require("mongoose");
-
-class SongsModule extends CoreClass {
-    constructor() {
-        super("songs");
-    }
-
-    initialize() {
-        return new Promise(async (resolve, reject) => {
-            this.setStage(1);
-
-            this.cache = this.moduleManager.modules["cache"];
-            this.db = this.moduleManager.modules["db"];
-            this.io = this.moduleManager.modules["io"];
-            this.utils = this.moduleManager.modules["utils"];
-
-            const songModel = await this.db.runJob("GET_MODEL", {
-                modelName: "song",
-            });
-
-            const songSchema = await this.cache.runJob("GET_SCHEMA", {
-                schemaName: "song",
-            });
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.setStage(2);
-                        this.cache
-                            .runJob("HGETALL", { table: "songs" })
-                            .then((songs) => next(null, songs))
-                            .catch(next);
-                    },
-
-                    (songs, next) => {
-                        this.setStage(3);
-                        if (!songs) return next();
-                        let songIds = Object.keys(songs);
-                        async.each(
-                            songIds,
-                            (songId, next) => {
-                                songModel.findOne({ songId }, (err, song) => {
-                                    if (err) next(err);
-                                    else if (!song)
-                                        this.cache
-                                            .runJob("HDEL", {
-                                                table: "songs",
-                                                key: songId,
-                                            })
-                                            .then(() => next())
-                                            .catch(next);
-                                    else next();
-                                });
-                            },
-                            next
-                        );
-                    },
-
-                    (next) => {
-                        this.setStage(4);
-                        songModel.find({}, next);
-                    },
-
-                    (songs, next) => {
-                        this.setStage(5);
-                        async.each(
-                            songs,
-                            (song, next) => {
-                                this.cache
-                                    .runJob("HSET", {
-                                        table: "songs",
-                                        key: song.songId,
-                                        value: songSchema(song),
-                                    })
-                                    .then(() => next())
-                                    .catch(next);
-                            },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else {
-                        resolve();
-                    }
-                }
-            );
-        });
-    }
-
-    /**
-     * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-     *
-     * @param {String} id - the id of the song we are trying to get
-     * @param {Function} cb - gets called once we're done initializing
-     */
-    GET_SONG(payload) {
-        //id, cb
-        return new Promise(async (resolve, reject) => {
-            const songModel = await this.db.runJob("GET_MODEL", {
-                modelName: "song",
-            });
-
-            async.waterfall(
-                [
-                    (next) => {
-                        if (!mongoose.Types.ObjectId.isValid(payload.id))
-                            return next("Id is not a valid ObjectId.");
-                        this.cache
-                            .runJob("HGET", { table: "songs", key: payload.id })
-                            .then((song) => next(null, song))
-                            .catch(next);
-                    },
-
-                    (song, next) => {
-                        if (song) return next(true, song);
-                        songModel.findOne({ _id: payload.id }, next);
-                    },
-
-                    (song, next) => {
-                        if (song) {
-                            this.cache
-                                .runJob("HSET", {
-                                    table: "songs",
-                                    key: payload.id,
-                                    value: song,
-                                })
-                                .then((song) => next(null, song));
-                        } else next("Song not found.");
-                    },
-                ],
-                (err, song) => {
-                    if (err && err !== true) return reject(new Error(err));
-
-                    resolve({ song });
-                }
-            );
-        });
-    }
-
-    /**
-     * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-     *
-     * @param {String} songId - the mongo id of the song we are trying to get
-     * @param {Function} cb - gets called once we're done initializing
-     */
-    GET_SONG_FROM_ID(payload) {
-        //songId, cb
-        return new Promise(async (resolve, reject) => {
-            const songModel = await this.db.runJob("GET_MODEL", {
-                modelName: "song",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        songModel.findOne({ songId: payload.songId }, next);
-                    },
-                ],
-                (err, song) => {
-                    if (err && err !== true) return reject(new Error(err));
-                    resolve({ song });
-                }
-            );
-        });
-    }
-
-    /**
-     * Gets a song from id from Mongo and updates the cache with it
-     *
-     * @param {String} songId - the id of the song we are trying to update
-     * @param {Function} cb - gets called when an error occurred or when the operation was successful
-     */
-    UPDATE_SONG(payload) {
-        //songId, cb
-        return new Promise(async (resolve, reject) => {
-            const songModel = await this.db.runJob("GET_MODEL", {
-                modelName: "song",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        songModel.findOne({ _id: payload.songId }, next);
-                    },
-
-                    (song, next) => {
-                        if (!song) {
-                            this.cache.runJob("HDEL", {
-                                table: "songs",
-                                key: payload.songId,
-                            });
-                            return next("Song not found.");
-                        }
-
-                        this.cache
-                            .runJob("HSET", {
-                                table: "songs",
-                                key: payload.songId,
-                                value: song,
-                            })
-                            .then((song) => next(null, song))
-                            .catch(next);
-                    },
-                ],
-                (err, song) => {
-                    if (err && err !== true) return reject(new Error(err));
-
-                    resolve(song);
-                }
-            );
-        });
-    }
-
-    /**
-     * Deletes song from id from Mongo and cache
-     *
-     * @param {String} songId - the id of the song we are trying to delete
-     * @param {Function} cb - gets called when an error occurred or when the operation was successful
-     */
-    DELETE_SONG(payload) {
-        //songId, cb
-        return new Promise(async (resolve, reject) => {
-            const songModel = await this.db.runJob("GET_MODEL", {
-                modelName: "song",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        songModel.deleteOne({ songId: payload.songId }, next);
-                    },
-
-                    (next) => {
-                        this.cache
-                            .runJob("HDEL", {
-                                table: "songs",
-                                key: payload.songId,
-                            })
-                            .then(() => next())
-                            .catch(next);
-                    },
-                ],
-                (err) => {
-                    if (err && err !== true) return reject(new Error(err));
-
-                    resolve();
-                }
-            );
-        });
-    }
+import async from "async";
+import config from "config";
+import mongoose from "mongoose";
+import CoreClass from "../core";
+
+let SongsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+let YouTubeModule;
+let StationsModule;
+let PlaylistsModule;
+
+class ErrorWithData extends Error {
+	/**
+	 * @param {string} message - the error message
+	 * @param {object} data - the error data
+	 */
+	constructor(message, data) {
+		super(message);
+		this.data = data;
+	}
+}
+
+class _SongsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("songs");
+
+		SongsModule = this;
+	}
+
+	/**
+	 * Initialises the songs module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		YouTubeModule = this.moduleManager.modules.youtube;
+		StationsModule = this.moduleManager.modules.stations;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+
+		this.SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
+		this.SongSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" });
+
+		this.setStage(2);
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "songs" })
+							.then(songs => {
+								next(null, songs);
+							})
+							.catch(next);
+					},
+
+					(songs, next) => {
+						this.setStage(3);
+
+						if (!songs) return next();
+
+						const youtubeIds = Object.keys(songs);
+
+						return async.each(
+							youtubeIds,
+							(youtubeId, next) => {
+								SongsModule.SongModel.findOne({ youtubeId }, (err, song) => {
+									if (err) next(err);
+									else if (!song)
+										CacheModule.runJob("HDEL", {
+											table: "songs",
+											key: youtubeId
+										})
+											.then(() => next())
+											.catch(next);
+									else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						SongsModule.SongModel.find({}, next);
+					},
+
+					(songs, next) => {
+						this.setStage(5);
+						async.each(
+							songs,
+							(song, next) => {
+								CacheModule.runJob("HSET", {
+									table: "songs",
+									key: song.youtubeId,
+									value: SongsModule.SongSchemaCache(song)
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err });
+						reject(new Error(err));
+					} else resolve();
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.songId - the id of the song we are trying to get
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SONG(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						if (!mongoose.Types.ObjectId.isValid(payload.songId))
+							return next("songId is not a valid ObjectId.");
+						return CacheModule.runJob("HGET", { table: "songs", key: payload.songId }, this)
+							.then(song => next(null, song))
+							.catch(next);
+					},
+
+					(song, next) => {
+						if (song) return next(true, song);
+						return SongsModule.SongModel.findOne({ _id: payload.songId }, next);
+					},
+
+					(song, next) => {
+						if (song) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "songs",
+									key: payload.songId,
+									value: song
+								},
+								this
+							).then(song => next(null, song));
+						} else next("Song not found.");
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets songs by id from Mongo
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.songIds - the ids of the songs we are trying to get
+	 * @param {string} payload.properties - the properties to return
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SONGS(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						if (!payload.songIds.every(songId => mongoose.Types.ObjectId.isValid(songId)))
+							next("One or more songIds are not a valid ObjectId.");
+						else next();
+					},
+
+					next => {
+						const includeProperties = {};
+						payload.properties.forEach(property => {
+							includeProperties[property] = true;
+						});
+						return SongsModule.SongModel.find(
+							{
+								_id: { $in: payload.songIds }
+							},
+							includeProperties,
+							next
+						);
+					}
+				],
+				(err, songs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ songs });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Makes sure that if a song is not currently in the songs db, to add it
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube song id of the song we are trying to ensure is in the songs db
+	 * @param {string} payload.userId - the youtube song id of the song we are trying to ensure is in the songs db
+	 * @param {string} payload.automaticallyRequested - whether the song was automatically requested or not
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	ENSURE_SONG_EXISTS_BY_YOUTUBE_ID(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
+					},
+
+					(song, next) => {
+						if (song && song.duration > 0) next(true, song);
+						else {
+							YouTubeModule.runJob("GET_SONG", { youtubeId: payload.youtubeId }, this)
+								.then(response => {
+									next(null, song, response.song);
+								})
+								.catch(next);
+						}
+					},
+
+					(song, youtubeSong, next) => {
+						if (song && song.duration <= 0) {
+							song.duration = youtubeSong.duration;
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) return next(err, song);
+								return next(null, song);
+							});
+						} else {
+							const status =
+								(!payload.userId && config.get("hideAnonymousSongs")) ||
+								(payload.automaticallyRequested && config.get("hideAutomaticallyRequestedSongs"))
+									? "hidden"
+									: "unverified";
+
+							const song = new SongsModule.SongModel({
+								...youtubeSong,
+								status,
+								requestedBy: payload.userId,
+								requestedAt: Date.now()
+							});
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) return next(err, song);
+								return next(null, song);
+							});
+						}
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets a song by youtube id
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id of the song we are trying to get
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SONG_FROM_YOUTUBE_ID(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets a song from id from Mongo and updates the cache with it
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.songId - the id of the song we are trying to update
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_SONG(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: payload.songId }, next);
+					},
+
+					(song, next) => {
+						if (!song) {
+							CacheModule.runJob("HDEL", {
+								table: "songs",
+								key: payload.songId
+							});
+							return next("Song not found.");
+						}
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "songs",
+								key: payload.songId,
+								value: song
+							},
+							this
+						)
+							.then(song => {
+								next(null, song);
+							})
+							.catch(next);
+					},
+
+					(song, next) => {
+						const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+						const trimmedSong = {
+							_id,
+							youtubeId,
+							title,
+							artists,
+							thumbnail,
+							duration,
+							status
+						};
+						this.log("INFO", `Going to update playlists now for song ${_id}`);
+						DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this)
+							.then(playlistModel => {
+								playlistModel.updateMany(
+									{ "songs._id": song._id },
+									{ $set: { "songs.$": trimmedSong } },
+									err => {
+										if (err) next(err);
+										else
+											playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														playlists,
+														1,
+														(playlist, next) => {
+															PlaylistsModule.runJob(
+																"UPDATE_PLAYLIST",
+																{
+																	playlistId: playlist._id
+																},
+																this
+															)
+																.then(() => {
+																	next();
+																})
+																.catch(err => {
+																	next(err);
+																});
+														},
+														err => {
+															if (err) next(err);
+															else next(null, song);
+														}
+													);
+												}
+											});
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(song, next) => {
+						const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+						this.log("INFO", `Going to update stations now for song ${_id}`);
+						DBModule.runJob("GET_MODEL", { modelName: "station" }, this)
+							.then(stationModel => {
+								stationModel.updateMany(
+									{ "queue._id": song._id },
+									{
+										$set: {
+											"queue.$.youtubeId": youtubeId,
+											"queue.$.title": title,
+											"queue.$.artists": artists,
+											"queue.$.thumbnail": thumbnail,
+											"queue.$.duration": duration,
+											"queue.$.status": status
+										}
+									},
+									err => {
+										if (err) this.log("ERROR", err);
+										else
+											stationModel.find({ "queue._id": song._id }, (err, stations) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														stations,
+														1,
+														(station, next) => {
+															StationsModule.runJob(
+																"UPDATE_STATION",
+																{ stationId: station._id },
+																this
+															)
+																.then(() => {
+																	next();
+																})
+																.catch(err => {
+																	next(err);
+																});
+														},
+														err => {
+															if (err) next(err);
+															else next(null, song);
+														}
+													);
+												}
+											});
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(song, next) => {
+						async.eachLimit(
+							song.genres,
+							1,
+							(genre, next) => {
+								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => next(err));
+							},
+							err => {
+								next(err, song);
+							}
+						);
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(song);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Updates all songs
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_ALL_SONGS() {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({}, next);
+					},
+
+					(songs, next) => {
+						let index = 0;
+						const { length } = songs;
+						async.eachLimit(
+							songs,
+							2,
+							(song, next) => {
+								index += 1;
+								console.log(`Updating song #${index} out of ${length}: ${song._id}`);
+								SongsModule.runJob("UPDATE_SONG", { songId: song._id }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err);
+							}
+						);
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			)
+		);
+	}
+
+	// /**
+	//  * Deletes song from id from Mongo and cache
+	//  *
+	//  * @param {object} payload - returns an object containing the payload
+	//  * @param {string} payload.songId - the song id of the song we are trying to delete
+	//  * @returns {Promise} - returns a promise (resolve, reject)
+	//  */
+	// DELETE_SONG(payload) {
+	// 	return new Promise((resolve, reject) =>
+	// 		async.waterfall(
+	// 			[
+	// 				next => {
+	// 					SongsModule.SongModel.deleteOne({ _id: payload.songId }, next);
+	// 				},
+
+	// 				next => {
+	// 					CacheModule.runJob(
+	// 						"HDEL",
+	// 						{
+	// 							table: "songs",
+	// 							key: payload.songId
+	// 						},
+	// 						this
+	// 					)
+	// 						.then(() => next())
+	// 						.catch(next);
+	// 				},
+
+	// 				next => {
+	// 					this.log("INFO", `Going to update playlists and stations now for deleted song ${payload.songId}`);
+	// 					DBModule.runJob("GET_MODEL", { modelName: "playlist" }).then(playlistModel => {
+	// 						playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
+	// 							if (err) this.log("ERROR", err);
+	// 							else {
+	// 								playlistModel.updateMany(
+	// 									{ "songs._id": payload.songId },
+	// 									{ $pull: { "songs.$._id": payload.songId} },
+	// 									err => {
+	// 										if (err) this.log("ERROR", err);
+	// 										else {
+	// 											playlists.forEach(playlist => {
+	// 												PlaylistsModule.runJob("UPDATE_PLAYLIST", {
+	// 													playlistId: playlist._id
+	// 												});
+	// 											});
+	// 										}
+	// 									}
+	// 								);
+
+	// 							}
+	// 						});
+	// 					});
+	// 					DBModule.runJob("GET_MODEL", { modelName: "station" }).then(stationModel => {
+	// 						stationModel.find({ "queue._id": payload.songId }, (err, stations) => {
+	// 							stationModel.updateMany(
+	// 								{ "queue._id": payload.songId },
+	// 								{
+	// 									$pull: { "queue._id":  }
+	// 								},
+	// 								err => {
+	// 									if (err) this.log("ERROR", err);
+	// 									else {
+	// 										stations.forEach(station => {
+	// 											StationsModule.runJob("UPDATE_STATION", { stationId: station._id });
+	// 										});
+	// 									}
+	// 								}
+	// 							);
+	// 						});
+	// 					});
+	// 				}
+	// 			],
+	// 			err => {
+	// 				if (err && err !== true) return reject(new Error(err));
+	// 				return resolve();
+	// 			}
+	// 		)
+	// 	);
+	// }
+
+	/**
+	 * Searches through songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query
+	 * @param {string} payload.includeHidden - include hidden songs
+	 * @param {string} payload.includeUnverified - include unverified songs
+	 * @param {string} payload.includeVerified - include verified songs
+	 * @param {string} payload.trimmed - include trimmed songs
+	 * @param {string} payload.page - page (default 1)
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const statuses = [];
+						if (payload.includeHidden) statuses.push("hidden");
+						if (payload.includeUnverified) statuses.push("unverified");
+						if (payload.includeVerified) statuses.push("verified");
+						if (statuses.length === 0) return next("No statuses have been included.");
+
+						let { query } = payload;
+
+						const isRegex =
+							query.length > 2 && query.indexOf("/") === 0 && query.lastIndexOf("/") === query.length - 1;
+						if (isRegex) query = query.slice(1, query.length - 1);
+						else query = query.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+						const filterArray = [
+							{
+								title: new RegExp(`${query}`, "i"),
+								status: { $in: statuses }
+							},
+							{
+								artists: new RegExp(`${query}`, "i"),
+								status: { $in: statuses }
+							}
+						];
+
+						return next(null, filterArray);
+					},
+
+					(filterArray, next) => {
+						const page = payload.page ? payload.page : 1;
+						const pageSize = 15;
+						const skipAmount = pageSize * (page - 1);
+
+						SongsModule.SongModel.find({ $or: filterArray }).count((err, count) => {
+							if (err) next(err);
+							else {
+								SongsModule.SongModel.find({ $or: filterArray })
+									.skip(skipAmount)
+									.limit(pageSize)
+									.exec((err, songs) => {
+										if (err) next(err);
+										else {
+											next(null, {
+												songs,
+												page,
+												pageSize,
+												skipAmount,
+												count
+											});
+										}
+									});
+							}
+						});
+					},
+
+					(data, next) => {
+						if (data.songs.length === 0) next("No songs found");
+						else if (payload.trimmed) {
+							next(null, {
+								songs: data.songs.map(song => {
+									const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+									return {
+										_id,
+										youtubeId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										status
+									};
+								}),
+								...data
+							});
+						} else next(null, data);
+					}
+				],
+				(err, data) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(data);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Recalculates dislikes and likes for a song
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id of the song
+	 * @param {string} payload.songId - the song id of the song
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async RECALCULATE_SONG_RATINGS(payload) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Liked Songs" },
+							(err, likes) => {
+								if (err) return next(err);
+								return next(null, likes);
+							}
+						);
+					},
+
+					(likes, next) => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Disliked Songs" },
+							(err, dislikes) => {
+								if (err) return next(err);
+								return next(err, { likes, dislikes });
+							}
+						);
+					},
+
+					({ likes, dislikes }, next) => {
+						SongsModule.SongModel.updateOne(
+							{ _id: payload.songId },
+							{
+								$set: {
+									likes,
+									dislikes
+								}
+							},
+							err => next(err, { likes, dislikes })
+						);
+					}
+				],
+				(err, { likes, dislikes }) => {
+					if (err) return reject(new Error(err));
+					return resolve({ likes, dislikes });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets an array of all genres
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_ALL_GENRES() {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({ status: "verified" }, { genres: 1, _id: false }, next);
+					},
+
+					(songs, next) => {
+						let allGenres = [];
+						songs.forEach(song => {
+							allGenres = allGenres.concat(song.genres);
+						});
+
+						const lowerCaseGenres = allGenres.map(genre => genre.toLowerCase());
+						const uniqueGenres = lowerCaseGenres.filter(
+							(value, index, self) => self.indexOf(value) === index
+						);
+
+						next(null, uniqueGenres);
+					}
+				],
+				(err, genres) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ genres });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets an array of all songs with a specific genre
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.genre - the genre
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_ALL_SONGS_WITH_GENRE(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find(
+							{
+								status: "verified",
+								genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") }
+							},
+							next
+						);
+					}
+				],
+				(err, songs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ songs });
+				}
+			)
+		);
+	}
+
+	// runjob songs GET_ORPHANED_PLAYLIST_SONGS {}
+
+	/**
+	 * Gets a orphaned playlist songs
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_PLAYLIST_SONGS() {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this).then(playlistModel => {
+				playlistModel.find({}, (err, playlists) => {
+					if (err) reject(new Error(err));
+					else {
+						SongsModule.SongModel.find({}, { _id: true, youtubeId: true }, (err, songs) => {
+							if (err) reject(new Error(err));
+							else {
+								const songIds = songs.map(song => song._id.toString());
+								const orphanedYoutubeIds = new Set();
+								async.eachLimit(
+									playlists,
+									1,
+									(playlist, next) => {
+										playlist.songs.forEach(song => {
+											if (
+												(!song._id || songIds.indexOf(song._id.toString() === -1)) &&
+												!orphanedYoutubeIds.has(song.youtubeId)
+											) {
+												orphanedYoutubeIds.add(song.youtubeId);
+											}
+										});
+										next();
+									},
+									() => {
+										resolve({ youtubeIds: Array.from(orphanedYoutubeIds) });
+									}
+								);
+							}
+						});
+					}
+				});
+			});
+		});
+	}
+
+	/**
+	 * Requests a song, adding it to the DB
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.youtubeId - The YouTube song id of the song
+	 * @param {string} payload.userId - The user id of the person requesting the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REQUEST_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { youtubeId, userId } = payload;
+			const requestedAt = Date.now();
+
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+							.then(UserModel => {
+								UserModel.findOne({ _id: userId }, { "preferences.anonymousSongRequests": 1 }, next);
+							})
+							.catch(next);
+					},
+
+					(user, next) => {
+						SongsModule.SongModel.findOne({ youtubeId }, (err, song) => next(err, user, song));
+					},
+
+					// Get YouTube data from id
+					(user, song, next) => {
+						if (song) return next("This song is already in the database.", song);
+						// TODO Add err object as first param of callback
+
+						const requestedBy = user.preferences.anonymousSongRequests ? null : userId;
+						const status = !requestedBy && config.get("hideAnonymousSongs") ? "hidden" : "unverified";
+
+						return YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
+							.then(response => {
+								const { song } = response;
+								song.artists = [];
+								song.genres = [];
+								song.skipDuration = 0;
+								song.explicit = false;
+								song.requestedBy = user.preferences.anonymousSongRequests ? null : userId;
+								song.requestedAt = requestedAt;
+								song.status = status;
+								next(null, song);
+							})
+							.catch(next);
+					},
+					(newSong, next) => {
+						const song = new SongsModule.SongModel(newSong);
+						song.save({ validateBeforeSave: false }, err => {
+							if (err) return next(err, song);
+							return next(null, song);
+						});
+					},
+					(song, next) => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+							.then(UserModel => {
+								UserModel.findOne({ _id: userId }, (err, user) => {
+									if (err) return next(err);
+									if (!user) return next(null, song);
+
+									user.statistics.songsRequested += 1;
+
+									return user.save(err => {
+										if (err) return next(err);
+										return next(null, song);
+									});
+								});
+							})
+							.catch(next);
+					}
+				],
+				async (err, song) => {
+					if (err && err !== "This song is already in the database.") return reject(err);
+
+					const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+					const trimmedSong = {
+						_id,
+						youtubeId,
+						title,
+						artists,
+						thumbnail,
+						duration,
+						status
+					};
+
+					if (err && err === "This song is already in the database.")
+						return reject(new ErrorWithData(err, { song: trimmedSong }));
+
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+					CacheModule.runJob("PUB", {
+						channel: "song.newUnverifiedSong",
+						value: song._id
+					});
+
+					return resolve({ song: trimmedSong });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Hides a song
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The song id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	HIDE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: songId }, next);
+					},
+
+					// Get YouTube data from id
+					(song, next) => {
+						if (!song) return next("This song does not exist.");
+						if (song.status === "hidden") return next("This song is already hidden.");
+						// TODO Add err object as first param of callback
+						return next();
+					},
+
+					next => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, next);
+					},
+
+					(res, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId });
+						next();
+					}
+				],
+				async err => {
+					if (err) reject(err);
+
+					CacheModule.runJob("PUB", {
+						channel: "song.newHiddenSong",
+						value: songId
+					});
+
+					CacheModule.runJob("PUB", {
+						channel: "song.removedUnverifiedSong",
+						value: songId
+					});
+
+					CacheModule.runJob("PUB", {
+						channel: "song.removedVerifiedSong",
+						value: songId
+					});
+
+					resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Unhides a song
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The song id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	UNHIDE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: songId }, next);
+					},
+
+					// Get YouTube data from id
+					(song, next) => {
+						if (!song) return next("This song does not exist.");
+						if (song.status !== "hidden") return next("This song is not hidden.");
+						// TODO Add err object as first param of callback
+						return next();
+					},
+
+					next => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "unverified" }, next);
+					},
+
+					(res, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId });
+						next();
+					}
+				],
+				async err => {
+					if (err) reject(err);
+
+					CacheModule.runJob("PUB", {
+						channel: "song.newUnverifiedSong",
+						value: songId
+					});
+
+					CacheModule.runJob("PUB", {
+						channel: "song.removedHiddenSong",
+						value: songId
+					});
+
+					resolve();
+				}
+			);
+		});
+	}
+
+	// runjob songs REQUEST_ORPHANED_PLAYLIST_SONGS {}
+
+	/**
+	 * Requests all orphaned playlist songs, adding them to the database
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REQUEST_ORPHANED_PLAYLIST_SONGS() {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob("GET_MODEL", { modelName: "playlist" })
+				.then(playlistModel => {
+					SongsModule.runJob("GET_ORPHANED_PLAYLIST_SONGS", {}, this).then(response => {
+						const { youtubeIds } = response;
+						const playlistsToUpdate = new Set();
+
+						async.eachLimit(
+							youtubeIds,
+							1,
+							(youtubeId, next) => {
+								async.waterfall(
+									[
+										next => {
+											console.log(
+												youtubeId,
+												`this is song ${youtubeIds.indexOf(youtubeId) + 1}/${youtubeIds.length}`
+											);
+											setTimeout(next, 150);
+										},
+
+										next => {
+											SongsModule.runJob(
+												"ENSURE_SONG_EXISTS_BY_SONG_ID",
+												{ youtubeId, automaticallyRequested: true },
+												this
+											)
+												.then(() => next())
+												.catch(next);
+										},
+
+										next => {
+											console.log(444, youtubeId);
+
+											SongsModule.SongModel.findOne({ youtubeId }, next);
+										},
+
+										(song, next) => {
+											const { _id, title, artists, thumbnail, duration, status } = song;
+											const trimmedSong = {
+												_id,
+												youtubeId,
+												title,
+												artists,
+												thumbnail,
+												duration,
+												status
+											};
+											playlistModel.updateMany(
+												{ "songs.youtubeId": song.youtubeId },
+												{ $set: { "songs.$": trimmedSong } },
+												err => {
+													next(err, song);
+												}
+											);
+										},
+
+										(song, next) => {
+											playlistModel.find({ "songs._id": song._id }, next);
+										},
+
+										(playlists, next) => {
+											playlists.forEach(playlist => {
+												playlistsToUpdate.add(playlist._id.toString());
+											});
+
+											next();
+										}
+									],
+									next
+								);
+							},
+							err => {
+								if (err) reject(err);
+								else {
+									async.eachLimit(
+										Array.from(playlistsToUpdate),
+										1,
+										(playlistId, next) => {
+											PlaylistsModule.runJob(
+												"UPDATE_PLAYLIST",
+												{
+													playlistId
+												},
+												this
+											)
+												.then(() => {
+													next();
+												})
+												.catch(next);
+										},
+										err => {
+											if (err) reject(err);
+											else resolve();
+										}
+									);
+								}
+							}
+						);
+					});
+				})
+				.catch(reject);
+		});
+	}
 }
 }
 
 
-module.exports = new SongsModule();
+export default new _SongsModule();

+ 0 - 116
backend/logic/spotify.js

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

+ 1670 - 1169
backend/logic/stations.js

@@ -1,1171 +1,1672 @@
-const CoreClass = require("../core.js");
-
-const async = require("async");
-
-let subscription = null;
-
-class StationsModule extends CoreClass {
-    constructor() {
-        super("stations");
-    }
-
-    initialize() {
-        return new Promise(async (resolve, reject) => {
-            this.cache = this.moduleManager.modules["cache"];
-            this.db = this.moduleManager.modules["db"];
-            this.utils = this.moduleManager.modules["utils"];
-            this.songs = this.moduleManager.modules["songs"];
-            this.notifications = this.moduleManager.modules["notifications"];
-
-            this.defaultSong = {
-                songId: "60ItHLz5WEA",
-                title: "Faded - Alan Walker",
-                duration: 212,
-                skipDuration: 0,
-                likes: -1,
-                dislikes: -1,
-            };
-
-            //TEMP
-            this.cache.runJob("SUB", {
-                channel: "station.pause",
-                cb: async (stationId) => {
-                    this.notifications
-                        .runJob("REMOVE", {
-                            subscription: `stations.nextSong?id=${stationId}`,
-                        })
-                        .then();
-                },
-            });
-
-            this.cache.runJob("SUB", {
-                channel: "station.resume",
-                cb: async (stationId) => {
-                    this.runJob("INITIALIZE_STATION", { stationId }).then();
-                },
-            });
-
-            this.cache.runJob("SUB", {
-                channel: "station.queueUpdate",
-                cb: async (stationId) => {
-                    this.runJob("GET_STATION", { stationId }).then(
-                        (station) => {
-                            if (
-                                !station.currentSong &&
-                                station.queue.length > 0
-                            ) {
-                                this.runJob("INITIALIZE_STATION", {
-                                    stationId,
-                                }).then();
-                            }
-                        }
-                    );
-                },
-            });
-
-            this.cache.runJob("SUB", {
-                channel: "station.newOfficialPlaylist",
-                cb: async (stationId) => {
-                    this.cache
-                        .runJob("HGET", {
-                            table: "officialPlaylists",
-                            key: stationId,
-                        })
-                        .then((playlistObj) => {
-                            if (playlistObj) {
-                                this.utils.runJob("EMIT_TO_ROOM", {
-                                    room: `station.${stationId}`,
-                                    args: [
-                                        "event:newOfficialPlaylist",
-                                        playlistObj.songs,
-                                    ],
-                                });
-                            }
-                        });
-                },
-            });
-
-            const stationModel = (this.stationModel = await this.db.runJob(
-                "GET_MODEL",
-                {
-                    modelName: "station",
-                }
-            ));
-
-            const stationSchema = (this.stationSchema = await this.cache.runJob(
-                "GET_SCHEMA",
-                {
-                    schemaName: "station",
-                }
-            ));
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.setStage(2);
-                        this.cache
-                            .runJob("HGETALL", { table: "stations" })
-                            .then((stations) => next(null, stations))
-                            .catch(next);
-                    },
-
-                    (stations, next) => {
-                        this.setStage(3);
-                        if (!stations) return next();
-                        let stationIds = Object.keys(stations);
-                        async.each(
-                            stationIds,
-                            (stationId, next) => {
-                                stationModel.findOne(
-                                    { _id: stationId },
-                                    (err, station) => {
-                                        if (err) next(err);
-                                        else if (!station) {
-                                            this.cache
-                                                .runJob("HDEL", {
-                                                    table: "stations",
-                                                    key: stationId,
-                                                })
-                                                .then(() => next())
-                                                .catch(next);
-                                        } else next();
-                                    }
-                                );
-                            },
-                            next
-                        );
-                    },
-
-                    (next) => {
-                        this.setStage(4);
-                        stationModel.find({}, next);
-                    },
-
-                    (stations, next) => {
-                        this.setStage(5);
-                        async.each(
-                            stations,
-                            (station, next2) => {
-                                async.waterfall(
-                                    [
-                                        (next) => {
-                                            this.cache
-                                                .runJob("HSET", {
-                                                    table: "stations",
-                                                    key: station._id,
-                                                    value: stationSchema(
-                                                        station
-                                                    ),
-                                                })
-                                                .then((station) =>
-                                                    next(null, station)
-                                                )
-                                                .catch(next);
-                                        },
-
-                                        (station, next) => {
-                                            this.runJob(
-                                                "INITIALIZE_STATION",
-                                                {
-                                                    stationId: station._id,
-                                                    bypassQueue: true,
-                                                },
-                                                { bypassQueue: true }
-                                            )
-                                                .then(() => next())
-                                                .catch(next); // bypassQueue is true because otherwise the module will never initialize
-                                        },
-                                    ],
-                                    (err) => {
-                                        next2(err);
-                                    }
-                                );
-                            },
-                            next
-                        );
-                    },
-                ],
-                async (err) => {
-                    if (err) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else {
-                        resolve();
-                    }
-                }
-            );
-        });
-    }
-
-    INITIALIZE_STATION(payload) {
-        //stationId, cb, bypassValidate = false
-        return new Promise((resolve, reject) => {
-            // if (typeof cb !== 'function') cb = ()=>{};
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.runJob(
-                            "GET_STATION",
-                            {
-                                stationId: payload.stationId,
-                                bypassQueue: payload.bypassQueue,
-                            },
-                            { bypassQueue: payload.bypassQueue }
-                        )
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                    (station, next) => {
-                        if (!station) return next("Station not found.");
-                        this.notifications
-                            .runJob("UNSCHEDULE", {
-                                subscription: `stations.nextSong?id=${station._id}`,
-                            })
-                            .then()
-                            .catch();
-                        this.notifications
-                            .runJob("SUBSCRIBE", {
-                                subscription: `stations.nextSong?id=${station._id}`,
-                                cb: () =>
-                                    this.runJob("SKIP_STATION", {
-                                        stationId: station._id,
-                                    }),
-                                unique: true,
-                                station,
-                            })
-                            .then()
-                            .catch();
-                        if (station.paused) return next(true, station);
-                        next(null, station);
-                    },
-                    (station, next) => {
-                        if (!station.currentSong) {
-                            return this.runJob(
-                                "SKIP_STATION",
-                                {
-                                    stationId: station._id,
-                                    bypassQueue: payload.bypassQueue,
-                                },
-                                { bypassQueue: payload.bypassQueue }
-                            )
-                                .then((station) => next(true, station))
-                                .catch(next)
-                                .finally(() => {});
-                        }
-                        let timeLeft =
-                            station.currentSong.duration * 1000 -
-                            (Date.now() -
-                                station.startedAt -
-                                station.timePaused);
-                        if (isNaN(timeLeft)) timeLeft = -1;
-                        if (
-                            station.currentSong.duration * 1000 < timeLeft ||
-                            timeLeft < 0
-                        ) {
-                            this.runJob(
-                                "SKIP_STATION",
-                                {
-                                    stationId: station._id,
-                                    bypassQueue: payload.bypassQueue,
-                                },
-                                { bypassQueue: payload.bypassQueue }
-                            )
-                                .then((station) => next(null, station))
-                                .catch(next);
-                        } else {
-                            //name, time, cb, station
-                            this.notifications.runJob("SCHEDULE", {
-                                name: `stations.nextSong?id=${station._id}`,
-                                time: timeLeft,
-                                station,
-                            });
-                            next(null, station);
-                        }
-                    },
-                ],
-                async (err, station) => {
-                    if (err && err !== true) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else resolve(station);
-                }
-            );
-        });
-    }
-
-    CALCULATE_SONG_FOR_STATION(payload) {
-        //station, cb, bypassValidate = false
-        return new Promise(async (resolve, reject) => {
-            const songModel = await this.db.runJob("GET_MODEL", {
-                modelName: "song",
-            });
-            const stationModel = await this.db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-
-            let songList = [];
-            async.waterfall(
-                [
-                    (next) => {
-                        if (payload.station.genres.length === 0) return next();
-                        let genresDone = [];
-                        payload.station.genres.forEach((genre) => {
-                            songModel.find({ genres: genre }, (err, songs) => {
-                                if (!err) {
-                                    songs.forEach((song) => {
-                                        if (songList.indexOf(song._id) === -1) {
-                                            let found = false;
-                                            song.genres.forEach((songGenre) => {
-                                                if (
-                                                    payload.station.blacklistedGenres.indexOf(
-                                                        songGenre
-                                                    ) !== -1
-                                                )
-                                                    found = true;
-                                            });
-                                            if (!found) {
-                                                songList.push(song._id);
-                                            }
-                                        }
-                                    });
-                                }
-                                genresDone.push(genre);
-                                if (
-                                    genresDone.length ===
-                                    payload.station.genres.length
-                                )
-                                    next();
-                            });
-                        });
-                    },
-
-                    (next) => {
-                        let playlist = [];
-                        songList.forEach(function(songId) {
-                            if (payload.station.playlist.indexOf(songId) === -1)
-                                playlist.push(songId);
-                        });
-                        payload.station.playlist.filter((songId) => {
-                            if (songList.indexOf(songId) !== -1)
-                                playlist.push(songId);
-                        });
-
-                        this.utils
-                            .runJob("SHUFFLE", { array: playlist })
-                            .then((result) => next(null, result.array))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        this.runJob(
-                            "CALCULATE_OFFICIAL_PLAYLIST_LIST",
-                            {
-                                stationId: payload.stationId,
-                                songList: playlist,
-                                bypassQueue: payload.bypassQueue,
-                            },
-                            { bypassQueue: payload.bypassQueue }
-                        )
-                            .then(() => next(null, playlist))
-                            .catch(next);
-                    },
-
-                    (playlist, next) => {
-                        stationModel.updateOne(
-                            { _id: payload.station._id },
-                            { $set: { playlist: playlist } },
-                            { runValidators: true },
-                            (err) => {
-                                this.runJob(
-                                    "UPDATE_STATION",
-                                    {
-                                        stationId: payload.station._id,
-                                        bypassQueue: payload.bypassQueue,
-                                    },
-                                    { bypassQueue: payload.bypassQueue }
-                                )
-                                    .then(() => next(null, playlist))
-                                    .catch(next);
-                            }
-                        );
-                    },
-                ],
-                (err, newPlaylist) => {
-                    if (err) return reject(new Error(err));
-                    resolve(newPlaylist);
-                }
-            );
-        });
-    }
-
-    // Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-    GET_STATION(payload) {
-        //stationId, cb, bypassValidate = false
-        return new Promise((resolve, reject) => {
-            async.waterfall(
-                [
-                    (next) => {
-                        this.cache
-                            .runJob("HGET", {
-                                table: "stations",
-                                key: payload.stationId,
-                            })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-
-                    (station, next) => {
-                        if (station) return next(true, station);
-                        this.stationModel.findOne(
-                            { _id: payload.stationId },
-                            next
-                        );
-                    },
-
-                    (station, next) => {
-                        if (station) {
-                            if (station.type === "official") {
-                                this.runJob(
-                                    "CALCULATE_OFFICIAL_PLAYLIST_LIST",
-                                    {
-                                        stationId: station._id,
-                                        songList: station.playlist,
-                                    }
-                                )
-                                    .then()
-                                    .catch();
-                            }
-                            station = this.stationSchema(station);
-                            this.cache
-                                .runJob("HSET", {
-                                    table: "stations",
-                                    key: payload.stationId,
-                                    value: station,
-                                })
-                                .then()
-                                .catch();
-                            next(true, station);
-                        } else next("Station not found");
-                    },
-                ],
-                async (err, station) => {
-                    if (err && err !== true) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else resolve(station);
-                }
-            );
-        });
-    }
-
-    // Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-    GET_STATION_BY_NAME(payload) {
-        //stationName, cb
-        return new Promise(async (resolve, reject) => {
-            const stationModel = await this.db.runJob("GET_MODEL", {
-                modelName: "station",
-            });
-            async.waterfall(
-                [
-                    (next) => {
-                        stationModel.findOne(
-                            { name: payload.stationName },
-                            next
-                        );
-                    },
-
-                    (station, next) => {
-                        if (station) {
-                            if (station.type === "official") {
-                                this.runJob(
-                                    "CALCULATE_OFFICIAL_PLAYLIST_LIST",
-                                    {
-                                        stationId: station._id,
-                                        songList: station.playlist,
-                                    }
-                                );
-                            }
-                            this.cache
-                                .runJob("GET_SCHEMA", { schemaName: "station" })
-                                .then((stationSchema) => {
-                                    station = stationSchema(station);
-                                    this.cache.runJob("HSET", {
-                                        table: "stations",
-                                        key: station._id,
-                                        value: station,
-                                    });
-                                    next(true, station);
-                                });
-                        } else next("Station not found");
-                    },
-                ],
-                (err, station) => {
-                    if (err && err !== true) return reject(new Error(err));
-                    resolve(station);
-                }
-            );
-        });
-    }
-
-    UPDATE_STATION(payload) {
-        //stationId, cb, bypassValidate = false
-        return new Promise((resolve, reject) => {
-            async.waterfall(
-                [
-                    (next) => {
-                        this.stationModel.findOne(
-                            { _id: payload.stationId },
-                            next
-                        );
-                    },
-
-                    (station, next) => {
-                        if (!station) {
-                            this.cache
-                                .runJob("HDEL", {
-                                    table: "stations",
-                                    key: payload.stationId,
-                                })
-                                .then()
-                                .catch();
-                            return next("Station not found");
-                        }
-                        this.cache
-                            .runJob("HSET", {
-                                table: "stations",
-                                key: payload.stationId,
-                                value: station,
-                            })
-                            .then((station) => next(null, station))
-                            .catch(next);
-                    },
-                ],
-                async (err, station) => {
-                    if (err && err !== true) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        reject(new Error(err));
-                    } else resolve(station);
-                }
-            );
-        });
-    }
-
-    CALCULATE_OFFICIAL_PLAYLIST_LIST(payload) {
-        //stationId, songList, cb, bypassValidate = false
-        return new Promise(async (resolve, reject) => {
-            const officialPlaylistSchema = await this.cache.runJob(
-                "GET_SCHEMA",
-                {
-                    schemaName: "officialPlaylist",
-                }
-            );
-
-            let lessInfoPlaylist = [];
-            async.each(
-                payload.songList,
-                (song, next) => {
-                    this.songs
-                        .runJob("GET_SONG", { id: song })
-                        .then((response) => {
-                            const song = response.song;
-                            if (song) {
-                                let newSong = {
-                                    songId: song.songId,
-                                    title: song.title,
-                                    artists: song.artists,
-                                    duration: song.duration,
-                                };
-                                lessInfoPlaylist.push(newSong);
-                            }
-                        })
-                        .finally(() => {
-                            next();
-                        });
-                },
-                () => {
-                    this.cache
-                        .runJob("HSET", {
-                            table: "officialPlaylists",
-                            key: payload.stationId,
-                            value: officialPlaylistSchema(
-                                payload.stationId,
-                                lessInfoPlaylist
-                            ),
-                        })
-                        .finally(() => {
-                            this.cache.runJob("PUB", {
-                                channel: "station.newOfficialPlaylist",
-                                value: payload.stationId,
-                            });
-                            resolve();
-                        });
-                }
-            );
-        });
-    }
-
-    SKIP_STATION(payload) {
-        //stationId
-        return new Promise((resolve, reject) => {
-            this.log("INFO", `Skipping station ${payload.stationId}.`);
-
-            this.log(
-                "STATION_ISSUE",
-                `SKIP_STATION_CB - Station ID: ${payload.stationId}.`
-            );
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.runJob(
-                            "GET_STATION",
-                            {
-                                stationId: payload.stationId,
-                                bypassQueue: payload.bypassQueue,
-                            },
-                            { bypassQueue: payload.bypassQueue }
-                        )
-                            .then((station) => {
-                                next(null, station);
-                            })
-                            .catch(() => {});
-                    },
-                    (station, next) => {
-                        if (!station) return next("Station not found.");
-
-                        if (
-                            station.type === "community" &&
-                            station.partyMode &&
-                            station.queue.length === 0
-                        )
-                            return next(null, null, -11, station); // Community station with party mode enabled and no songs in the queue
-
-                        if (
-                            station.type === "community" &&
-                            station.partyMode &&
-                            station.queue.length > 0
-                        ) {
-                            // Community station with party mode enabled and songs in the queue
-                            if (station.paused) {
-                                return next(null, null, -19, station);
-                            } else {
-                                return this.stationModel.updateOne(
-                                    { _id: payload.stationId },
-                                    {
-                                        $pull: {
-                                            queue: {
-                                                _id: station.queue[0]._id,
-                                            },
-                                        },
-                                    },
-                                    (err) => {
-                                        if (err) return next(err);
-                                        next(
-                                            null,
-                                            station.queue[0],
-                                            -12,
-                                            station
-                                        );
-                                    }
-                                );
-                            }
-                        }
-                        if (
-                            station.type === "community" &&
-                            !station.partyMode
-                        ) {
-                            this.db
-                                .runJob("GET_MODEL", { modelName: "playlist" })
-                                .then((playlistModel) => {
-                                    return playlistModel.findOne(
-                                        { _id: station.privatePlaylist },
-                                        (err, playlist) => {
-                                            if (err) return next(err);
-                                            if (!playlist)
-                                                return next(
-                                                    null,
-                                                    null,
-                                                    -13,
-                                                    station
-                                                );
-                                            playlist = playlist.songs;
-                                            if (playlist.length > 0) {
-                                                let currentSongIndex;
-                                                if (
-                                                    station.currentSongIndex <
-                                                    playlist.length - 1
-                                                )
-                                                    currentSongIndex =
-                                                        station.currentSongIndex +
-                                                        1;
-                                                else currentSongIndex = 0;
-                                                let callback = (err, song) => {
-                                                    if (err) return next(err);
-                                                    if (song)
-                                                        return next(
-                                                            null,
-                                                            song,
-                                                            currentSongIndex,
-                                                            station
-                                                        );
-                                                    else {
-                                                        let song =
-                                                            playlist[
-                                                                currentSongIndex
-                                                            ];
-                                                        let currentSong = {
-                                                            songId: song.songId,
-                                                            title: song.title,
-                                                            duration:
-                                                                song.duration,
-                                                            likes: -1,
-                                                            dislikes: -1,
-                                                        };
-                                                        return next(
-                                                            null,
-                                                            currentSong,
-                                                            currentSongIndex,
-                                                            station
-                                                        );
-                                                    }
-                                                };
-                                                if (
-                                                    playlist[currentSongIndex]
-                                                        ._id
-                                                )
-                                                    this.songs
-                                                        .runJob("GET_SONG", {
-                                                            id:
-                                                                playlist[
-                                                                    currentSongIndex
-                                                                ]._id,
-                                                        })
-                                                        .then((response) =>
-                                                            callback(
-                                                                null,
-                                                                response.song
-                                                            )
-                                                        )
-                                                        .catch(callback);
-                                                else
-                                                    this.songs
-                                                        .runJob(
-                                                            "GET_SONG_FROM_ID",
-                                                            {
-                                                                songId:
-                                                                    playlist[
-                                                                        currentSongIndex
-                                                                    ].songId,
-                                                            }
-                                                        )
-                                                        .then((response) =>
-                                                            callback(
-                                                                null,
-                                                                response.song
-                                                            )
-                                                        )
-                                                        .catch(callback);
-                                            } else
-                                                return next(
-                                                    null,
-                                                    null,
-                                                    -14,
-                                                    station
-                                                );
-                                        }
-                                    );
-                                });
-                        }
-                        if (
-                            station.type === "official" &&
-                            station.playlist.length === 0
-                        ) {
-                            return this.runJob(
-                                "CALCULATE_SONG_FOR_STATION",
-                                { station, bypassQueue: payload.bypassQueue },
-                                { bypassQueue: payload.bypassQueue }
-                            )
-                                .then((playlist) => {
-                                    if (playlist.length === 0)
-                                        return next(
-                                            null,
-                                            this.defaultSong,
-                                            0,
-                                            station
-                                        );
-                                    else {
-                                        this.songs
-                                            .runJob("GET_SONG", {
-                                                id: playlist[0],
-                                            })
-                                            .then((response) => {
-                                                next(
-                                                    null,
-                                                    response.song,
-                                                    0,
-                                                    station
-                                                );
-                                            })
-                                            .catch((err) => {
-                                                return next(
-                                                    null,
-                                                    this.defaultSong,
-                                                    0,
-                                                    station
-                                                );
-                                            });
-                                    }
-                                })
-                                .catch(next);
-                        }
-                        if (
-                            station.type === "official" &&
-                            station.playlist.length > 0
-                        ) {
-                            async.doUntil(
-                                (next) => {
-                                    if (
-                                        station.currentSongIndex <
-                                        station.playlist.length - 1
-                                    ) {
-                                        this.songs
-                                            .runJob("GET_SONG", {
-                                                id:
-                                                    station.playlist[
-                                                        station.currentSongIndex +
-                                                            1
-                                                    ],
-                                            })
-                                            .then((response) => {
-                                                return next(
-                                                    null,
-                                                    response.song,
-                                                    station.currentSongIndex + 1
-                                                );
-                                            })
-                                            .catch((err) => {
-                                                station.currentSongIndex++;
-                                                next(null, null, null);
-                                            });
-                                    } else {
-                                        this.runJob(
-                                            "CALCULATE_SONG_FOR_STATION",
-                                            {
-                                                station,
-                                                bypassQueue:
-                                                    payload.bypassQueue,
-                                            },
-                                            { bypassQueue: payload.bypassQueue }
-                                        )
-                                            .then((newPlaylist) => {
-                                                this.songs.getSong(
-                                                    newPlaylist[0],
-                                                    (err, song) => {
-                                                        if (err || !song)
-                                                            return next(
-                                                                null,
-                                                                this
-                                                                    .defaultSong,
-                                                                0
-                                                            );
-                                                        station.playlist = newPlaylist;
-                                                        next(null, song, 0);
-                                                    }
-                                                );
-                                            })
-                                            .catch((err) => {
-                                                next(null, this.defaultSong, 0);
-                                            });
-                                    }
-                                },
-                                (song, currentSongIndex, next) => {
-                                    if (!!song)
-                                        return next(
-                                            null,
-                                            true,
-                                            currentSongIndex
-                                        );
-                                    else return next(null, false);
-                                },
-                                (err, song, currentSongIndex) => {
-                                    return next(
-                                        err,
-                                        song,
-                                        currentSongIndex,
-                                        station
-                                    );
-                                }
-                            );
-                        }
-                    },
-                    (song, currentSongIndex, station, next) => {
-                        let $set = {};
-                        if (song === null) $set.currentSong = null;
-                        else if (song.likes === -1 && song.dislikes === -1) {
-                            $set.currentSong = {
-                                songId: song.songId,
-                                title: song.title,
-                                duration: song.duration,
-                                skipDuration: 0,
-                                likes: -1,
-                                dislikes: -1,
-                            };
-                        } else {
-                            $set.currentSong = {
-                                songId: song.songId,
-                                title: song.title,
-                                artists: song.artists,
-                                duration: song.duration,
-                                likes: song.likes,
-                                dislikes: song.dislikes,
-                                skipDuration: song.skipDuration,
-                                thumbnail: song.thumbnail,
-                            };
-                        }
-                        if (currentSongIndex >= 0)
-                            $set.currentSongIndex = currentSongIndex;
-                        $set.startedAt = Date.now();
-                        $set.timePaused = 0;
-                        if (station.paused) $set.pausedAt = Date.now();
-                        next(null, $set, station);
-                    },
-
-                    ($set, station, next) => {
-                        this.stationModel.updateOne(
-                            { _id: station._id },
-                            { $set },
-                            (err) => {
-                                this.runJob(
-                                    "UPDATE_STATION",
-                                    {
-                                        stationId: station._id,
-                                        bypassQueue: payload.bypassQueue,
-                                    },
-
-                                    { bypassQueue: payload.bypassQueue }
-                                )
-                                    .then((station) => {
-                                        if (
-                                            station.type === "community" &&
-                                            station.partyMode === true
-                                        )
-                                            this.cache
-                                                .runJob("PUB", {
-                                                    channel:
-                                                        "station.queueUpdate",
-                                                    value: payload.stationId,
-                                                })
-                                                .then()
-                                                .catch();
-                                        next(null, station);
-                                    })
-                                    .catch(next);
-                            }
-                        );
-                    },
-                ],
-                async (err, station) => {
-                    if (err) {
-                        err = await this.utils.runJob("GET_ERROR", {
-                            error: err,
-                        });
-                        this.log(
-                            "ERROR",
-                            `Skipping station "${payload.stationId}" failed. "${err}"`
-                        );
-                        reject(new Error(err));
-                    } else {
-                        if (
-                            station.currentSong !== null &&
-                            station.currentSong.songId !== undefined
-                        ) {
-                            station.currentSong.skipVotes = 0;
-                        }
-                        //TODO Pub/Sub this
-
-                        this.utils
-                            .runJob("EMIT_TO_ROOM", {
-                                room: `station.${station._id}`,
-                                args: [
-                                    "event:songs.next",
-                                    {
-                                        currentSong: station.currentSong,
-                                        startedAt: station.startedAt,
-                                        paused: station.paused,
-                                        timePaused: 0,
-                                    },
-                                ],
-                            })
-                            .then()
-                            .catch();
-
-                        if (station.privacy === "public") {
-                            this.utils
-                                .runJob("EMIT_TO_ROOM", {
-                                    room: "home",
-                                    args: [
-                                        "event:station.nextSong",
-                                        station._id,
-                                        station.currentSong,
-                                    ],
-                                })
-                                .then()
-                                .catch();
-                        } else {
-                            let sockets = await this.utils.runJob(
-                                "GET_ROOM_SOCKETS",
-                                { room: "home" }
-                            );
-                            for (let socketId in sockets) {
-                                let socket = sockets[socketId];
-                                let session = sockets[socketId].session;
-                                if (session.sessionId) {
-                                    this.cache
-                                        .runJob("HGET", {
-                                            table: "sessions",
-                                            key: session.sessionId,
-                                        })
-                                        .then((session) => {
-                                            if (session) {
-                                                this.db
-                                                    .runJob("GET_MODEL", {
-                                                        modelName: "user",
-                                                    })
-                                                    .then((userModel) => {
-                                                        userModel.findOne(
-                                                            {
-                                                                _id:
-                                                                    session.userId,
-                                                            },
-                                                            (err, user) => {
-                                                                if (
-                                                                    !err &&
-                                                                    user
-                                                                ) {
-                                                                    if (
-                                                                        user.role ===
-                                                                        "admin"
-                                                                    )
-                                                                        socket.emit(
-                                                                            "event:station.nextSong",
-                                                                            station._id,
-                                                                            station.currentSong
-                                                                        );
-                                                                    else if (
-                                                                        station.type ===
-                                                                            "community" &&
-                                                                        station.owner ===
-                                                                            session.userId
-                                                                    )
-                                                                        socket.emit(
-                                                                            "event:station.nextSong",
-                                                                            station._id,
-                                                                            station.currentSong
-                                                                        );
-                                                                }
-                                                            }
-                                                        );
-                                                    });
-                                            }
-                                        });
-                                }
-                            }
-                        }
-
-                        if (
-                            station.currentSong !== null &&
-                            station.currentSong.songId !== undefined
-                        ) {
-                            this.utils.runJob("SOCKETS_JOIN_SONG_ROOM", {
-                                sockets: await this.utils.runJob(
-                                    "GET_ROOM_SOCKETS",
-                                    { room: `station.${station._id}` }
-                                ),
-                                room: `song.${station.currentSong.songId}`,
-                            });
-                            if (!station.paused) {
-                                this.notifications.runJob("SCHEDULE", {
-                                    name: `stations.nextSong?id=${station._id}`,
-                                    time: station.currentSong.duration * 1000,
-                                    station,
-                                });
-                            }
-                        } else {
-                            this.utils
-                                .runJob("SOCKETS_LEAVE_SONG_ROOMS", {
-                                    sockets: await this.utils.runJob(
-                                        "GET_ROOM_SOCKETS",
-                                        { room: `station.${station._id}` }
-                                    ),
-                                })
-                                .then()
-                                .catch();
-                        }
-
-                        resolve({ station: station });
-                    }
-                }
-            );
-        });
-    }
-
-    CAN_USER_VIEW_STATION(payload) {
-        // station, userId, cb
-        return new Promise((resolve, reject) => {
-            async.waterfall(
-                [
-                    (next) => {
-                        if (payload.station.privacy !== "private")
-                            return next(true);
-                        if (!payload.userId) return next("Not allowed");
-                        next();
-                    },
-
-                    (next) => {
-                        this.db
-                            .runJob("GET_MODEL", {
-                                modelName: "user",
-                            })
-                            .then((userModel) => {
-                                userModel.findOne(
-                                    { _id: payload.userId },
-                                    next
-                                );
-                            });
-                    },
-
-                    (user, next) => {
-                        if (!user) return next("Not allowed");
-                        if (user.role === "admin") return next(true);
-                        if (payload.station.type === "official")
-                            return next("Not allowed");
-                        if (payload.station.owner === payload.userId)
-                            return next(true);
-                        next("Not allowed");
-                    },
-                ],
-                async (errOrResult) => {
-                    if (errOrResult !== true && errOrResult !== "Not allowed") {
-                        errOrResult = await this.utils.runJob("GET_ERROR", {
-                            error: errOrResult,
-                        });
-                        reject(new Error(errOrResult));
-                    } else {
-                        resolve(errOrResult === true ? true : false);
-                    }
-                }
-            );
-        });
-    }
+import async from "async";
+
+import CoreClass from "../core";
+
+let StationsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+let WSModule;
+let SongsModule;
+let PlaylistsModule;
+let NotificationsModule;
+
+class _StationsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("stations");
+
+		StationsModule = this;
+	}
+
+	/**
+	 * Initialises the stations module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		WSModule = this.moduleManager.modules.ws;
+		SongsModule = this.moduleManager.modules.songs;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+		NotificationsModule = this.moduleManager.modules.notifications;
+
+		this.userList = {};
+		this.usersPerStation = {};
+		this.usersPerStationCount = {};
+
+		// TEMP
+		CacheModule.runJob("SUB", {
+			channel: "station.pause",
+			cb: async stationId => {
+				NotificationsModule.runJob("REMOVE", {
+					subscription: `stations.nextSong?id=${stationId}`
+				}).then();
+			}
+		});
+
+		CacheModule.runJob("SUB", {
+			channel: "station.resume",
+			cb: async stationId => {
+				StationsModule.runJob("INITIALIZE_STATION", { stationId }).then();
+			}
+		});
+
+		CacheModule.runJob("SUB", {
+			channel: "station.queueUpdate",
+			cb: async stationId => {
+				StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+					if (!station.currentSong && station.queue.length > 0) {
+						StationsModule.runJob("INITIALIZE_STATION", {
+							stationId
+						}).then();
+					}
+				});
+			}
+		});
+
+		CacheModule.runJob("SUB", {
+			channel: "station.newOfficialPlaylist",
+			cb: async stationId => {
+				CacheModule.runJob("HGET", {
+					table: "officialPlaylists",
+					key: stationId
+				}).then(playlistObj => {
+					if (playlistObj) {
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `station.${stationId}`,
+							args: ["event:newOfficialPlaylist", { data: { playlist: playlistObj.songs } }]
+						});
+					}
+				});
+			}
+		});
+
+		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
+		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "stations" })
+							.then(stations => {
+								next(null, stations);
+							})
+							.catch(next);
+					},
+
+					(stations, next) => {
+						this.setStage(3);
+
+						if (!stations) return next();
+
+						const stationIds = Object.keys(stations);
+
+						return async.each(
+							stationIds,
+							(stationId, next) => {
+								stationModel.findOne({ _id: stationId }, (err, station) => {
+									if (err) next(err);
+									else if (!station) {
+										CacheModule.runJob("HDEL", {
+											table: "stations",
+											key: stationId
+										})
+											.then(() => {
+												next();
+											})
+											.catch(next);
+									} else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						stationModel.find({}, next);
+					},
+
+					(stations, next) => {
+						this.setStage(5);
+						async.each(
+							stations,
+							(station, next2) => {
+								async.waterfall(
+									[
+										next => {
+											CacheModule.runJob("HSET", {
+												table: "stations",
+												key: station._id,
+												value: stationSchema(station)
+											})
+												.then(station => next(null, station))
+												.catch(next);
+										},
+
+										(station, next) => {
+											StationsModule.runJob(
+												"INITIALIZE_STATION",
+												{
+													stationId: station._id
+												},
+												null,
+												-1
+											)
+												.then(() => {
+													next();
+												})
+												.catch(next);
+										}
+									],
+									err => {
+										next2(err);
+									}
+								);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", {
+							error: err
+						});
+						reject(new Error(err));
+					} else {
+						resolve();
+					}
+				}
+			)
+		);
+	}
+
+	/**
+	 * Initialises a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - id of the station to initialise
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	INITIALIZE_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob(
+							"GET_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+					(station, next) => {
+						if (!station) return next("Station not found.");
+
+						return NotificationsModule.runJob(
+							"UNSCHEDULE",
+							{
+								name: `stations.nextSong?id=${station._id}`
+							},
+							this
+						)
+							.then()
+							.catch()
+							.finally(() => {
+								NotificationsModule.runJob("SUBSCRIBE", {
+									name: `stations.nextSong?id=${station._id}`,
+									cb: () =>
+										StationsModule.runJob("SKIP_STATION", {
+											stationId: station._id,
+											natural: true
+										}),
+									unique: true,
+									station
+								})
+									.then()
+									.catch();
+
+								if (station.paused) return next(true, station);
+
+								return next(null, station);
+							});
+					},
+					(station, next) => {
+						if (!station.currentSong) {
+							return StationsModule.runJob(
+								"SKIP_STATION",
+								{
+									stationId: station._id,
+									natural: false
+								},
+								this
+							)
+								.then(station => {
+									next(true, station);
+								})
+								.catch(next)
+								.finally(() => {});
+						}
+
+						let timeLeft =
+							station.currentSong.duration * 1000 - (Date.now() - station.startedAt - station.timePaused);
+
+						if (Number.isNaN(timeLeft)) timeLeft = -1;
+
+						if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
+							return StationsModule.runJob(
+								"SKIP_STATION",
+								{
+									stationId: station._id,
+									natural: false
+								},
+								this
+							)
+								.then(station => {
+									next(null, station);
+								})
+								.catch(next);
+						}
+						// name, time, cb, station
+						NotificationsModule.runJob("SCHEDULE", {
+							name: `stations.nextSong?id=${station._id}`,
+							time: timeLeft,
+							station
+						});
+
+						return next(null, station);
+					}
+				],
+				async (err, station) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						reject(new Error(err));
+					} else resolve(station);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGET", { table: "stations", key: payload.stationId }, this)
+							.then(station => next(null, station))
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station) return next(true, station);
+						return StationsModule.stationModel.findOne({ _id: payload.stationId }, next);
+					},
+
+					(station, next) => {
+						if (station) {
+							station = StationsModule.stationSchema(station);
+							CacheModule.runJob("HSET", {
+								table: "stations",
+								key: payload.stationId,
+								value: station
+							})
+								.then()
+								.catch();
+							next(true, station);
+						} else next("Station not found");
+					}
+				],
+				async (err, station) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						reject(new Error(err));
+					} else resolve(station);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Attempts to get a station by name, firstly from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationName - the unique name of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_STATION_BY_NAME(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.findOne({ name: payload.stationName }, next);
+					},
+
+					(station, next) => {
+						if (station) {
+							station = StationsModule.stationSchema(station);
+							CacheModule.runJob("HSET", {
+								table: "stations",
+								key: station._id,
+								value: station
+							});
+							next(true, station);
+						} else next("Station not found");
+					}
+				],
+				(err, station) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(station);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Updates the station in cache from mongo or deletes station in cache if no longer in mongo.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station to update
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.findOne({ _id: payload.stationId }, next);
+					},
+
+					(station, next) => {
+						if (!station) {
+							CacheModule.runJob("HDEL", {
+								table: "stations",
+								key: payload.stationId
+							})
+								.then()
+								.catch();
+							return next("Station not found");
+						}
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "stations",
+								key: payload.stationId,
+								value: station
+							},
+							this
+						)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					}
+				],
+				async (err, station) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						reject(new Error(err));
+					} else resolve(station);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Fills up the official station playlist queue using the songs from the official station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @param {string} payload.ignoreExistingQueue - ignore the existing queue songs, replacing the old queue with a completely fresh one
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, ignoreExistingQueue } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_STATION_PLAYLIST", { stationId, includeSongs: true }, this)
+							.then(response => {
+								next(null, response.playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								if (ignoreExistingQueue) station.queue = [];
+								next(null, playlist, station);
+							})
+							.catch(next);
+					},
+
+					(playlist, station, next) => {
+						if (station.playMode === "random") {
+							UtilsModule.runJob("SHUFFLE", { array: playlist.songs }, this)
+								.then(response => {
+									next(null, response.array, station);
+								})
+								.catch(next);
+						} else next(null, playlist.songs, station);
+					},
+
+					(_playlistSongs, station, next) => {
+						let playlistSongs = JSON.parse(JSON.stringify(_playlistSongs));
+						if (station.playMode === "sequential") {
+							if (station.currentSongIndex <= playlistSongs.length) {
+								const songsToAddToEnd = playlistSongs.splice(0, station.currentSongIndex);
+								playlistSongs = [...playlistSongs, ...songsToAddToEnd];
+							}
+						}
+						const songsStillNeeded = 50 - station.queue.length;
+						const currentSongs = station.queue;
+						const currentYoutubeIds = station.queue.map(song => song.youtubeId);
+						const songsToAdd = [];
+						let lastSongAdded = null;
+
+						playlistSongs.every(song => {
+							if (
+								songsToAdd.length < songsStillNeeded &&
+								currentYoutubeIds.indexOf(song.youtubeId) === -1
+							) {
+								lastSongAdded = song;
+								songsToAdd.push(song);
+								return true;
+							}
+							if (songsToAdd.length >= songsStillNeeded) return false;
+							return true;
+						});
+
+						let { currentSongIndex } = station;
+
+						if (station.playMode === "sequential" && lastSongAdded) {
+							const indexOfLastSong = _playlistSongs
+								.map(song => song.youtubeId)
+								.indexOf(lastSongAdded.youtubeId);
+
+							if (indexOfLastSong !== -1) currentSongIndex = indexOfLastSong;
+						}
+
+						next(null, currentSongs, songsToAdd, currentSongIndex);
+					},
+
+					(currentSongs, songsToAdd, currentSongIndex, next) => {
+						SongsModule.runJob("GET_SONGS", {
+							songIds: songsToAdd.map(song => song._id),
+							properties: [
+								"youtubeId",
+								"title",
+								"duration",
+								"skipDuration",
+								"artists",
+								"thumbnail",
+								"status"
+							]
+						})
+							.then(response => {
+								const newSongsToAdd = songsToAdd.map(song =>
+									response.songs.find(newSong => newSong._id.toString() === song._id.toString())
+								);
+								next(null, currentSongs, newSongsToAdd, currentSongIndex);
+							})
+							.catch(err => next(err));
+					},
+
+					(currentSongs, songsToAdd, currentSongIndex, next) => {
+						const newPlaylist = [...currentSongs, ...songsToAdd].map(song => {
+							if (!song._id) song._id = null;
+							return song;
+						});
+						next(null, newPlaylist, currentSongIndex);
+					},
+
+					(newPlaylist, currentSongIndex, next) => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $set: { queue: newPlaylist, currentSongIndex } },
+							{ runValidators: true },
+							err => {
+								if (err) next(err);
+								else
+									StationsModule.runJob(
+										"UPDATE_STATION",
+										{
+											stationId
+										},
+										this
+									)
+										.then(() => {
+											next(null);
+										})
+										.catch(next);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets next station song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_NEXT_STATION_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.queue.length === 0) next("No songs available.");
+						else {
+							next(null, station.queue[0]);
+						}
+					},
+
+					(queueSong, next) => {
+						if (!queueSong._id) next(null, queueSong);
+						else
+							SongsModule.runJob("GET_SONG", { songId: queueSong._id }, this)
+								.then(response => {
+									const { song } = response;
+
+									if (song) {
+										const newSong = {
+											_id: song._id,
+											youtubeId: song.youtubeId,
+											title: song.title,
+											artists: song.artists,
+											duration: song.duration,
+											skipDuration: song.skipDuration,
+											thumbnail: song.thumbnail,
+											requestedAt: queueSong.requestedAt,
+											requestedBy: queueSong.requestedBy,
+											likes: song.likes,
+											dislikes: song.dislikes,
+											status: song.status
+										};
+
+										return next(null, newSong);
+									}
+
+									return next(null, song);
+								})
+								.catch(err => {
+									next(err);
+								});
+					}
+				],
+				(err, song) => {
+					if (err) console.log(33333, err, payload);
+					if (err) reject(err);
+					else resolve({ song });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes first station queue song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FIRST_QUEUE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $pop: { queue: -1 } },
+							{ runValidators: true },
+							err => {
+								if (err) next(err);
+								else
+									StationsModule.runJob(
+										"UPDATE_STATION",
+										{
+											stationId
+										},
+										this
+									)
+										.then(() => {
+											next(null);
+										})
+										.catch(next);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Skips a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station to skip
+	 * @param {string} payload.natural - whether to skip naturally or forcefully
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	SKIP_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			StationsModule.log("INFO", `Skipping station ${payload.stationId}.`);
+
+			StationsModule.log("STATION_ISSUE", `SKIP_STATION_CB - Station ID: ${payload.stationId}.`);
+
+			async.waterfall(
+				[
+					// Clears up any existing timers that would skip the station if the song ends
+					next => {
+						NotificationsModule.runJob("UNSCHEDULE", {
+							name: `stations.nextSong?id=${payload.stationId}`
+						})
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					},
+
+					// Gets the station object
+					next => {
+						StationsModule.runJob(
+							"GET_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(station => next(null, station))
+							.catch(next);
+					},
+
+					// eslint-disable-next-line consistent-return
+					(station, next) => {
+						if (!station) return next("Station not found.");
+
+						if (station.type === "community" && station.partyMode && station.queue.length === 0)
+							return next(null, null, station); // Community station with party mode enabled and no songs in the queue
+
+						if (station.type === "community" && station.partyMode && station.queue.length > 0) {
+							// Community station with party mode enabled and songs in the queue
+							if (station.paused) return next(null, null, station);
+
+							StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+								.then(response => {
+									StationsModule.runJob(
+										"REMOVE_FIRST_QUEUE_SONG",
+										{ stationId: station._id },
+										this
+									).then(() => {
+										next(null, response.song, station);
+									});
+								})
+								.catch(err => {
+									if (err === "No songs available.") next(null, null, station);
+									else next(err);
+								});
+						}
+
+						if (station.type === "community" && !station.partyMode) {
+							StationsModule.runJob(
+								"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+								{ stationId: station._id },
+								this
+							)
+								.then(() => {
+									StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+										.then(response => {
+											StationsModule.runJob(
+												"REMOVE_FIRST_QUEUE_SONG",
+												{ stationId: station._id },
+												this
+											).then(() => {
+												next(null, response.song, station);
+											});
+										})
+										.catch(err => {
+											if (err === "No songs available.") next(null, null, station);
+											else next(err);
+										});
+								})
+								.catch(next);
+						}
+
+						if (station.type === "official") {
+							StationsModule.runJob(
+								"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+								{ stationId: station._id },
+								this
+							)
+								.then(() => {
+									StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+										.then(response => {
+											StationsModule.runJob(
+												"REMOVE_FIRST_QUEUE_SONG",
+												{ stationId: station._id },
+												this
+											)
+												.then(() => {
+													next(null, response.song, station);
+												})
+												.catch(next);
+										})
+										.catch(err => {
+											if (err === "No songs available.") next(null, null, station);
+											else next(err);
+										});
+								})
+								.catch(next);
+						}
+					},
+					(song, station, next) => {
+						const $set = {};
+
+						if (song === null) $set.currentSong = null;
+						else {
+							$set.currentSong = {
+								_id: song._id,
+								youtubeId: song.youtubeId,
+								title: song.title,
+								artists: song.artists,
+								duration: song.duration,
+								skipDuration: song.skipDuration,
+								thumbnail: song.thumbnail,
+								requestedAt: song.requestedAt,
+								requestedBy: song.requestedBy,
+								status: song.status
+							};
+						}
+
+						$set.startedAt = Date.now();
+						$set.timePaused = 0;
+						if (station.paused) $set.pausedAt = Date.now();
+						next(null, $set, song, station);
+					},
+
+					($set, song, station, next) => {
+						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
+							if (err) return next(err);
+
+							return StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
+								.then(station => {
+									CacheModule.runJob("PUB", {
+										channel: "station.queueUpdate",
+										value: payload.stationId
+									})
+										.then()
+										.catch();
+									next(null, station, song);
+								})
+								.catch(next);
+						});
+					},
+
+					(station, song, next) => {
+						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+							station.currentSong.likes = song.likes;
+							station.currentSong.dislikes = song.dislikes;
+							station.currentSong.skipVotes = 0;
+						}
+						next(null, station);
+					}
+				],
+				async (err, station) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						StationsModule.log("ERROR", `Skipping station "${payload.stationId}" failed. "${err}"`);
+						return reject(new Error(err));
+					}
+
+					// TODO Pub/Sub this
+
+					const { currentSong } = station;
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `station.${station._id}`,
+						args: [
+							"event:station.nextSong",
+							{
+								data: {
+									currentSong,
+									startedAt: station.startedAt,
+									paused: station.paused,
+									timePaused: 0,
+									natural: payload.natural
+								}
+							}
+						]
+					});
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `manage-station.${station._id}`,
+						args: ["event:station.nextSong", { data: { stationId: station._id, currentSong } }]
+					});
+
+					if (station.privacy === "public")
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: "home",
+							args: ["event:station.nextSong", { data: { stationId: station._id, currentSong } }]
+						});
+					else {
+						const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: "home" }, this);
+
+						sockets.forEach(async socketId => {
+							const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId });
+							if (!socket) return;
+							const { session } = socket;
+
+							if (session.sessionId) {
+								CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }).then(
+									session => {
+										if (session) {
+											DBModule.runJob("GET_MODEL", { modelName: "user" }).then(userModel => {
+												userModel.findOne({ _id: session.userId }, (err, user) => {
+													if (!err && user) {
+														if (user.role === "admin")
+															socket.dispatch("event:station.nextSong", {
+																data: {
+																	stationId: station._id,
+																	currentSong
+																}
+															});
+														else if (
+															station.type === "community" &&
+															station.owner === session.userId
+														)
+															socket.dispatch("event:station.nextSong", {
+																data: {
+																	stationId: station._id,
+																	currentSong
+																}
+															});
+													}
+												});
+											});
+										}
+									}
+								);
+							}
+						});
+					}
+
+					WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${station._id}` }).then(sockets => {
+						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+							WSModule.runJob("SOCKETS_JOIN_SONG_ROOM", {
+								sockets,
+								room: `song.${station.currentSong.youtubeId}`
+							});
+							if (!station.paused) {
+								NotificationsModule.runJob("SCHEDULE", {
+									name: `stations.nextSong?id=${station._id}`,
+									time: station.currentSong.duration * 1000,
+									station
+								});
+							}
+						} else WSModule.runJob("SOCKETS_LEAVE_SONG_ROOMS", { sockets });
+					});
+
+					return resolve({ station });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Checks if a user can view/access a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.station - the station object of the station in question
+	 * @param {string} payload.userId - the id of the user in question
+	 * @param {boolean} payload.hideUnlisted - whether the user is allowed to see unlisted stations or not
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CAN_USER_VIEW_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (payload.station.privacy === "public") return next(true);
+						if (payload.station.privacy === "unlisted")
+							if (payload.hideUnlisted === true) return next();
+							else return next(true);
+						if (!payload.userId) return next("Not allowed");
+
+						return next();
+					},
+
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+							userModel.findOne({ _id: payload.userId }, next);
+						});
+					},
+
+					(user, next) => {
+						if (!user) return next("Not allowed");
+						if (user.role === "admin") return next(true);
+						if (payload.station.type === "official") return next("Not allowed");
+						if (payload.station.owner === payload.userId) return next(true);
+
+						return next("Not allowed");
+					}
+				],
+				async errOrResult => {
+					if (errOrResult !== true && errOrResult !== "Not allowed") {
+						errOrResult = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: errOrResult
+							},
+							this
+						);
+						reject(new Error(errOrResult));
+					} else {
+						resolve(errOrResult === true);
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Checks if a user has favorited a station or not
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station in question
+	 * @param {string} payload.userId - the id of the user in question
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	HAS_USER_FAVORITED_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+							userModel.findOne({ _id: payload.userId }, next);
+						});
+					},
+
+					(user, next) => {
+						if (!user) return next("User not found.");
+						if (user.favoriteStations.indexOf(payload.stationId) !== -1) return next(null, true);
+						return next(null, false);
+					}
+				],
+				async (err, isStationFavorited) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve(isStationFavorited);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns a list of sockets in a room that can and can't know about a station
+	 *
+	 * @param {object} payload - the payload object
+	 * @param {object} payload.station - the station object
+	 * @param {string} payload.room - the websockets room to get the sockets from
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: payload.room }, this)
+				.then(socketIds => {
+					const sockets = [];
+					async.eachLimit(
+						socketIds,
+						1,
+						(socketId, next) => {
+							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
+								.then(socket => {
+									if (socket) sockets.push(socket);
+									next();
+								})
+								.catch(err => {
+									reject(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else {
+								let socketsThatCan = [];
+								const socketsThatCannot = [];
+
+								if (payload.station.privacy === "public") {
+									socketsThatCan = sockets;
+									resolve({ socketsThatCan, socketsThatCannot });
+								} else {
+									async.eachLimit(
+										sockets,
+										1,
+										(socket, next) => {
+											const { session } = socket;
+
+											async.waterfall(
+												[
+													next => {
+														if (!session.sessionId) next("No session id");
+														else next();
+													},
+
+													next => {
+														CacheModule.runJob(
+															"HGET",
+															{
+																table: "sessions",
+																key: session.sessionId
+															},
+															this
+														)
+															.then(response => {
+																next(null, response);
+															})
+															.catch(next);
+													},
+
+													(session, next) => {
+														if (!session) next("No session");
+														else {
+															DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+																.then(userModel => {
+																	next(null, userModel);
+																})
+																.catch(next);
+														}
+													},
+
+													(userModel, next) => {
+														if (!userModel) next("No user model");
+														else
+															userModel.findOne(
+																{
+																	_id: session.userId
+																},
+																next
+															);
+													},
+
+													(user, next) => {
+														if (!user) next("No user found");
+														else if (user.role === "admin") {
+															socketsThatCan.push(socket);
+															next();
+														} else if (
+															payload.station.type === "community" &&
+															payload.station.owner === session.userId
+														) {
+															socketsThatCan.push(socket);
+															next();
+														}
+													}
+												],
+												err => {
+													if (err) socketsThatCannot.push(socket);
+													next();
+												}
+											);
+										},
+										err => {
+											if (err) reject(err);
+											else resolve({ socketsThatCan, socketsThatCannot });
+										}
+									);
+								}
+							}
+						}
+					);
+				})
+				.catch(reject);
+		});
+	}
+
+	/**
+	 * Adds a playlist to be included in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station to include the playlist in
+	 * @param {object} payload.playlistId - the id of the playlist to be included
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	INCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist === payload.playlistId) next("You cannot include the station playlist");
+						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already included");
+						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
+							next(
+								"This playlist is currently excluded, please remove it from there before including it"
+							);
+						else
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
+								.then(() => {
+									next(null);
+								})
+								.catch(next);
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $push: { includedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes a playlist that is included in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_INCLUDED_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.includedPlaylists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't included");
+						else next();
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $pull: { includedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Adds a playlist to be excluded in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	EXCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist === payload.playlistId) next("You cannot exclude the station playlist");
+						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already excluded");
+						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+							next(
+								"This playlist is currently included, please remove it from there before excluding it"
+							);
+						else
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
+								.then(() => {
+									next(null);
+								})
+								.catch(next);
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $push: { excludedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes a playlist that is excluded in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_EXCLUDED_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.excludedPlaylists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't excluded");
+						else next();
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $pull: { excludedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes included or excluded playlist from a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REMOVE_INCLUDED_OR_EXCLUDED_PLAYLIST_FROM_STATIONS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.stationModel.updateMany(
+							{
+								$or: [
+									{ includedPlaylists: payload.playlistId },
+									{ excludedPlaylists: payload.playlistId }
+								]
+							},
+							{
+								$pull: {
+									includedPlaylists: payload.playlistId,
+									excludedPlaylists: payload.playlistId
+								}
+							},
+							err => {
+								if (err) next(err);
+								else next();
+							}
+						);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets stations that include or exclude a specific playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob(
+				"GET_MODEL",
+				{
+					modelName: "station"
+				},
+				this
+			).then(stationModel => {
+				stationModel.find(
+					{
+						$or: [{ includedPlaylists: payload.playlistId }, { excludedPlaylists: payload.playlistId }]
+					},
+					(err, stations) => {
+						if (err) reject(err);
+						else resolve({ stationIds: stations.map(station => station._id) });
+					}
+				);
+			});
+		});
+	}
+
+	/**
+	 * Clears every queue
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CLEAR_EVERY_STATION_QUEUE() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.updateMany({}, { $set: { queue: [] } }, err => {
+							if (err) next(err);
+							else {
+								StationsModule.stationModel.find({}, (err, stations) => {
+									if (err) next(err);
+									else {
+										async.eachLimit(
+											stations,
+											1,
+											(station, next) => {
+												StationsModule.runJob("UPDATE_STATION", {
+													stationId: station._id
+												})
+													.then(() => next())
+													.catch(next);
+												CacheModule.runJob("PUB", {
+													channel: "station.queueUpdate",
+													value: station._id
+												})
+													.then()
+													.catch();
+											},
+											next
+										);
+									}
+								});
+							}
+						});
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Clears and refills a station queue
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CLEAR_AND_REFILL_STATION_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob(
+							"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+							{ stationId: payload.stationId, ignoreExistingQueue: true },
+							this
+						)
+							.then(() => {
+								CacheModule.runJob("PUB", {
+									channel: "station.queueUpdate",
+									value: payload.stationId
+								})
+									.then()
+									.catch();
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
 }
 }
 
 
-module.exports = new StationsModule();
+export default new _StationsModule();

+ 468 - 322
backend/logic/tasks.js

@@ -1,324 +1,470 @@
-const CoreClass = require("../core.js");
-
-const tasks = {};
-
-const async = require("async");
-const fs = require("fs");
-
-const Timer = require("../classes/Timer.class");
-
-class TasksModule extends CoreClass {
-    constructor() {
-        super("tasks");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            // return reject(new Error("Not fully migrated yet."));
-
-            this.cache = this.moduleManager.modules["cache"];
-            this.stations = this.moduleManager.modules["stations"];
-            this.notifications = this.moduleManager.modules["notifications"];
-            this.utils = this.moduleManager.modules["utils"];
-
-            //this.createTask("testTask", testTask, 5000, true);
-
-            this.runJob("CREATE_TASK", {
-                name: "stationSkipTask",
-                fn: this.checkStationSkipTask,
-                timeout: 1000 * 60 * 30,
-            });
-
-            this.runJob("CREATE_TASK", {
-                name: "sessionClearTask",
-                fn: this.sessionClearingTask,
-                timeout: 1000 * 60 * 60 * 6,
-            });
-
-            this.runJob("CREATE_TASK", {
-                name: "logFileSizeCheckTask",
-                fn: this.logFileSizeCheckTask,
-                timeout: 1000 * 60 * 60,
-            });
-
-            resolve();
-        });
-    }
-
-    CREATE_TASK(payload) {
-        return new Promise((resolve, reject) => {
-            tasks[payload.name] = {
-                name: payload.name,
-                fn: payload.fn,
-                timeout: payload.timeout,
-                lastRan: 0,
-                timer: null,
-            };
-
-            if (!payload.paused) {
-                this.runJob("RUN_TASK", { name: payload.name })
-                    .then(() => resolve())
-                    .catch((err) => reject(err));
-            } else resolve();
-        });
-    }
-
-    PAUSE_TASK(payload) {
-        return new Promise((resolve, reject) => {
-            if (tasks[payload.name].timer) tasks[name].timer.pause();
-            resolve();
-        });
-    }
-
-    RESUME_TASK(payload) {
-        return new Promise((resolve, reject) => {
-            tasks[payload.name].timer.resume();
-            resolve();
-        });
-    }
-
-    RUN_TASK(payload) {
-        return new Promise((resolve, reject) => {
-            const task = tasks[payload.name];
-            if (task.timer) task.timer.pause();
-
-            task.fn.apply(this).then(() => {
-                task.lastRan = Date.now();
-                task.timer = new Timer(
-                    () => {
-                        this.runJob("RUN_TASK", { name: payload.name });
-                    },
-                    task.timeout,
-                    false
-                );
-
-                resolve();
-            });
-        });
-    }
-
-    checkStationSkipTask(callback) {
-        return new Promise((resolve, reject) => {
-            this.log(
-                "INFO",
-                "TASK_STATIONS_SKIP_CHECK",
-                `Checking for stations to be skipped.`,
-                false
-            );
-            async.waterfall(
-                [
-                    (next) => {
-                        this.cache
-                            .runJob("HGETALL", {
-                                table: "stations",
-                            })
-                            .then((response) => next(null, response))
-                            .catch(next);
-                    },
-                    (stations, next) => {
-                        async.each(
-                            stations,
-                            (station, next2) => {
-                                if (
-                                    station.paused ||
-                                    !station.currentSong ||
-                                    !station.currentSong.title
-                                )
-                                    return next2();
-                                const timeElapsed =
-                                    Date.now() -
-                                    station.startedAt -
-                                    station.timePaused;
-                                if (timeElapsed <= station.currentSong.duration)
-                                    return next2();
-                                else {
-                                    this.log(
-                                        "ERROR",
-                                        "TASK_STATIONS_SKIP_CHECK",
-                                        `Skipping ${station._id} as it should have skipped already.`
-                                    );
-                                    this.stations
-                                        .runJob("INITIALIZE_STATION", {
-                                            stationId: station._id,
-                                        })
-                                        .then(() => {
-                                            next2();
-                                        });
-                                }
-                            },
-                            () => {
-                                next();
-                            }
-                        );
-                    },
-                ],
-                () => {
-                    resolve();
-                }
-            );
-        });
-    }
-
-    sessionClearingTask() {
-        return new Promise((resolve, reject) => {
-            this.log(
-                "INFO",
-                "TASK_SESSION_CLEAR",
-                `Checking for sessions to be cleared.`
-            );
-
-            async.waterfall(
-                [
-                    (next) => {
-                        this.cache
-                            .runJob("HGETALL", {
-                                table: "sessions",
-                            })
-                            .then((sessions) => {
-                                next(null, sessions);
-                            })
-                            .catch(next);
-                    },
-                    (sessions, next) => {
-                        if (!sessions) return next();
-                        let keys = Object.keys(sessions);
-                        async.each(
-                            keys,
-                            (sessionId, next2) => {
-                                let session = sessions[sessionId];
-                                if (
-                                    session &&
-                                    session.refreshDate &&
-                                    Date.now() - session.refreshDate <
-                                        60 * 60 * 24 * 30 * 1000
-                                )
-                                    return next2();
-                                if (!session) {
-                                    this.log(
-                                        "INFO",
-                                        "TASK_SESSION_CLEAR",
-                                        "Removing an empty session."
-                                    );
-                                    this.cache
-                                        .runJob("HDEL", {
-                                            table: "sessions",
-                                            key: sessionId,
-                                        })
-                                        .finally(() => next2());
-                                } else if (!session.refreshDate) {
-                                    session.refreshDate = Date.now();
-                                    this.cache
-                                        .runJob("HSET", {
-                                            table: "sessions",
-                                            key: sessionId,
-                                            value: session,
-                                        })
-                                        .finally(() => next2());
-                                } else if (
-                                    Date.now() - session.refreshDate >
-                                    60 * 60 * 24 * 30 * 1000
-                                ) {
-                                    this.utils
-                                        .runJob("SOCKETS_FROM_SESSION_ID", {
-                                            sessionId: session.sessionId,
-                                        })
-                                        .then((response) => {
-                                            if (response.sockets.length > 0) {
-                                                session.refreshDate = Date.now();
-                                                this.cache
-                                                    .runJob("HSET", {
-                                                        table: "sessions",
-                                                        key: sessionId,
-                                                        value: session,
-                                                    })
-                                                    .finally(() => next2());
-                                            } else {
-                                                this.log(
-                                                    "INFO",
-                                                    "TASK_SESSION_CLEAR",
-                                                    `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`
-                                                );
-                                                this.cache
-                                                    .runJob("HDEL", {
-                                                        table: "sessions",
-                                                        key: session.sessionId,
-                                                    })
-                                                    .finally(() => next2());
-                                            }
-                                        });
-                                } else {
-                                    this.log(
-                                        "ERROR",
-                                        "TASK_SESSION_CLEAR",
-                                        "This should never log."
-                                    );
-                                    next2();
-                                }
-                            },
-                            () => {
-                                next();
-                            }
-                        );
-                    },
-                ],
-                () => {
-                    resolve();
-                }
-            );
-        });
-    }
-
-    logFileSizeCheckTask() {
-        return new Promise((resolve, reject) => {
-            this.log(
-                "INFO",
-                "TASK_LOG_FILE_SIZE_CHECK",
-                `Checking the size for the log files.`
-            );
-            async.each(
-                [
-                    "all.log",
-                    "debugStation.log",
-                    "error.log",
-                    "info.log",
-                    "success.log",
-                ],
-                (fileName, next) => {
-                    const stats = fs.statSync(
-                        `${__dirname}/../../log/${fileName}`
-                    );
-                    const mb = stats.size / 1000000;
-                    if (mb > 25) return next(true);
-                    else next();
-                },
-                (err) => {
-                    if (err === true) {
-                        this.log(
-                            "ERROR",
-                            "LOGGER_FILE_SIZE_WARNING",
-                            "************************************WARNING*************************************"
-                        );
-                        this.log(
-                            "ERROR",
-                            "LOGGER_FILE_SIZE_WARNING",
-                            "***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************"
-                        );
-                        this.log(
-                            "ERROR",
-                            "LOGGER_FILE_SIZE_WARNING",
-                            "****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****"
-                        );
-                        this.log(
-                            "ERROR",
-                            "LOGGER_FILE_SIZE_WARNING",
-                            "********************************************************************************"
-                        );
-                    }
-                    resolve();
-                }
-            );
-        });
-    }
+import async from "async";
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+import CoreClass from "../core";
+import Timer from "../classes/Timer.class";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+let TasksModule;
+let CacheModule;
+let StationsModule;
+let UtilsModule;
+let WSModule;
+let DBModule;
+
+class _TasksModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("tasks");
+
+		this.tasks = {};
+
+		TasksModule = this;
+	}
+
+	/**
+	 * Initialises the tasks module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			CacheModule = this.moduleManager.modules.cache;
+			StationsModule = this.moduleManager.modules.stations;
+			UtilsModule = this.moduleManager.modules.utils;
+			WSModule = this.moduleManager.modules.ws;
+			DBModule = this.moduleManager.modules.db;
+
+			TasksModule.runJob("CREATE_TASK", {
+				name: "stationSkipTask",
+				fn: TasksModule.checkStationSkipTask,
+				timeout: 1000 * 60 * 30
+			});
+
+			TasksModule.runJob("CREATE_TASK", {
+				name: "sessionClearTask",
+				fn: TasksModule.sessionClearingTask,
+				timeout: 1000 * 60 * 60 * 6
+			});
+
+			// TasksModule.runJob("CREATE_TASK", {
+			// 	name: "logFileSizeCheckTask",
+			// 	fn: TasksModule.logFileSizeCheckTask,
+			// 	timeout: 1000 * 60 * 60
+			// });
+
+			TasksModule.runJob("CREATE_TASK", {
+				name: "collectStationUsersTask",
+				fn: TasksModule.collectStationUsersTask,
+				timeout: 1000 * 3
+			});
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Creates a new task
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.name - the name of the task
+	 * @param {string} payload.fn - the function the task will run
+	 * @param {string} payload.paused - if the task is currently paused
+	 * @param {boolean} payload.timeout - how often to run the task
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_TASK(payload) {
+		return new Promise((resolve, reject) => {
+			TasksModule.tasks[payload.name] = {
+				name: payload.name,
+				fn: payload.fn,
+				timeout: payload.timeout,
+				lastRan: 0,
+				timer: null
+			};
+
+			if (!payload.paused) {
+				TasksModule.runJob("RUN_TASK", { name: payload.name }, this)
+					.then(() => resolve())
+					.catch(err => reject(err));
+			} else resolve();
+		});
+	}
+
+	/**
+	 * Pauses a task
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.taskName - the name of the task to pause
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	PAUSE_TASK(payload) {
+		const taskName = { payload };
+
+		return new Promise(resolve => {
+			if (TasksModule.tasks[taskName].timer) TasksModule.tasks[taskName].timer.pause();
+			resolve();
+		});
+	}
+
+	/**
+	 * Resumes a task
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.name - the name of the task to resume
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RESUME_TASK(payload) {
+		return new Promise(resolve => {
+			TasksModule.tasks[payload.name].timer.resume();
+			resolve();
+		});
+	}
+
+	/**
+	 * Runs a task's function and restarts the timer
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.name - the name of the task to run
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RUN_TASK(payload) {
+		return new Promise(resolve => {
+			const task = TasksModule.tasks[payload.name];
+			if (task.timer) task.timer.pause();
+
+			task.fn.apply(this).then(() => {
+				task.lastRan = Date.now();
+				task.timer = new Timer(
+					() => TasksModule.runJob("RUN_TASK", { name: payload.name }),
+					task.timeout,
+					false
+				);
+				resolve();
+			});
+		});
+	}
+
+	/**
+	 * Periodically checks if any stations need to be skipped
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	checkStationSkipTask() {
+		return new Promise(resolve => {
+			TasksModule.log("INFO", "TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGETALL", { table: "stations" })
+							.then(response => next(null, response))
+							.catch(next);
+					},
+					(stations, next) => {
+						async.each(
+							stations,
+							(station, next2) => {
+								if (station.paused || !station.currentSong || !station.currentSong.title)
+									return next2();
+								const timeElapsed = Date.now() - station.startedAt - station.timePaused;
+								if (timeElapsed <= station.currentSong.duration) return next2();
+
+								TasksModule.log(
+									"ERROR",
+									"TASK_STATIONS_SKIP_CHECK",
+									`Skipping ${station._id} as it should have skipped already.`
+								);
+								return StationsModule.runJob("INITIALIZE_STATION", {
+									stationId: station._id
+								}).then(() => next2());
+							},
+							() => next()
+						);
+					}
+				],
+				() => resolve()
+			);
+		});
+	}
+
+	/**
+	 * Periodically checks if any sessions are out of date and need to be cleared
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	sessionClearingTask() {
+		return new Promise(resolve => {
+			TasksModule.log("INFO", "TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`);
+
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGETALL", { table: "sessions" })
+							.then(sessions => next(null, sessions))
+							.catch(next);
+					},
+					(sessions, next) => {
+						if (!sessions) return next();
+
+						const keys = Object.keys(sessions);
+
+						return async.each(
+							keys,
+							(sessionId, next2) => {
+								const session = sessions[sessionId];
+
+								if (
+									session &&
+									session.refreshDate &&
+									Date.now() - session.refreshDate < 60 * 60 * 24 * 30 * 1000
+								)
+									return next2();
+
+								if (!session) {
+									TasksModule.log("INFO", "TASK_SESSION_CLEAR", "Removing an empty session.");
+									return CacheModule.runJob("HDEL", {
+										table: "sessions",
+										key: sessionId
+									}).finally(() => {
+										next2();
+									});
+								}
+								if (!session.refreshDate) {
+									session.refreshDate = Date.now();
+									return CacheModule.runJob("HSET", {
+										table: "sessions",
+										key: sessionId,
+										value: session
+									}).finally(() => next2());
+								}
+								if (Date.now() - session.refreshDate > 60 * 60 * 24 * 30 * 1000) {
+									return WSModule.runJob("SOCKETS_FROM_SESSION_ID", {
+										sessionId: session.sessionId
+									}).then(response => {
+										if (response.sockets.length > 0) {
+											session.refreshDate = Date.now();
+											CacheModule.runJob("HSET", {
+												table: "sessions",
+												key: sessionId,
+												value: session
+											}).finally(() => {
+												next2();
+											});
+										} else {
+											TasksModule.log(
+												"INFO",
+												"TASK_SESSION_CLEAR",
+												`Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`
+											);
+											CacheModule.runJob("HDEL", {
+												table: "sessions",
+												key: session.sessionId
+											}).finally(() => next2());
+										}
+									});
+								}
+								TasksModule.log("ERROR", "TASK_SESSION_CLEAR", "This should never log.");
+								return next2();
+							},
+							() => next()
+						);
+					}
+				],
+				() => resolve()
+			);
+		});
+	}
+
+	/**
+	 * Periodically warns about the size of any log files
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	logFileSizeCheckTask() {
+		return new Promise((resolve, reject) => {
+			TasksModule.log("INFO", "TASK_LOG_FILE_SIZE_CHECK", `Checking the size for the log files.`);
+			async.each(
+				["all.log", "debugStation.log", "error.log", "info.log", "success.log"],
+				(fileName, next) => {
+					try {
+						const stats = fs.statSync(path.resolve(__dirname, "../../log/", fileName));
+						const mb = stats.size / 1000000;
+						if (mb > 25) return next(true);
+
+						return next();
+					} catch (err) {
+						return next(err);
+					}
+				},
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err });
+						return reject(new Error(err));
+					}
+					if (err === true) {
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"************************************WARNING*************************************"
+						);
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************"
+						);
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****"
+						);
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"********************************************************************************"
+						);
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Periodically collect users in stations
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async collectStationUsersTask() {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		return new Promise(resolve => {
+			TasksModule.log("INFO", "TASK_COLLECT_STATION_USERS_TASK", `Checking for users in stations.`, false);
+
+			const stationsCountUpdated = [];
+			const stationsUpdated = [];
+
+			const oldUsersPerStation = StationsModule.usersPerStation;
+			const usersPerStation = { loggedIn: [], loggedOut: [] };
+
+			const oldUsersPerStationCount = JSON.parse(JSON.stringify(StationsModule.usersPerStationCount));
+			const usersPerStationCount = {};
+
+			async.each(
+				Object.keys(StationsModule.userList),
+				(socketId, next) => {
+					WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }).then(async socket => {
+						const stationId = StationsModule.userList[socketId];
+						const room = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
+							room: `station.${stationId}`
+						});
+
+						if (!socket || !room.includes(socketId)) {
+							if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
+							if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(String(stationId));
+
+							delete StationsModule.userList[socketId];
+
+							return next();
+						}
+
+						if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0; // start count for station
+						if (!usersPerStation[stationId]) usersPerStation[stationId] = { loggedIn: [], loggedOut: [] };
+
+						return async.waterfall(
+							[
+								next => {
+									if (!socket.session || !socket.session.sessionId) {
+										return next("No session found.", { ip: socket.ip });
+									}
+
+									return CacheModule.runJob("HGET", {
+										table: "sessions",
+										key: socket.session.sessionId
+									})
+										.then(session => next(null, session))
+										.catch(next);
+								},
+
+								(session, next) => {
+									if (!session) return next("Session not found.");
+									return userModel.findOne({ _id: session.userId }, next);
+								},
+
+								(user, next) => {
+									if (!user) return next("User not found.");
+
+									if (usersPerStation[stationId].loggedIn.some(u => user.username === u.username))
+										return next("User already in the list.");
+
+									usersPerStationCount[stationId] += 1; // increment user count for station
+
+									return next(null, {
+										username: user.username,
+										name: user.name,
+										avatar: user.avatar
+									});
+								}
+							],
+							(err, user) => {
+								if (!err) usersPerStation[stationId].loggedIn.push(user);
+
+								// if user is logged out (an ip can only be counted once)
+								if (
+									err === "No session found." &&
+									!usersPerStation[stationId].loggedOut.some(u => user.ip === u.ip)
+								) {
+									usersPerStationCount[stationId] += 1; // increment user count for station
+									usersPerStation[stationId].loggedOut.push(user);
+								}
+
+								next();
+							}
+						);
+					});
+				},
+				() => {
+					Object.keys(usersPerStationCount).forEach(stationId => {
+						if (
+							oldUsersPerStationCount[stationId] !== usersPerStationCount[stationId] &&
+							stationsCountUpdated.indexOf(stationId) === -1
+						) {
+							this.log("INFO", "UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
+
+							CacheModule.runJob("PUB", {
+								channel: "station.updateUserCount",
+								value: { stationId, usersPerStationCount: usersPerStationCount[stationId] }
+							});
+						}
+					});
+
+					Object.keys(usersPerStation).forEach(stationId => {
+						if (
+							!oldUsersPerStation[stationId] ||
+							JSON.stringify(oldUsersPerStation[stationId]) !==
+								JSON.stringify(usersPerStation[stationId]) ||
+							oldUsersPerStationCount[stationId] !== usersPerStationCount[stationId]
+						) {
+							this.log("INFO", "UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
+
+							CacheModule.runJob("PUB", {
+								channel: "station.updateUsers",
+								value: { stationId, usersPerStation: usersPerStation[stationId] }
+							});
+						}
+					});
+
+					StationsModule.usersPerStationCount = usersPerStationCount;
+					StationsModule.usersPerStation = usersPerStation;
+				}
+			);
+
+			resolve();
+		});
+	}
 }
 }
 
 
-module.exports = new TasksModule();
+export default new _TasksModule();

+ 347 - 766
backend/logic/utils.js

@@ -1,768 +1,349 @@
-const CoreClass = require("../core.js");
-
-const config = require("config");
-const async = require("async");
-const request = require("request");
-const crypto = require("crypto");
-
-let youtubeRequestCallbacks = [];
-let youtubeRequestsPending = 0;
-let youtubeRequestsActive = false;
-
-class UtilsModule extends CoreClass {
-    constructor() {
-        super("utils");
-    }
-
-    initialize() {
-        return new Promise((resolve, reject) => {
-            this.io = this.moduleManager.modules["io"];
-            this.db = this.moduleManager.modules["db"];
-            this.spotify = this.moduleManager.modules["spotify"];
-            this.cache = this.moduleManager.modules["cache"];
-
-            resolve();
-        });
-    }
-
-    PARSE_COOKIES(payload) {
-        //cookieString
-        return new Promise((resolve, reject) => {
-            let cookies = {};
-            payload.cookieString.split("; ").map((cookie) => {
-                cookies[
-                    cookie.substring(0, cookie.indexOf("="))
-                ] = cookie.substring(cookie.indexOf("=") + 1, cookie.length);
-            });
-            resolve(cookies);
-        });
-    }
-
-    // COOKIES_TO_STRING() {//cookies
-    // 	return new Promise((resolve, reject) => {
-    //         let newCookie = [];
-    //         for (let prop in cookie) {
-    //             newCookie.push(prop + "=" + cookie[prop]);
-    //         }
-    //         return newCookie.join("; ");
-    //     });
-    // }
-
-    REMOVE_COOKIE(payload) {
-        //cookieString, cookieName
-        return new Promise(async (resolve, reject) => {
-            var cookies = await this.runJob("PARSE_COOKIES", {
-                cookieString: payload.cookieString,
-            });
-            delete cookies[payload.cookieName];
-            resolve(this.toString(cookies));
-        });
-    }
-
-    HTML_ENTITIES(payload) {
-        //str
-        return new Promise((resolve, reject) => {
-            resolve(
-                String(payload.str)
-                    .replace(/&/g, "&amp;")
-                    .replace(/</g, "&lt;")
-                    .replace(/>/g, "&gt;")
-                    .replace(/"/g, "&quot;")
-            );
-        });
-    }
-
-    GENERATE_RANDOM_STRING(payload) {
-        //length
-        return new Promise(async (resolve, reject) => {
-            let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(
-                ""
-            );
-            let result = [];
-            for (let i = 0; i < payload.length; i++) {
-                result.push(
-                    chars[
-                        await this.runJob("GET_RANDOM_NUMBER", {
-                            min: 0,
-                            max: chars.length - 1,
-                        })
-                    ]
-                );
-            }
-            resolve(result.join(""));
-        });
-    }
-
-    GET_SOCKET_FROM_ID(payload) {
-        //socketId
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            resolve(io.sockets.sockets[payload.socketId]);
-        });
-    }
-
-    GET_RANDOM_NUMBER(payload) {
-        //min, max
-        return new Promise((resolve, reject) => {
-            resolve(
-                Math.floor(Math.random() * (payload.max - payload.min + 1)) +
-                    payload.min
-            );
-        });
-    }
-
-    CONVERT_TIME(payload) {
-        //duration
-        return new Promise((resolve, reject) => {
-            let duration = payload.duration;
-            let a = duration.match(/\d+/g);
-
-            if (
-                duration.indexOf("M") >= 0 &&
-                duration.indexOf("H") == -1 &&
-                duration.indexOf("S") == -1
-            ) {
-                a = [0, a[0], 0];
-            }
-
-            if (duration.indexOf("H") >= 0 && duration.indexOf("M") == -1) {
-                a = [a[0], 0, a[1]];
-            }
-            if (
-                duration.indexOf("H") >= 0 &&
-                duration.indexOf("M") == -1 &&
-                duration.indexOf("S") == -1
-            ) {
-                a = [a[0], 0, 0];
-            }
-
-            duration = 0;
-
-            if (a.length == 3) {
-                duration = duration + parseInt(a[0]) * 3600;
-                duration = duration + parseInt(a[1]) * 60;
-                duration = duration + parseInt(a[2]);
-            }
-
-            if (a.length == 2) {
-                duration = duration + parseInt(a[0]) * 60;
-                duration = duration + parseInt(a[1]);
-            }
-
-            if (a.length == 1) {
-                duration = duration + parseInt(a[0]);
-            }
-
-            let hours = Math.floor(duration / 3600);
-            let minutes = Math.floor((duration % 3600) / 60);
-            let seconds = Math.floor((duration % 3600) % 60);
-
-            resolve(
-                (hours < 10 ? "0" + hours + ":" : hours + ":") +
-                    (minutes < 10 ? "0" + minutes + ":" : minutes + ":") +
-                    (seconds < 10 ? "0" + seconds : seconds)
-            );
-        });
-    }
-
-    GUID(payload) {
-        return new Promise((resolve, reject) => {
-            resolve(
-                [1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1]
-                    .map((b) =>
-                        b
-                            ? Math.floor((1 + Math.random()) * 0x10000)
-                                  .toString(16)
-                                  .substring(1)
-                            : "-"
-                    )
-                    .join("")
-            );
-        });
-    }
-
-    SOCKET_FROM_SESSION(payload) {
-        //socketId
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let ns = io.of("/");
-            if (ns) {
-                resolve(ns.connected[payload.socketId]);
-            }
-        });
-    }
-
-    SOCKETS_FROM_SESSION_ID(payload) {
-        //sessionId, cb
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let ns = io.of("/");
-            let sockets = [];
-            if (ns) {
-                async.each(
-                    Object.keys(ns.connected),
-                    (id, next) => {
-                        let session = ns.connected[id].session;
-                        if (session.sessionId === payload.sessionId)
-                            sockets.push(session.sessionId);
-                        next();
-                    },
-                    () => {
-                        resolve({ sockets });
-                    }
-                );
-            }
-        });
-    }
-
-    SOCKETS_FROM_USER(payload) {
-        //userId, cb
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let ns = io.of("/");
-            let sockets = [];
-            if (ns) {
-                async.each(
-                    Object.keys(ns.connected),
-                    (id, next) => {
-                        let session = ns.connected[id].session;
-                        this.cache
-                            .runJob("HGET", {
-                                table: "sessions",
-                                key: session.sessionId,
-                            })
-                            .then((session) => {
-                                if (
-                                    session &&
-                                    session.userId === payload.userId
-                                )
-                                    sockets.push(ns.connected[id]);
-                                next();
-                            })
-                            .catch(() => {
-                                next();
-                            });
-                    },
-                    () => {
-                        resolve({ sockets });
-                    }
-                );
-            }
-        });
-    }
-
-    SOCKETS_FROM_IP(payload) {
-        //ip, cb
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let ns = io.of("/");
-            let sockets = [];
-            if (ns) {
-                async.each(
-                    Object.keys(ns.connected),
-                    (id, next) => {
-                        let session = ns.connected[id].session;
-                        this.cache
-                            .runJob("HGET", {
-                                table: "sessions",
-                                key: session.sessionId,
-                            })
-                            .then((session) => {
-                                if (
-                                    session &&
-                                    ns.connected[id].ip === payload.ip
-                                )
-                                    sockets.push(ns.connected[id]);
-                                next();
-                            })
-                            .catch((err) => {
-                                next();
-                            });
-                    },
-                    () => {
-                        resolve({ sockets });
-                    }
-                );
-            }
-        });
-    }
-
-    SOCKETS_FROM_USER_WITHOUT_CACHE(payload) {
-        //userId, cb
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let ns = io.of("/");
-            let sockets = [];
-            if (ns) {
-                async.each(
-                    Object.keys(ns.connected),
-                    (id, next) => {
-                        let session = ns.connected[id].session;
-                        if (session.userId === payload.userId)
-                            sockets.push(ns.connected[id]);
-                        next();
-                    },
-                    () => {
-                        resolve({ sockets });
-                    }
-                );
-            }
-        });
-    }
-
-    SOCKET_LEAVE_ROOMS(payload) {
-        //socketId
-        return new Promise(async (resolve, reject) => {
-            let socket = await this.runJob("SOCKET_FROM_SESSION", {
-                socketId: payload.socketId,
-            });
-            let rooms = socket.rooms;
-            for (let room in rooms) {
-                socket.leave(room);
-            }
-
-            resolve();
-        });
-    }
-
-    SOCKET_JOIN_ROOM(payload) {
-        //socketId, room
-        return new Promise(async (resolve, reject) => {
-            let socket = await this.runJob("SOCKET_FROM_SESSION", {
-                socketId: payload.socketId,
-            });
-            let rooms = socket.rooms;
-            for (let room in rooms) {
-                socket.leave(room);
-            }
-            socket.join(payload.room);
-            resolve();
-        });
-    }
-
-    SOCKET_JOIN_SONG_ROOM(payload) {
-        //socketId, room
-        return new Promise(async (resolve, reject) => {
-            let socket = await this.runJob("SOCKET_FROM_SESSION", {
-                socketId: payload.socketId,
-            });
-            let rooms = socket.rooms;
-            for (let room in rooms) {
-                if (room.indexOf("song.") !== -1) socket.leave(rooms);
-            }
-            socket.join(payload.room);
-            resolve();
-        });
-    }
-
-    SOCKETS_JOIN_SONG_ROOM(payload) {
-        //sockets, room
-        return new Promise((resolve, reject) => {
-            for (let id in payload.sockets) {
-                let socket = payload.sockets[id];
-                let rooms = socket.rooms;
-                for (let room in rooms) {
-                    if (room.indexOf("song.") !== -1) socket.leave(room);
-                }
-                socket.join(payload.room);
-            }
-            resolve();
-        });
-    }
-
-    SOCKETS_LEAVE_SONG_ROOMS(payload) {
-        //sockets
-        return new Promise((resolve, reject) => {
-            for (let id in payload.sockets) {
-                let socket = payload.sockets[id];
-                let rooms = socket.rooms;
-                for (let room in rooms) {
-                    if (room.indexOf("song.") !== -1) socket.leave(room);
-                }
-            }
-            resolve();
-        });
-    }
-
-    EMIT_TO_ROOM(payload) {
-        //room, ...args
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let sockets = io.sockets.sockets;
-            for (let id in sockets) {
-                let socket = sockets[id];
-                if (socket.rooms[payload.room]) {
-                    socket.emit.apply(socket, payload.args);
-                }
-            }
-            resolve();
-        });
-    }
-
-    GET_ROOM_SOCKETS(payload) {
-        //room
-        return new Promise(async (resolve, reject) => {
-            let io = await this.io.runJob("IO", {});
-            let sockets = io.sockets.sockets;
-            let roomSockets = [];
-            for (let id in sockets) {
-                let socket = sockets[id];
-                if (socket.rooms[payload.room]) roomSockets.push(socket);
-            }
-            resolve(roomSockets);
-        });
-    }
-
-    GET_SONG_FROM_YOUTUBE(payload) {
-        //songId, cb
-        return new Promise((resolve, reject) => {
-            youtubeRequestCallbacks.push({
-                cb: (test) => {
-                    youtubeRequestsActive = true;
-                    const youtubeParams = [
-                        "part=snippet,contentDetails,statistics,status",
-                        `id=${encodeURIComponent(payload.songId)}`,
-                        `key=${config.get("apis.youtube.key")}`,
-                    ].join("&");
-
-                    request(
-                        `https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`,
-                        (err, res, body) => {
-                            youtubeRequestCallbacks.splice(0, 1);
-                            if (youtubeRequestCallbacks.length > 0) {
-                                youtubeRequestCallbacks[0].cb(
-                                    youtubeRequestCallbacks[0].songId
-                                );
-                            } else youtubeRequestsActive = false;
-
-                            if (err) {
-                                console.error(err);
-                                return null;
-                            }
-
-                            body = JSON.parse(body);
-
-                            //TODO Clean up duration converter
-                            let dur = body.items[0].contentDetails.duration;
-                            dur = dur.replace("PT", "");
-                            let duration = 0;
-                            dur = dur.replace(/([\d]*)H/, (v, v2) => {
-                                v2 = Number(v2);
-                                duration = v2 * 60 * 60;
-                                return "";
-                            });
-                            dur = dur.replace(/([\d]*)M/, (v, v2) => {
-                                v2 = Number(v2);
-                                duration += v2 * 60;
-                                return "";
-                            });
-                            dur = dur.replace(/([\d]*)S/, (v, v2) => {
-                                v2 = Number(v2);
-                                duration += v2;
-                                return "";
-                            });
-
-                            let song = {
-                                songId: body.items[0].id,
-                                title: body.items[0].snippet.title,
-                                duration,
-                            };
-                            resolve({ song });
-                        }
-                    );
-                },
-                songId: payload.songId,
-            });
-
-            if (!youtubeRequestsActive) {
-                youtubeRequestCallbacks[0].cb(
-                    youtubeRequestCallbacks[0].songId
-                );
-            }
-        });
-    }
-
-    FILTER_MUSIC_VIDEOS_YOUTUBE(payload) {
-        //videoIds, cb
-        return new Promise((resolve, reject) => {
-            function getNextPage(cb2) {
-                let localVideoIds = payload.videoIds.splice(0, 50);
-
-                const youtubeParams = [
-                    "part=topicDetails",
-                    `id=${encodeURIComponent(localVideoIds.join(","))}`,
-                    `maxResults=50`,
-                    `key=${config.get("apis.youtube.key")}`,
-                ].join("&");
-
-                request(
-                    `https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`,
-                    async (err, res, body) => {
-                        if (err) {
-                            console.error(err);
-                            return next("Failed to find playlist from YouTube");
-                        }
-
-                        body = JSON.parse(body);
-
-                        let songIds = [];
-                        body.items.forEach((item) => {
-                            const songId = item.id;
-                            if (!item.topicDetails) return;
-                            else if (
-                                item.topicDetails.relevantTopicIds.indexOf(
-                                    "/m/04rlf"
-                                ) !== -1
-                            ) {
-                                songIds.push(songId);
-                            }
-                        });
-
-                        if (payload.videoIds.length > 0) {
-                            getNextPage((newSongIds) => {
-                                cb2(songIds.concat(newSongIds));
-                            });
-                        } else cb2(songIds);
-                    }
-                );
-            }
-
-            if (payload.videoIds.length === 0) resolve({ songIds: [] });
-            else
-                getNextPage((songIds) => {
-                    resolve({ songIds });
-                });
-        });
-    }
-
-    GET_PLAYLIST_FROM_YOUTUBE(payload) {
-        //url, musicOnly, cb
-        return new Promise((resolve, reject) => {
-            let local = this;
-
-            let name = "list".replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
-            var regex = new RegExp("[\\?&]" + name + "=([^&#]*)");
-            let playlistId = regex.exec(payload.url)[1];
-
-            function getPage(pageToken, songs) {
-                let nextPageToken = pageToken ? `pageToken=${pageToken}` : "";
-                const youtubeParams = [
-                    "part=contentDetails",
-                    `playlistId=${encodeURIComponent(playlistId)}`,
-                    `maxResults=50`,
-                    `key=${config.get("apis.youtube.key")}`,
-                    nextPageToken,
-                ].join("&");
-
-                request(
-                    `https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`,
-                    async (err, res, body) => {
-                        if (err) {
-                            console.error(err);
-                            return next("Failed to find playlist from YouTube");
-                        }
-
-                        body = JSON.parse(body);
-                        songs = songs.concat(body.items);
-                        if (body.nextPageToken)
-                            getPage(body.nextPageToken, songs);
-                        else {
-                            songs = songs.map(
-                                (song) => song.contentDetails.videoId
-                            );
-                            if (!payload.musicOnly) resolve({ songs });
-                            else {
-                                local
-                                    .runJob("FILTER_MUSIC_VIDEOS_YOUTUBE", {
-                                        videoIds: songs.slice(),
-                                    })
-                                    .then((filteredSongs) => {
-                                        resolve({ filteredSongs, songs });
-                                    });
-                            }
-                        }
-                    }
-                );
-            }
-            getPage(null, []);
-        });
-    }
-
-    GET_SONG_FROM_SPOTIFY(payload) {
-        //song, cb
-        return new Promise(async (resolve, reject) => {
-            if (!config.get("apis.spotify.enabled"))
-                return reject(new Error("Spotify is not enabled."));
-
-            const song = Object.assign({}, payload.song);
-
-            const spotifyParams = [
-                `q=${encodeURIComponent(payload.song.title)}`,
-                `type=track`,
-            ].join("&");
-
-            const token = await this.spotify.runJob("GET_TOKEN", {});
-            const options = {
-                url: `https://api.spotify.com/v1/search?${spotifyParams}`,
-                headers: {
-                    Authorization: `Bearer ${token}`,
-                },
-            };
-
-            request(options, (err, res, body) => {
-                if (err) console.error(err);
-                body = JSON.parse(body);
-                if (body.error) console.error(body.error);
-
-                durationArtistLoop: for (let i in body) {
-                    let items = body[i].items;
-                    for (let j in items) {
-                        let item = items[j];
-                        let hasArtist = false;
-                        for (let k = 0; k < item.artists.length; k++) {
-                            let artist = item.artists[k];
-                            if (song.title.indexOf(artist.name) !== -1)
-                                hasArtist = true;
-                        }
-                        if (hasArtist && song.title.indexOf(item.name) !== -1) {
-                            song.duration = item.duration_ms / 1000;
-                            song.artists = item.artists.map((artist) => {
-                                return artist.name;
-                            });
-                            song.title = item.name;
-                            song.explicit = item.explicit;
-                            song.thumbnail = item.album.images[1].url;
-                            break durationArtistLoop;
-                        }
-                    }
-                }
-
-                resolve({ song });
-            });
-        });
-    }
-
-    GET_SONGS_FROM_SPOTIFY(payload) {
-        //title, artist, cb
-        return new Promise(async (resolve, reject) => {
-            if (!config.get("apis.spotify.enabled"))
-                return reject(new Error("Spotify is not enabled."));
-
-            const spotifyParams = [
-                `q=${encodeURIComponent(payload.title)}`,
-                `type=track`,
-            ].join("&");
-
-            const token = await this.spotify.runJob("GET_TOKEN", {});
-            const options = {
-                url: `https://api.spotify.com/v1/search?${spotifyParams}`,
-                headers: {
-                    Authorization: `Bearer ${token}`,
-                },
-            };
-
-            request(options, (err, res, body) => {
-                if (err) return console.error(err);
-                body = JSON.parse(body);
-                if (body.error) return console.error(body.error);
-
-                let songs = [];
-
-                for (let i in body) {
-                    let items = body[i].items;
-                    for (let j in items) {
-                        let item = items[j];
-                        let hasArtist = false;
-                        for (let k = 0; k < item.artists.length; k++) {
-                            let localArtist = item.artists[k];
-                            if (
-                                payload.artist.toLowerCase() ===
-                                localArtist.name.toLowerCase()
-                            )
-                                hasArtist = true;
-                        }
-                        if (
-                            hasArtist &&
-                            (payload.title.indexOf(item.name) !== -1 ||
-                                item.name.indexOf(payload.title) !== -1)
-                        ) {
-                            let song = {};
-                            song.duration = item.duration_ms / 1000;
-                            song.artists = item.artists.map((artist) => {
-                                return artist.name;
-                            });
-                            song.title = item.name;
-                            song.explicit = item.explicit;
-                            song.thumbnail = item.album.images[1].url;
-                            songs.push(song);
-                        }
-                    }
-                }
-
-                resolve({ songs });
-            });
-        });
-    }
-
-    SHUFFLE(payload) {
-        //array
-        return new Promise((resolve, reject) => {
-            const array = payload.array.slice();
-
-            let currentIndex = payload.array.length,
-                temporaryValue,
-                randomIndex;
-
-            // While there remain elements to shuffle...
-            while (0 !== currentIndex) {
-                // Pick a remaining element...
-                randomIndex = Math.floor(Math.random() * currentIndex);
-                currentIndex -= 1;
-
-                // And swap it with the current element.
-                temporaryValue = array[currentIndex];
-                array[currentIndex] = array[randomIndex];
-                array[randomIndex] = temporaryValue;
-            }
-
-            resolve({ array });
-        });
-    }
-
-    GET_ERROR(payload) {
-        //err
-        return new Promise((resolve, reject) => {
-            let error = "An error occurred.";
-            if (typeof payload.error === "string") error = payload.error;
-            else if (payload.error.message) {
-                if (payload.error.message !== "Validation failed")
-                    error = payload.error.message;
-                else
-                    error =
-                        payload.error.errors[Object.keys(payload.error.errors)]
-                            .message;
-            }
-            resolve(error);
-        });
-    }
-
-    CREATE_GRAVATAR(payload) {
-        //email
-        return new Promise((resolve, reject) => {
-            const hash = crypto
-                .createHash("md5")
-                .update(payload.email)
-                .digest("hex");
-
-            resolve(`https://www.gravatar.com/avatar/${hash}`);
-        });
-    }
-
-    DEBUG(payload) {
-        return new Promise((resolve, reject) => {
-            resolve();
-        });
-    }
+import crypto from "crypto";
+import CoreClass from "../core";
+
+let UtilsModule;
+
+class _UtilsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("utils");
+
+		UtilsModule = this;
+	}
+
+	/**
+	 * Initialises the utils module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => resolve());
+	}
+
+	/**
+	 * Parses the cookie into a readable object
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.cookieString - the cookie string
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	PARSE_COOKIES(payload) {
+		return new Promise((resolve, reject) => {
+			const cookies = {};
+
+			if (typeof payload.cookieString !== "string") return reject(new Error("Cookie string is not a string"));
+
+			// eslint-disable-next-line array-callback-return
+			payload.cookieString.split("; ").map(cookie => {
+				cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(
+					cookie.indexOf("=") + 1,
+					cookie.length
+				);
+			});
+
+			return resolve(cookies);
+		});
+	}
+
+	// COOKIES_TO_STRING() {//cookies
+	// 	return new Promise((resolve, reject) => {
+	//         let newCookie = [];
+	//         for (let prop in cookie) {
+	//             newCookie.push(prop + "=" + cookie[prop]);
+	//         }
+	//         return newCookie.join("; ");
+	//     });
+	// }
+
+	/**
+	 * Removes a cookie by name
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.cookieString - the cookie string
+	 * @param {string} payload.cookieName - the unique name of the cookie
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REMOVE_COOKIE(payload) {
+		return new Promise((resolve, reject) => {
+			let cookies;
+
+			try {
+				cookies = UtilsModule.runJob(
+					"PARSE_COOKIES",
+					{
+						cookieString: payload.cookieString
+					},
+					this
+				);
+			} catch (err) {
+				return reject(err);
+			}
+
+			delete cookies[payload.cookieName];
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Replaces any html reserved characters in a string with html entities
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.str - the string to replace characters with html entities
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	HTML_ENTITIES(payload) {
+		return new Promise(resolve => {
+			resolve(
+				String(payload.str)
+					.replace(/&/g, "&amp;")
+					.replace(/</g, "&lt;")
+					.replace(/>/g, "&gt;")
+					.replace(/"/g, "&quot;")
+			);
+		});
+	}
+
+	/**
+	 * Generates a random string of a specified length
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {number} payload.length - the length the random string should be
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GENERATE_RANDOM_STRING(payload) {
+		const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
+
+		const promises = [];
+		for (let i = 0; i < payload.length; i += 1) {
+			promises.push(
+				UtilsModule.runJob(
+					"GET_RANDOM_NUMBER",
+					{
+						min: 0,
+						max: chars.length - 1
+					},
+					this
+				)
+			);
+		}
+
+		const randomNums = await Promise.all(promises);
+
+		const randomChars = [];
+		for (let i = 0; i < payload.length; i += 1) {
+			randomChars.push(chars[randomNums[i]]);
+		}
+
+		return new Promise(resolve => resolve(randomChars.join("")));
+	}
+
+	/**
+	 * Creates a random number within a range
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {number} payload.min - the minimum number the result should be
+	 * @param {number} payload.max - the maximum number the result should be
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_RANDOM_NUMBER(payload) {
+		// min, max
+		return new Promise(resolve =>
+			resolve(Math.floor(Math.random() * (payload.max - payload.min + 1)) + payload.min)
+		);
+	}
+
+	/**
+	 * Converts ISO8601 time format (YouTube API) to HH:MM:SS
+	 *
+	 * @param  {object} payload - object contaiing the payload
+	 * @param {string} payload.duration - string in the format of ISO8601
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CONVERT_TIME(payload) {
+		// duration
+		return new Promise(resolve => {
+			let { duration } = payload;
+			let a = duration.match(/\d+/g);
+
+			if (duration.indexOf("M") >= 0 && duration.indexOf("H") === -1 && duration.indexOf("S") === -1) {
+				a = [0, a[0], 0];
+			}
+
+			if (duration.indexOf("H") >= 0 && duration.indexOf("M") === -1) {
+				a = [a[0], 0, a[1]];
+			}
+			if (duration.indexOf("H") >= 0 && duration.indexOf("M") === -1 && duration.indexOf("S") === -1) {
+				a = [a[0], 0, 0];
+			}
+
+			duration = 0;
+
+			if (a.length === 3) {
+				duration += parseInt(a[0]) * 3600;
+				duration += parseInt(a[1]) * 60;
+				duration += parseInt(a[2]);
+			}
+
+			if (a.length === 2) {
+				duration += parseInt(a[0]) * 60;
+				duration += parseInt(a[1]);
+			}
+
+			if (a.length === 1) {
+				duration += parseInt(a[0]);
+			}
+
+			const hours = Math.floor(duration / 3600);
+			const minutes = Math.floor((duration % 3600) / 60);
+			const seconds = Math.floor((duration % 3600) % 60);
+
+			resolve(
+				(hours < 10 ? `0${hours}:` : `${hours}:`) +
+					(minutes < 10 ? `0${minutes}:` : `${minutes}:`) +
+					(seconds < 10 ? `0${seconds}` : seconds)
+			);
+		});
+	}
+
+	/**
+	 * Creates a random identifier for e.g. sessionId
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GUID() {
+		return new Promise(resolve => {
+			resolve(
+				[1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1]
+					.map(b =>
+						b
+							? Math.floor((1 + Math.random()) * 0x10000)
+									.toString(16)
+									.substring(1)
+							: "-"
+					)
+					.join("")
+			);
+		});
+	}
+
+	/**
+	 * Shuffles an array
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.array - an array of songs that should be shuffled
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SHUFFLE(payload) {
+		// array
+		return new Promise(resolve => {
+			const { array } = payload;
+
+			// sort the positions array
+			let currentIndex = array.length;
+			let temporaryValue;
+			let randomIndex;
+
+			// While there remain elements to shuffle...
+			while (currentIndex !== 0) {
+				// 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;
+			}
+
+			resolve({ array });
+		});
+	}
+
+	/**
+	 * Shuffles an array of songs by their position property
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.array - an array of songs that should be shuffled
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SHUFFLE_SONG_POSITIONS(payload) {
+		// array
+		return new Promise(resolve => {
+			const { array } = payload;
+
+			// get array of positions
+			const positions = [];
+			array.forEach(song => positions.push(song.position));
+
+			// sort the positions array
+			let currentIndex = positions.length;
+			let temporaryValue;
+			let randomIndex;
+
+			// While there remain elements to shuffle...
+			while (currentIndex !== 0) {
+				// Pick a remaining element...
+				randomIndex = Math.floor(Math.random() * currentIndex);
+				currentIndex -= 1;
+
+				// And swap it with the current element.
+				temporaryValue = positions[currentIndex];
+				positions[currentIndex] = positions[randomIndex];
+				positions[randomIndex] = temporaryValue;
+			}
+
+			// assign new positions
+			array.forEach((song, index) => {
+				song.position = positions[index];
+			});
+
+			resolve({ array });
+		});
+	}
+
+	/**
+	 * Creates an error
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.error - object that contains the error
+	 * @param {string} payload.message - possible error message
+	 * @param {object} payload.errors - possible object that contains multiple errors
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ERROR(payload) {
+		return new Promise(resolve => {
+			let error = "An error occurred.";
+			if (typeof payload.error === "string") error = payload.error;
+			else if (payload.error.message) {
+				if (payload.error.message !== "Validation failed") error = payload.error.message;
+				else error = payload.error.errors[Object.keys(payload.error.errors)].message;
+			}
+			resolve(error);
+		});
+	}
+
+	/**
+	 * Creates the gravatar url for a specified email address
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.email - the email address
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_GRAVATAR(payload) {
+		return new Promise(resolve => {
+			const hash = crypto.createHash("md5").update(payload.email).digest("hex");
+
+			resolve(`https://www.gravatar.com/avatar/${hash}`);
+		});
+	}
+
+	/**
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DEBUG() {
+		return new Promise(resolve => resolve());
+	}
 }
 }
 
 
-module.exports = new UtilsModule();
+export default new _UtilsModule();

+ 771 - 0
backend/logic/ws.js

@@ -0,0 +1,771 @@
+/**
+ * @file
+ */
+
+import config from "config";
+import async from "async";
+import { WebSocketServer } from "ws";
+import { EventEmitter } from "events";
+
+import CoreClass from "../core";
+
+let WSModule;
+let AppModule;
+let CacheModule;
+let UtilsModule;
+let DBModule;
+let PunishmentsModule;
+
+class _WSModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("ws");
+
+		WSModule = this;
+	}
+
+	/**
+	 * Initialises the ws module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		AppModule = this.moduleManager.modules.app;
+		CacheModule = this.moduleManager.modules.cache;
+		UtilsModule = this.moduleManager.modules.utils;
+		DBModule = this.moduleManager.modules.db;
+		PunishmentsModule = this.moduleManager.modules.punishments;
+
+		this.actions = (await import("./actions")).default;
+
+		this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		this.setStage(2);
+
+		this.SIDname = config.get("cookie.SIDname");
+
+		// TODO: Check every 30s/, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
+		const server = await AppModule.runJob("SERVER");
+
+		this._io = new WebSocketServer({ server, path: "/ws" });
+
+		this.rooms = {};
+
+		return new Promise(resolve => {
+			this.setStage(3);
+
+			this._io.on("connection", async (socket, req) => {
+				socket.dispatch = (...args) => socket.send(JSON.stringify(args));
+
+				socket.actions = new EventEmitter();
+				socket.actions.setMaxListeners(0);
+				socket.listen = (target, cb) => socket.actions.addListener(target, args => cb(args));
+
+				WSModule.runJob("HANDLE_WS_USE", { socket, req }).then(socket =>
+					WSModule.runJob("HANDLE_WS_CONNECTION", { socket })
+				);
+
+				socket.isAlive = true;
+				socket.on("pong", function heartbeat() {
+					this.isAlive = true;
+				});
+			});
+
+			const keepAliveInterval = setInterval(() => {
+				this._io.clients.forEach(socket => {
+					if (socket.isAlive === false) return socket.terminate();
+
+					socket.isAlive = false;
+					return socket.ping(() => {});
+				});
+			}, 45000);
+
+			this._io.on("close", () => clearInterval(keepAliveInterval));
+
+			this.setStage(4);
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns the websockets variable
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	WS() {
+		return new Promise(resolve => resolve(WSModule._io));
+	}
+
+	/**
+	 * Obtains socket object for a specified socket id
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.socketId - the id of the socket
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_FROM_SOCKET_ID(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+
+			if (clients)
+				// eslint-disable-next-line consistent-return
+				clients.forEach(socket => {
+					if (socket.session.socketId === payload.socketId) return resolve(socket);
+				});
+
+			// socket doesn't exist
+			return resolve();
+		});
+	}
+
+	/**
+	 * Gets all sockets for a specified session id
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.sessionId - user session id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_SESSION_ID(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+			const sockets = [];
+
+			if (clients) {
+				return async.each(
+					Object.keys(clients),
+					(id, next) => {
+						const { session } = clients[id];
+						if (session.sessionId === payload.sessionId) sockets.push(session.sessionId);
+						next();
+					},
+					() => resolve(sockets)
+				);
+			}
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns any sockets for a specific user
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the user id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_USER(payload) {
+		return new Promise((resolve, reject) => {
+			const sockets = [];
+
+			return async.eachLimit(
+				WSModule._io.clients,
+				1,
+				(socket, next) => {
+					const { sessionId } = socket.session;
+
+					if (sessionId) {
+						return CacheModule.runJob("HGET", { table: "sessions", key: sessionId }, this)
+							.then(session => {
+								if (session && session.userId === payload.userId) sockets.push(socket);
+								next();
+							})
+							.catch(err => next(err));
+					}
+
+					return next();
+				},
+				err => {
+					if (err) return reject(err);
+					return resolve(sockets);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns any sockets from a specific ip address
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.ip - the ip address in question
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_IP(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+
+			const sockets = [];
+
+			return async.each(
+				Object.keys(clients),
+				(id, next) => {
+					const { session } = clients[id];
+
+					CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }, this)
+						.then(session => {
+							if (session && clients[id].ip === payload.ip) sockets.push(clients[id]);
+							next();
+						})
+						.catch(() => next());
+				},
+				() => resolve(sockets)
+			);
+		});
+	}
+
+	/**
+	 * Returns any sockets from a specific user without using redis/cache
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user in question
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_USER_WITHOUT_CACHE(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+			const sockets = [];
+
+			if (clients) {
+				return async.each(
+					Object.keys(clients),
+					(id, next) => {
+						const { session } = clients[id];
+						if (session.userId === payload.userId) sockets.push(clients[id]);
+						next();
+					},
+					() => resolve(sockets)
+				);
+			}
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows a socket to leave any rooms they are connected to
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should leave all their rooms
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_LEAVE_ROOMS(payload) {
+		return new Promise(resolve => {
+			// filter out rooms that the user is in
+			Object.keys(WSModule.rooms).forEach(room => {
+				WSModule.rooms[room] = WSModule.rooms[room].filter(participant => participant !== payload.socketId);
+			});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows a socket to leave a specific room they are connected to
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should leave a room
+	 * @param {string} payload.room - the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_LEAVE_ROOM(payload) {
+		return new Promise(resolve => {
+			// filter out rooms that the user is in
+			if (WSModule.rooms[payload.room])
+				WSModule.rooms[payload.room] = WSModule.rooms[payload.room].filter(
+					participant => participant !== payload.socketId
+				);
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows a socket to join a specified room (this will remove them from any rooms they are currently in)
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should join the room
+	 * @param {string} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_JOIN_ROOM(payload) {
+		const { room, socketId } = payload;
+		return new Promise(resolve => {
+			// create room if it doesn't exist, and add socketId to array
+			if (WSModule.rooms[room]) {
+				if (WSModule.rooms[room].indexOf(socketId) === -1) WSModule.rooms[room].push(socketId);
+			} else WSModule.rooms[room] = [socketId];
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Emits arguments to any sockets that are in a specified a room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.room - the name of the room to emit arguments
+	 * @param {object} payload.args - any arguments to be emitted to the sockets in the specific room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async EMIT_TO_ROOM(payload) {
+		return new Promise(resolve => {
+			// if the room exists
+			if (WSModule.rooms[payload.room] && WSModule.rooms[payload.room].length > 0)
+				return WSModule.rooms[payload.room].forEach(async socketId => {
+					// get every socketId (and thus every socket) in the room, and dispatch to each
+					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (socket) socket.dispatch(...payload.args);
+					return resolve();
+				});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Emits arguments to any sockets that are in specified rooms
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.rooms - array of strings with the name of each room e.g. ["station-page", "song.1234"]
+	 * @param {object} payload.args - any arguments to be emitted to the sockets in the specific room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async EMIT_TO_ROOMS(payload) {
+		return new Promise(resolve =>
+			async.each(
+				payload.rooms,
+				(room, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", { room, args: payload.args });
+					return next();
+				},
+				() => resolve()
+			)
+		);
+	}
+
+	/**
+	 * Allows a socket to join a 'song' room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should join the room
+	 * @param {string} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_JOIN_SONG_ROOM(payload) {
+		const { room, socketId } = payload;
+
+		// leave any other song rooms the user is in
+		await WSModule.runJob("SOCKETS_LEAVE_SONG_ROOMS", { sockets: [socketId] }, this);
+
+		return new Promise(resolve => {
+			// join the room
+			if (WSModule.rooms[room]) WSModule.rooms[room].push(socketId);
+			else WSModule.rooms[room] = [socketId];
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows multiple sockets to join a 'song' room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.sockets - array of socketIds
+	 * @param {object} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SOCKETS_JOIN_SONG_ROOM(payload) {
+		return new Promise(resolve => {
+			Promise.allSettled(
+				payload.sockets.map(async socketId => {
+					await WSModule.runJob("SOCKET_JOIN_SONG_ROOM", { socketId, room: payload.room }, this);
+				})
+			).then(() => resolve());
+		});
+	}
+
+	/**
+	 * Allows multiple sockets to leave any 'song' rooms they are in
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.sockets - array of socketIds
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SOCKETS_LEAVE_SONG_ROOMS(payload) {
+		return new Promise(resolve =>
+			Promise.allSettled(
+				payload.sockets.map(async socketId => {
+					const rooms = await WSModule.runJob("GET_ROOMS_FOR_SOCKET", { socketId }, this);
+
+					rooms.forEach(room => {
+						if (room.indexOf("song.") !== -1)
+							WSModule.rooms[room] = WSModule.rooms[room].filter(participant => participant !== socketId);
+					});
+				})
+			).then(() => resolve())
+		);
+	}
+
+	/**
+	 * Gets any sockets connected to a room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_SOCKETS_FOR_ROOM(payload) {
+		return new Promise(resolve => {
+			if (WSModule.rooms[payload.room]) return resolve(WSModule.rooms[payload.room]);
+			return resolve([]);
+		});
+	}
+
+	/**
+	 * Gets any rooms a socket is connected to
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket to check the rooms for
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ROOMS_FOR_SOCKET(payload) {
+		return new Promise(resolve => {
+			const rooms = [];
+
+			Object.keys(WSModule.rooms).forEach(room => {
+				if (WSModule.rooms[room].includes(payload.socketId)) rooms.push(room);
+			});
+
+			return resolve(rooms);
+		});
+	}
+
+	/**
+	 * Handles use of websockets
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async HANDLE_WS_USE(payload) {
+		return new Promise(resolve => {
+			const { socket, req } = payload;
+			let SID = "";
+
+			socket.ip = req.headers["x-forwarded-for"] || "0..0.0";
+
+			return async.waterfall(
+				[
+					next => {
+						if (!req.headers.cookie) return next("No cookie exists yet.");
+						return UtilsModule.runJob("PARSE_COOKIES", { cookieString: req.headers.cookie }, this).then(
+							res => {
+								SID = res[WSModule.SIDname];
+								next(null);
+							}
+						);
+					},
+
+					next => {
+						if (!SID) return next("No SID.");
+						return next();
+					},
+
+					// see if session exists for cookie
+					next => {
+						CacheModule.runJob("HGET", { table: "sessions", key: SID }, this)
+							.then(session => next(null, session))
+							.catch(next);
+					},
+
+					(session, next) => {
+						if (!session) return next("No session found.");
+
+						session.refreshDate = Date.now();
+
+						socket.session = session;
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "sessions",
+								key: SID,
+								value: session
+							},
+							this
+						).then(session => next(null, session));
+					},
+
+					(res, next) => {
+						// check if a session's user / IP is banned
+						PunishmentsModule.runJob("GET_PUNISHMENTS", {}, this)
+							.then(punishments => {
+								const isLoggedIn = !!(socket.session && socket.session.refreshDate);
+								const userId = isLoggedIn ? socket.session.userId : null;
+
+								const banishment = {
+									banned: false,
+									ban: 0
+								};
+
+								punishments.forEach(punishment => {
+									if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
+									if (punishment.type === "banUserId" && isLoggedIn && punishment.value === userId)
+										banishment.banned = true;
+									if (punishment.type === "banUserIp" && punishment.value === socket.ip)
+										banishment.banned = true;
+								});
+
+								socket.banishment = banishment;
+
+								next();
+							})
+							.catch(() => next());
+					}
+				],
+				() => {
+					if (!socket.session) socket.session = { socketId: req.headers["sec-websocket-key"] };
+					else socket.session.socketId = req.headers["sec-websocket-key"];
+
+					resolve(socket);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Handles a websocket connection
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.socket - socket itself
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async HANDLE_WS_CONNECTION(payload) {
+		return new Promise(resolve => {
+			const { socket } = payload;
+
+			let sessionInfo = "";
+			if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+
+			// if session is banned
+			if (socket.banishment && socket.banishment.banned) {
+				WSModule.log(
+					"INFO",
+					"IO_BANNED_CONNECTION",
+					`A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`
+				);
+
+				socket.dispatch("keep.event:banned", { data: { ban: socket.banishment.ban } });
+
+				return socket.close(); // close socket connection
+			}
+
+			WSModule.log("INFO", "IO_CONNECTION", `User connected. IP: ${socket.ip}.${sessionInfo}`);
+
+			// catch when the socket has been disconnected
+			socket.on("close", async () => {
+				if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+				WSModule.log("INFO", "IO_DISCONNECTION", `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
+
+				// leave all rooms when a socket connection is closed (to prevent rooms object building up)
+				await WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: socket.session.socketId });
+			});
+
+			// catch errors on the socket
+			socket.onerror = error => {
+				console.error("SOCKET ERROR: ", error);
+			};
+
+			if (socket.session.sessionId) {
+				CacheModule.runJob("HGET", {
+					table: "sessions",
+					key: socket.session.sessionId
+				})
+					.then(session => {
+						if (session && session.userId) {
+							WSModule.userModel.findOne({ _id: session.userId }, (err, user) => {
+								if (err || !user) return socket.dispatch("ready", { data: { loggedIn: false } });
+
+								let role = "";
+								let username = "";
+								let userId = "";
+								let email = "";
+
+								if (user) {
+									role = user.role;
+									username = user.username;
+									email = user.email.address;
+									userId = session.userId;
+								}
+
+								return socket.dispatch("ready", {
+									data: { loggedIn: true, role, username, userId, email }
+								});
+							});
+						} else socket.dispatch("ready", { data: { loggedIn: false } });
+					})
+					.catch(() => socket.dispatch("ready", { data: { loggedIn: false } }));
+			} else socket.dispatch("ready", { data: { loggedIn: false } });
+
+			socket.onmessage = message => {
+				const data = JSON.parse(message.data);
+
+				if (data.length === 0) return socket.dispatch("ERROR", "Not enough arguments specified.");
+				if (typeof data[0] !== "string") return socket.dispatch("ERROR", "First argument must be a string.");
+
+				const namespaceAction = data[0];
+				if (
+					!namespaceAction ||
+					namespaceAction.indexOf(".") === -1 ||
+					namespaceAction.indexOf(".") !== namespaceAction.lastIndexOf(".")
+				)
+					return socket.dispatch("ERROR", "Invalid first argument");
+
+				const namespace = data[0].split(".")[0];
+				const action = data[0].split(".")[1];
+
+				if (!namespace) return socket.dispatch("ERROR", "Invalid namespace.");
+				if (!action) return socket.dispatch("ERROR", "Invalid action.");
+				if (!WSModule.actions[namespace]) return socket.dispatch("ERROR", "Namespace not found.");
+				if (!WSModule.actions[namespace][action]) return socket.dispatch("ERROR", "Action not found.");
+
+				if (data[data.length - 1].CB_REF) {
+					const { CB_REF } = data[data.length - 1];
+					data.pop();
+
+					return socket.actions.emit(data.shift(0), [...data, res => socket.dispatch("CB_REF", CB_REF, res)]);
+				}
+
+				return socket.actions.emit(data.shift(0), data);
+			};
+
+			// have the socket listen for each action
+			Object.keys(WSModule.actions).forEach(namespace => {
+				Object.keys(WSModule.actions[namespace]).forEach(action => {
+					// the full name of the action
+					const name = `${namespace}.${action}`;
+
+					// listen for this action to be called
+					socket.listen(name, async args =>
+						WSModule.runJob("RUN_ACTION", { socket, namespace, action, args })
+					);
+				});
+			});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Runs an action
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async RUN_ACTION(payload) {
+		return new Promise((resolve, reject) => {
+			const { socket, namespace, action, args } = payload;
+
+			// the full name of the action
+			const name = `${namespace}.${action}`;
+
+			let cb = args[args.length - 1];
+
+			if (typeof cb !== "function")
+				cb = () => {
+					WSModule.log("INFO", "IO_MODULE", `There was no callback provided for ${name}.`);
+				};
+			else args.pop();
+
+			WSModule.log("INFO", "IO_ACTION", `A user executed an action. Action: ${namespace}.${action}.`);
+
+			// load the session from the cache
+			new Promise(resolve => {
+				if (socket.session.sessionId)
+					CacheModule.runJob("HGET", {
+						table: "sessions",
+						key: socket.session.sessionId
+					})
+						.then(session => {
+							// make sure the sockets sessionId isn't set if there is no session
+							if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+							resolve();
+						})
+						.catch(() => {
+							if (typeof cb === "function")
+								cb({
+									status: "error",
+									message: "An error occurred while obtaining your session"
+								});
+							reject(new Error("An error occurred while obtaining the session"));
+						});
+				else resolve();
+			})
+				.then(() => {
+					// call the job that calls the action, passing it the session, and the arguments the websocket passed us
+
+					WSModule.runJob("RUN_ACTION2", { session: socket.session, namespace, action, args }, this)
+						.then(response => {
+							cb(response);
+							resolve();
+						})
+						.catch(err => {
+							if (typeof cb === "function")
+								cb({
+									status: "error",
+									message: "An error occurred while executing the specified action."
+								});
+
+							reject(err);
+
+							WSModule.log(
+								"ERROR",
+								"IO_ACTION_ERROR",
+								`Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`
+							);
+						});
+				})
+				.catch(reject);
+		});
+	}
+
+	/**
+	 * Runs an action
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async RUN_ACTION2(payload) {
+		return new Promise((resolve, reject) => {
+			const { session, namespace, action, args } = payload;
+
+			try {
+				// call the the action, passing it the session, and the arguments the websocket passed us
+				WSModule.actions[namespace][action].apply(
+					this,
+					[session].concat(args).concat([
+						result => {
+							WSModule.log(
+								"INFO",
+								"RUN_ACTION2",
+								`Response to action. Action: ${namespace}.${action}. Response status: ${result.status}`
+							);
+
+							resolve(result);
+						}
+					])
+				);
+			} catch (err) {
+				reject(err);
+
+				WSModule.log(
+					"ERROR",
+					"IO_ACTION_ERROR",
+					`Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`
+				);
+			}
+		});
+	}
+}
+
+export default new _WSModule();

+ 430 - 0
backend/logic/youtube.js

@@ -0,0 +1,430 @@
+import async from "async";
+import config from "config";
+
+import * as rax from "retry-axios";
+import axios from "axios";
+
+import CoreClass from "../core";
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed YouTube request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
+	 *
+	 * @returns {Promise} - promise that gets resolved when the rate limit allows it
+	 */
+	continue() {
+		return new Promise(resolve => {
+			if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
+			else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
+		});
+	}
+
+	/**
+	 * Restart the rate limit timer
+	 */
+	restart() {
+		this.dateStarted = Date.now();
+	}
+}
+
+let YouTubeModule;
+
+class _YouTubeModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("youtube", {
+			concurrency: 1,
+			priorities: {
+				GET_PLAYLIST: 11
+			}
+		});
+
+		YouTubeModule = this;
+	}
+
+	/**
+	 * Initialises the activities module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			this.rateLimiter = new RateLimitter(config.get("apis.youtube.rateLimit"));
+			this.requestTimeout = config.get("apis.youtube.requestTimeout");
+
+			this.axios = axios.create();
+			this.axios.defaults.raxConfig = {
+				instance: this.axios,
+				retry: config.get("apis.youtube.retryAmount"),
+				noResponseRetries: config.get("apis.youtube.retryAmount")
+			};
+			rax.attach(this.axios);
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Fetches a list of songs from Youtube's API
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query we'll pass to youtubes api
+	 * @param {string} payload.pageToken - (optional) if this exists, will search search youtube for a specific page reference
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		const params = {
+			part: "snippet",
+			q: payload.query,
+			key: config.get("apis.youtube.key"),
+			type: "video",
+			maxResults: 10
+		};
+
+		if (payload.pageToken) params.pageToken = payload.pageToken;
+
+		return new Promise((resolve, reject) =>
+			YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/search", {
+						params,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"SEARCH",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.err) {
+							YouTubeModule.log("ERROR", "SEARCH", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						return resolve(res.data);
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
+						return reject(new Error("An error has occured. Please try again later."));
+					});
+			})
+		);
+	}
+
+	/**
+	 * Gets the details of a song using the YouTube API
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.youtubeId - the YouTube API id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const params = {
+				part: "snippet,contentDetails,statistics,status",
+				id: payload.youtubeId,
+				key: config.get("apis.youtube.key")
+			};
+
+			if (payload.pageToken) params.pageToken = payload.pageToken;
+
+			YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/videos", {
+						params,
+						timeout: YouTubeModule.requestTimeout,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"GET_SONG",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.error) {
+							YouTubeModule.log("ERROR", "GET_SONG", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						if (res.data.items[0] === undefined)
+							return reject(
+								new Error("The specified video does not exist or cannot be publicly accessed.")
+							);
+
+						// TODO Clean up duration converter
+						let dur = res.data.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 "";
+						});
+
+						// eslint-disable-next-line no-unused-vars
+						dur = dur.replace(/([\d]*)S/, (v, v2) => {
+							v2 = Number(v2);
+							duration += v2;
+							return "";
+						});
+
+						const song = {
+							youtubeId: res.data.items[0].id,
+							title: res.data.items[0].snippet.title,
+							thumbnail: res.data.items[0].snippet.thumbnails.default.url,
+							duration
+						};
+
+						return resolve({ song });
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "GET_SONG", `${err.message}`);
+						return reject(new Error("An error has occured. Please try again later."));
+					});
+			});
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a YouTube playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the playlist
+	 * @param {string} payload.url - the url of the YouTube playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const splitQuery = regex.exec(payload.url);
+
+			if (!splitQuery) {
+				YouTubeModule.log("ERROR", "GET_PLAYLIST", "Invalid YouTube playlist URL query.");
+				return reject(new Error("Invalid playlist URL."));
+			}
+			const playlistId = splitQuery[1];
+
+			return async.waterfall(
+				[
+					next => {
+						let songs = [];
+						let nextPageToken = "";
+
+						async.whilst(
+							next => {
+								YouTubeModule.log(
+									"INFO",
+									`Getting playlist progress for job (${this.toString()}): ${
+										songs.length
+									} songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
+								);
+								next(null, nextPageToken !== undefined);
+							},
+							next => {
+								// Add 250ms delay between each job request
+								setTimeout(() => {
+									YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
+										.then(response => {
+											songs = songs.concat(response.songs);
+											nextPageToken = response.nextPageToken;
+											next();
+										})
+										.catch(err => next(err));
+								}, 250);
+							},
+							err => next(err, songs)
+						);
+					},
+
+					(songs, next) =>
+						next(
+							null,
+							songs.map(song => song.contentDetails.videoId)
+						),
+
+					(songs, next) => {
+						if (!payload.musicOnly) return next(true, { songs });
+						return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
+							.then(filteredSongs => next(null, { filteredSongs, songs }))
+							.catch(next);
+					}
+				],
+				(err, response) => {
+					if (err && err !== true) {
+						YouTubeModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
+						reject(new Error(err.message));
+					} else {
+						resolve({ songs: response.filteredSongs ? response.filteredSongs.youtubeIds : response.songs });
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {boolean} payload.playlistId - the playlist id to get videos from
+	 * @param {boolean} payload.nextPageToken - the nextPageToken to use
+	 * @param {string} payload.url - the url of the YouTube playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST_PAGE(payload) {
+		return new Promise((resolve, reject) => {
+			const params = {
+				part: "contentDetails",
+				playlistId: payload.playlistId,
+				key: config.get("apis.youtube.key"),
+				maxResults: 50
+			};
+
+			if (payload.nextPageToken) params.pageToken = payload.nextPageToken;
+
+			YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/playlistItems", {
+						params,
+						timeout: YouTubeModule.requestTimeout,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"GET_PLAYLIST_PAGE",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.err) {
+							YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						const songs = res.data.items;
+
+						if (res.data.nextPageToken) return resolve({ nextPageToken: res.data.nextPageToken, songs });
+
+						return resolve({ songs });
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
+						if (err.message === "Request failed with status code 404") {
+							return reject(new Error("Playlist not found. Is the playlist public/unlisted?"));
+						}
+						return reject(new Error("An error has occured. Please try again later."));
+					});
+			});
+		});
+	}
+
+	/**
+	 * Filters a list of YouTube videos so that they only contains videos with music. Is used internally by GET_PLAYLIST
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.videoIds - an array of YouTube videoIds to filter through
+	 * @param {Array} payload.page - the current page/set of video's to get, starting at 0. If left null, 0 is assumed. Will recurse.
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	FILTER_MUSIC_VIDEOS(payload) {
+		return new Promise((resolve, reject) => {
+			const page = payload.page ? payload.page : 0;
+
+			const videosPerPage = 50;
+
+			const localVideoIds = payload.videoIds.splice(page * 50, videosPerPage);
+
+			if (localVideoIds.length === 0) {
+				return resolve({ videoIds: [] });
+			}
+
+			const params = {
+				part: "topicDetails",
+				id: localVideoIds.join(","),
+				key: config.get("apis.youtube.key"),
+				maxResults: videosPerPage
+			};
+
+			return YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/videos", {
+						params,
+						timeout: YouTubeModule.requestTimeout,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"FILTER_MUSIC_VIDEOS",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.err) {
+							YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						const youtubeIds = [];
+
+						res.data.items.forEach(item => {
+							const youtubeId = item.id;
+
+							if (!item.topicDetails) return;
+							if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
+								youtubeIds.push(youtubeId);
+						});
+
+						return YouTubeModule.runJob(
+							"FILTER_MUSIC_VIDEOS",
+							{ videoIds: payload.videoIds, page: page + 1 },
+							this
+						)
+							.then(result => resolve({ youtubeIds: youtubeIds.concat(result.youtubeIds) }))
+							.catch(err => reject(err));
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
+						return reject(new Error("Failed to find playlist from YouTube"));
+					});
+			});
+		});
+	}
+}
+
+export default new _YouTubeModule();

File diff suppressed because it is too large
+ 432 - 474
backend/package-lock.json


+ 30 - 22
backend/package.json

@@ -1,39 +1,47 @@
 {
 {
   "name": "musare-backend",
   "name": "musare-backend",
   "private": true,
   "private": true,
-  "version": "2.1.0",
-  "description": "A modern, open-source, collaborative music app https://musare.com",
+  "version": "3.0.0",
+  "type": "module",
+  "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
   "main": "index.js",
   "author": "Musare Team",
   "author": "Musare Team",
   "license": "GPL-3.0",
   "license": "GPL-3.0",
-  "repository": "https://github.com/Musare/MusareNode",
+  "repository": "https://github.com/Musare/Musare",
   "scripts": {
   "scripts": {
-    "dev": "nodemon",
-    "docker:dev": "nodemon -L /opt/app",
-    "docker:prod": "node /opt/app",
-    "snyk-protect": "snyk protect",
-    "prepublish": "npm run snyk-protect"
+    "dev": "nodemon --es-module-specifier-resolution=node",
+    "docker:dev": "nodemon --es-module-specifier-resolution=node -L /opt/app",
+    "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
+    "lint": "npx eslint logic"
   },
   },
   "dependencies": {
   "dependencies": {
-    "async": "3.1.0",
-    "bcrypt": "^3.0.6",
-    "bluebird": "^3.5.5",
+    "async": "^3.2.1",
+    "axios": "^0.22.0",
+    "bcrypt": "^5.0.1",
+    "bluebird": "^3.7.2",
     "body-parser": "^1.19.0",
     "body-parser": "^1.19.0",
-    "config": "^3.3.1",
+    "config": "^3.3.6",
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
-    "discord.js": "^11.6.4",
     "express": "^4.17.1",
     "express": "^4.17.1",
-    "mailgun-js": "^0.22.0",
-    "moment": "^2.24.0",
-    "mongoose": "^5.9.10",
+    "moment": "^2.29.1",
+    "mongoose": "^6.0.10",
+    "nodemailer": "^6.7.0",
     "oauth": "^0.9.15",
     "oauth": "^0.9.15",
-    "redis": "^2.8.0",
-    "request": "^2.88.0",
+    "redis": "^3.1.2",
+    "retry-axios": "^2.6.0",
     "sha256": "^0.2.0",
     "sha256": "^0.2.0",
-    "socket.io": "^2.2.0",
-    "underscore": "^1.10.2",
-    "snyk": "^1.316.1"
+    "underscore": "^1.13.1",
+    "ws": "^8.2.3"
   },
   },
-  "snyk": true
+  "devDependencies": {
+    "eslint": "^7.32.0",
+    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-config-prettier": "^8.3.0",
+    "eslint-plugin-import": "^2.24.2",
+    "eslint-plugin-jsdoc": "^36.1.1",
+    "eslint-plugin-prettier": "^4.0.0",
+    "prettier": "2.4.1",
+    "trace-unhandled": "^2.0.1"
+  }
 }
 }

+ 15 - 16
docker-compose.yml

@@ -1,33 +1,35 @@
-version: '2'
+version: '3'
 services:
 services:
+
   backend:
   backend:
     build: ./backend
     build: ./backend
     ports:
     ports:
-      - "${BACKEND_PORT}:8080"
+      - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
     volumes:
     volumes:
       - ./backend:/opt/app
       - ./backend:/opt/app
       - ./log:/opt/log
       - ./log:/opt/log
     links:
     links:
       - mongo
       - mongo
       - redis
       - redis
-    environment:
-      - SNYK_TOKEN=${SNYK_TOKEN}
     stdin_open: true
     stdin_open: true
     tty: true
     tty: true
+
   frontend:
   frontend:
     build: ./frontend
     build: ./frontend
     ports:
     ports:
-      - "${FRONTEND_PORT}:80"
+      - "${FRONTEND_HOST}:${FRONTEND_PORT}:80"
     volumes:
     volumes:
       - ./frontend:/opt/app
       - ./frontend:/opt/app
       - /opt/app/node_modules/
       - /opt/app/node_modules/
     environment:
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE}
       - FRONTEND_MODE=${FRONTEND_MODE}
-      - SNYK_TOKEN=${SNYK_TOKEN}
+    links:
+      - backend
+
   mongo:
   mongo:
     image: mongo:4.0
     image: mongo:4.0
     ports:
     ports:
-      - "${MONGO_PORT}:27017"
+      - "${MONGO_HOST}:${MONGO_PORT}:${MONGO_PORT}"
     environment:
     environment:
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
@@ -38,16 +40,13 @@ services:
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
     volumes:
     volumes:
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
-  mongoclient:
-    image: mongoclient/mongoclient
-    ports:
-      - "${MONGOCLIENT_PORT}:3000"
-    environment:
-      - MONGOCLIENT_DEFAULT_CONNECTION_URL=mongodb://${MONGO_USER_USERNAME}:${MONGO_USER_PASSWORD}@mongo:27017/musare
+      - ./.db:/data/db
+
   redis:
   redis:
     image: redis
     image: redis
-    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
+    ports:
+      - "${REDIS_HOST}:${REDIS_PORT}:6379"
+    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
     volumes:
     volumes:
       - .redis:/data
       - .redis:/data
-    ports:
-      - "${REDIS_PORT}:6379"
+

+ 49 - 139
fallback.html

@@ -1,148 +1,58 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html>
 <html>
-    <head>
-        <title>Musare</title>
 
 
-        <link href="https://fonts.googleapis.com/css?family=Lato:100" rel="stylesheet" type="text/css">
-        <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
-        <style media="screen"></style>
-        <style>
-            html, body {
-                height: 100%;
-            }
-            body {
-                margin: 0;
-                padding: 0;
-                width: 100%;
-                display: table;
-                font-weight: 100;
-                font-family: 'Lato';
-            }
-            .container {
-                text-align: center;
-                display: table-cell;
-                vertical-align: middle;
-            }
-            .content {
-                text-align: center;
-                display: inline-block;
-                padding-left: 15px;
-                padding-right: 15px;
-            }
-            .title {
-                font-size: 96px;
-            }
-            p {
-              font-size: 28px;
-              font-weight: 700;
-            }
-            .social {
-                width: 100%;
-                text-align:center;
-                display: block;
-            }
-            .social #discord {
-                display: inline-block;
-                vertical-align: middle;
-                color: #ff4545;
-                font-size: 22px;
-                background-color: transparent;
-                height: 40px;
-                line-height: 42px;
-                width: 40px;
-                margin: 10px 5px;
-                transition: all ease-in-out 0.5s
-            }
-            .social #discord .st0 {
-                fill:#ff4545;
-                transition: all ease-in-out 0.5s
-            }
-            .social #discord:hover .st0 {
-                fill:#0279b1;
-                transition: all ease-in-out 0.5s
-            }
-            .social .fa {
-                display: inline-block;
-                vertical-align: middle;
-                color: #ff4545;
-                font-size: 28px;
-                background-color: transparent;
-                height: 40px;
-                line-height: 42px;
-                width: 40px;
-                margin: 10px 5px;
-                transition: all ease-in-out 0.5s
-            }
-            .social .fa:hover {
-                color: #0279b1;
-            }
+<head>
+    <title>Musare</title>
+    <link href="https://fonts.googleapis.com/css?family=Lato:100" rel="stylesheet" type="text/css">
+    <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
+        integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
+    <style>
+        html,
+        body {
+            height: 100%;
+        }
 
 
-            .social .social-icon .fa {
-                font-size:20px;
-            	position:absolute;
-            	left:9px;
-            	top:10px;
-            }
+        body {
+            margin: 0;
+            padding: 0;
+            width: 100%;
+            display: table;
+            font-weight: 100;
+            font-family: 'Lato';
+        }
 
 
-            .socialIcon {
-                position: relative;
-            }
+        .container {
+            text-align: center;
+            display: table-cell;
+            vertical-align: middle;
+        }
 
 
-            .socialIcon .icon-purpose {
-        		visibility: hidden;
-        		width: 120px;
-        		font-size: 18px;
-        		background-color: rgba(255, 69, 69, 0.8);
-        		color: #fff;
-        		text-align: center;
-        		border-radius: 6px;
-        		padding: 5px;
-        		position: absolute;
-        		z-index: 1;
-                bottom: 150%;
-                left: 50%;
-                margin-left: -65px;
-        		opacity: 0;
-                margin-bottom: 10px;
-            	transition: opacity 0.5s;
-        		display: none;
-        	}
+        .content {
+            text-align: center;
+            display: inline-block;
+            padding-left: 15px;
+            padding-right: 15px;
+        }
 
 
-        	.socialIcon .icon-purpose::after {
-        		content: "";
-        	    position: absolute;
-                top: 100%;
-                left: 50%;
-                margin-left: -5px;
-        	    border-width: 5px;
-        	    border-style: solid;
-        	    border-color: rgba(255, 69, 69, 0.8) transparent transparent transparent;
-        	}
+        .title {
+            font-size: 96px;
+        }
 
 
-        	.socialIcon:hover .icon-purpose {
-        		visibility: visible;
-        		opacity: 1;
-        		display: block;
-        	}
-        </style>
-    </head>
-    <body>
-        <div class="container">
-            <div class="content">
-                <img src="https://preview.ibb.co/eAo1y5/logo.png" alt="Logo" style="width: 80%;" />
-                <div class="title">We are offline!</div>
-                <p>Visit Twitter or Discord via the links below to check when we are back online.</p>
-                <span class="social">
-                    <a class="socialIcon" href="https://twitter.com/MusareApp" target="_blank">
-                        <i class="fa fa-twitter" aria-hidden="true"></i>
-                        <span class="icon-purpose">Twitter</span>
-                    </a>
-                    <a class="socialIcon" href="https://discord.gg/Y5NxYGP" target="_blank">
-                        <svg id="discord" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><path class="st0" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path class="st0" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>
-                        <span class="icon-purpose">Discord</span>
-                    </a>
-                </span>
-            </div>
+        p {
+            font-size: 28px;
+            font-weight: 700;
+        }
+    </style>
+</head>
+
+<body>
+    <div class="container">
+        <div class="content">
+            <img src="https://preview.ibb.co/eAo1y5/logo.png" alt="Logo" style="width: 80%;" />
+            <div class="title">We are offline!</div>
+            <p>Please check back later.</p>
         </div>
         </div>
-    </body>
-</html>
+    </div>
+</body>
+
+</html>

+ 1 - 1
frontend/.babelrc

@@ -1,5 +1,5 @@
 {
 {
-	"presets": ["@babel/preset-env"],
+	"presets": [ ["@babel/preset-env", { "modules": false }] ],
 	"plugins": [
 	"plugins": [
 		"@babel/plugin-transform-runtime",
 		"@babel/plugin-transform-runtime",
 		"@babel/plugin-syntax-dynamic-import",
 		"@babel/plugin-syntax-dynamic-import",

+ 19 - 9
frontend/.eslintrc

@@ -4,23 +4,27 @@
 		"browser": true,
 		"browser": true,
 		"amd": true,
 		"amd": true,
 		"node": true,
 		"node": true,
-		"es6": true,
-		"jquery": true
+		"es6": true
 	},
 	},
 	"parserOptions": {
 	"parserOptions": {
 		"ecmaVersion": 2018,
 		"ecmaVersion": 2018,
 		"sourceType": "module",
 		"sourceType": "module",
-		"parser": "babel-eslint"
+		"parser": "@babel/eslint-parser",
+		"requireConfigFile": false
 	},
 	},
 	"extends": [
 	"extends": [
 		"airbnb-base",
 		"airbnb-base",
-		"plugin:vue/essential",
-		"plugin:prettier/recommended",
-		"eslint:recommended"
+		"plugin:vue/strongly-recommended",
+		"eslint:recommended",
+		"prettier"
+	],
+	"plugins": [
+		"prettier"
 	],
 	],
 	"globals": {
 	"globals": {
 		"lofig": "writable",
 		"lofig": "writable",
-		"grecaptcha": "readonly"
+		"grecaptcha": "readonly",
+		"history": "readonly"
 	},
 	},
 	"rules": {
 	"rules": {
 		"no-console": 0,
 		"no-console": 0,
@@ -30,6 +34,12 @@
 		"radix": 0,
 		"radix": 0,
 		"no-multi-assign": 0,
 		"no-multi-assign": 0,
 		"no-shadow": 0,
 		"no-shadow": 0,
-		"no-new": 0
+		"no-new": 0,
+		"import/no-unresolved": 0,
+		"import/extensions": 0,
+		"prettier/prettier": [
+			"error"
+		],
+		"vue/order-in-components": 2
 	}
 	}
-}
+}

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