Selaa lähdekoodia

Merge branch 'staging' into release/backend-rewrite

Owen Diffey 2 vuotta sitten
vanhempi
sitoutus
cd24b1dc82
100 muutettua tiedostoa jossa 5927 lisäystä ja 1438 poistoa
  1. 15 0
      .dockerignore
  2. 15 3
      .env.example
  3. 2 4
      .github/workflows/automated-tests.yml
  4. 2 4
      .github/workflows/build-lint.yml.disabled
  5. 1 2
      .gitignore
  6. 140 119
      .wiki/Configuration.md
  7. 10 33
      .wiki/Installation.md
  8. 15 17
      .wiki/Upgrading.md
  9. 1 1
      .wiki/Value_Formats.md
  10. 121 0
      CHANGELOG.md
  11. 0 3
      backend/.dockerignore
  12. 1 2
      backend/.eslintignore
  13. 0 1
      backend/.prettierignore
  14. 9 6
      backend/Dockerfile
  15. 0 1
      backend/classes/Timer.class.js
  16. 20 0
      backend/config/custom-environment-variables.json
  17. 154 0
      backend/config/default.json
  18. 20 121
      backend/config/template.json
  19. 14 32
      backend/core.js
  20. 7 3
      backend/entrypoint.sh
  21. 62 46
      backend/index.js
  22. 0 4
      backend/logic/actions/activities.js
  23. 197 9
      backend/logic/actions/apis.js
  24. 0 2
      backend/logic/actions/dataRequests.js
  25. 4 0
      backend/logic/actions/index.js
  26. 91 78
      backend/logic/actions/media.js
  27. 0 7
      backend/logic/actions/news.js
  28. 434 227
      backend/logic/actions/playlists.js
  29. 0 5
      backend/logic/actions/punishments.js
  30. 8 16
      backend/logic/actions/reports.js
  31. 53 61
      backend/logic/actions/songs.js
  32. 260 0
      backend/logic/actions/soundcloud.js
  33. 80 0
      backend/logic/actions/spotify.js
  34. 173 66
      backend/logic/actions/stations.js
  35. 26 48
      backend/logic/actions/users.js
  36. 19 5
      backend/logic/actions/utils.js
  37. 220 21
      backend/logic/actions/youtube.js
  38. 6 11
      backend/logic/activities.js
  39. 1 2
      backend/logic/api.js
  40. 19 15
      backend/logic/app.js
  41. 78 26
      backend/logic/cache/index.js
  42. 0 1
      backend/logic/cache/schemas/playlist.js
  43. 0 1
      backend/logic/cache/schemas/station.js
  44. 90 28
      backend/logic/db/index.js
  45. 2 2
      backend/logic/db/schemas/activity.js
  46. 6 0
      backend/logic/db/schemas/genericApiRequest.js
  47. 10 3
      backend/logic/db/schemas/playlist.js
  48. 2 0
      backend/logic/db/schemas/queueSong.js
  49. 2 2
      backend/logic/db/schemas/ratings.js
  50. 2 2
      backend/logic/db/schemas/report.js
  51. 2 2
      backend/logic/db/schemas/song.js
  52. 27 0
      backend/logic/db/schemas/soundcloudTrack.js
  53. 8 0
      backend/logic/db/schemas/spotifyAlbum.js
  54. 8 0
      backend/logic/db/schemas/spotifyArtist.js
  55. 18 0
      backend/logic/db/schemas/spotifyTrack.js
  56. 23 14
      backend/logic/db/schemas/station.js
  57. 22 0
      backend/logic/db/schemas/stationHistory.js
  58. 9 0
      backend/logic/db/schemas/youtubeChannel.js
  59. 3 1
      backend/logic/db/schemas/youtubeVideo.js
  60. 41 7
      backend/logic/hooks/hasPermission.js
  61. 8 14
      backend/logic/mail/index.js
  62. 4 5
      backend/logic/mail/schemas/dataRequest.js
  63. 0 3
      backend/logic/mail/schemas/passwordRequest.js
  64. 0 3
      backend/logic/mail/schemas/resetPasswordRequest.js
  65. 2 5
      backend/logic/mail/schemas/verifyEmail.js
  66. 211 50
      backend/logic/media.js
  67. 4 6
      backend/logic/migration/index.js
  68. 0 1
      backend/logic/migration/migrations/migration1.js
  69. 0 1
      backend/logic/migration/migrations/migration10.js
  70. 0 1
      backend/logic/migration/migrations/migration11.js
  71. 0 1
      backend/logic/migration/migrations/migration12.js
  72. 0 1
      backend/logic/migration/migrations/migration13.js
  73. 0 1
      backend/logic/migration/migrations/migration14.js
  74. 0 1
      backend/logic/migration/migrations/migration15.js
  75. 0 1
      backend/logic/migration/migrations/migration16.js
  76. 0 1
      backend/logic/migration/migrations/migration17.js
  77. 0 1
      backend/logic/migration/migrations/migration18.js
  78. 0 1
      backend/logic/migration/migrations/migration19.js
  79. 0 1
      backend/logic/migration/migrations/migration2.js
  80. 0 1
      backend/logic/migration/migrations/migration20.js
  81. 0 1
      backend/logic/migration/migrations/migration21.js
  82. 0 1
      backend/logic/migration/migrations/migration22.js
  83. 0 1
      backend/logic/migration/migrations/migration23.js
  84. 0 1
      backend/logic/migration/migrations/migration24.js
  85. 270 0
      backend/logic/migration/migrations/migration25.js
  86. 0 1
      backend/logic/migration/migrations/migration3.js
  87. 0 1
      backend/logic/migration/migrations/migration4.js
  88. 0 1
      backend/logic/migration/migrations/migration5.js
  89. 0 1
      backend/logic/migration/migrations/migration6.js
  90. 0 1
      backend/logic/migration/migrations/migration7.js
  91. 0 1
      backend/logic/migration/migrations/migration8.js
  92. 0 1
      backend/logic/migration/migrations/migration9.js
  93. 122 0
      backend/logic/musicbrainz.js
  94. 2 11
      backend/logic/notifications.js
  95. 191 52
      backend/logic/playlists.js
  96. 0 6
      backend/logic/punishments.js
  97. 172 86
      backend/logic/songs.js
  98. 696 0
      backend/logic/soundcloud.js
  99. 1479 0
      backend/logic/spotify.js
  100. 213 109
      backend/logic/stations.js

+ 15 - 0
.dockerignore

@@ -0,0 +1,15 @@
+*
+
+!.git/config
+!.git/HEAD
+!.git/refs/
+
+!types/
+!backend/
+!frontend/
+
+*/node_modules/
+*/Dockerfile
+*/build/
+
+backend/config/local*.json

+ 15 - 3
.env.example

@@ -1,17 +1,20 @@
 COMPOSE_PROJECT_NAME=musare
 RESTART_POLICY=unless-stopped
-CONTAINER_MODE=prod
+CONTAINER_MODE=production
 DOCKER_COMMAND=docker
 
 BACKEND_HOST=127.0.0.1
 BACKEND_PORT=8080
-BACKEND_MODE=prod
+BACKEND_MODE=production
 BACKEND_DEBUG=false
 BACKEND_DEBUG_PORT=9229
 
 FRONTEND_HOST=127.0.0.1
 FRONTEND_PORT=80
-FRONTEND_MODE=prod
+FRONTEND_CLIENT_PORT=80
+FRONTEND_DEV_PORT=81
+FRONTEND_MODE=production
+FRONTEND_PROD_DEVTOOLS=false
 
 MONGO_HOST=127.0.0.1
 MONGO_PORT=27017
@@ -28,3 +31,12 @@ REDIS_DATA_LOCATION=.redis
 
 BACKUP_LOCATION=
 BACKUP_NAME=
+
+MUSARE_SITENAME=Musare
+
+MUSARE_DEBUG_VERSION=true
+MUSARE_DEBUG_GIT_REMOTE=false
+MUSARE_DEBUG_GIT_REMOTE_URL=false
+MUSARE_DEBUG_GIT_BRANCH=true
+MUSARE_DEBUG_GIT_LATEST_COMMIT=true
+MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=true

+ 2 - 4
.github/workflows/automated-tests.yml

@@ -5,12 +5,12 @@ on: [ push, pull_request, workflow_dispatch ]
 env:
     COMPOSE_PROJECT_NAME: musare
     RESTART_POLICY: unless-stopped
-    CONTAINER_MODE: prod
+    CONTAINER_MODE: production
     BACKEND_HOST: 127.0.0.1
     BACKEND_PORT: 8080
     FRONTEND_HOST: 127.0.0.1
     FRONTEND_PORT: 80
-    FRONTEND_MODE: prod
+    FRONTEND_MODE: production
     MONGO_HOST: 127.0.0.1
     MONGO_PORT: 27017
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
@@ -31,8 +31,6 @@ jobs:
             - name: Build Musare
               run: |
                   cp .env.example .env
-                  cp backend/config/template.json backend/config/default.json
-                  cp frontend/dist/config/template.json frontend/dist/config/default.json
                   ./musare.sh build
             - name: Start Musare
               run: ./musare.sh start

+ 2 - 4
.github/workflows/build-lint.yml.disabled

@@ -5,12 +5,12 @@ on: [ push, pull_request, workflow_dispatch ]
 env:
     COMPOSE_PROJECT_NAME: musare
     RESTART_POLICY: unless-stopped
-    CONTAINER_MODE: prod
+    CONTAINER_MODE: production
     BACKEND_HOST: 127.0.0.1
     BACKEND_PORT: 8080
     FRONTEND_HOST: 127.0.0.1
     FRONTEND_PORT: 80
-    FRONTEND_MODE: prod
+    FRONTEND_MODE: production
     MONGO_HOST: 127.0.0.1
     MONGO_PORT: 27017
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
@@ -31,8 +31,6 @@ jobs:
             - name: Build Musare
               run: |
                   cp .env.example .env
-                  cp backend/config/template.json backend/config/default.json
-                  cp frontend/dist/config/template.json frontend/dist/config/default.json
                   ./musare.sh build
             - name: Start Musare
               run: ./musare.sh start

+ 1 - 2
.gitignore

@@ -19,7 +19,7 @@ lerna-debug.log
 
 # Backend
 backend/node_modules/
-backend/config/default.json
+backend/config/local*.json
 backend/build
 
 # Frontend
@@ -27,7 +27,6 @@ frontend/bundle-stats.json
 frontend/bundle-report.html
 frontend/node_modules/
 frontend/build/
-frontend/dist/config/default.json
 frontend/src/coverage/
 
 npm

+ 140 - 119
.wiki/Configuration.md

@@ -1,19 +1,98 @@
 # Configuration
 
-## Backend
+## Environment Variables
 
-Location: `backend/config/default.json`
+Environment variables are the means of configuring application services,
+particularly with our standard Docker environment.
+
+For our standard Docker setup variables should be defined in `.env`,
+an example can be found in `.env.example`
+(when updating please refer to this file).
+After updating values in `.env`, containers should be restarted or rebuilt.
+If you are using a different setup, you will need to define the relevant
+environment variables yourself.
+
+In the table below, the `[SERVICE]_HOST` properties refer to the IP address that
+the Docker container listens on. Setting this to `127.0.0.1` for will only expose
+the configured port to localhost, whereas setting this to `0.0.0.0` will expose the
+port on all interfaces.
+The `[SERVICE]_PORT` properties refer to the external Docker container port, used
+to access services from outside 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 database through that port on your
+machine, 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. |
+| `RESTART_POLICY` | Restart policy for Docker containers, values can be found [here](https://docs.docker.com/config/containers/start-containers-automatically/). |
+| `CONTAINER_MODE` | Should be either `production` or `development`.  |
+| `DOCKER_COMMAND` | Should be either `docker` or `podman`.  |
+| `BACKEND_HOST` | Backend container host. Only used for development mode. |
+| `BACKEND_PORT` | Backend container port. Only used for development mode. |
+| `BACKEND_MODE` | Should be either `production` or `development`. |
+| `BACKEND_DEBUG` | Should be either `true` or `false`. If enabled backend will await debugger connection and trigger to start. |
+| `BACKEND_DEBUG_PORT` | Backend container debug port, if enabled. |
+| `FRONTEND_HOST` | Frontend container host. |
+| `FRONTEND_PORT` | Frontend container port. |
+| `FRONTEND_CLIENT_PORT` | Should be the port on which the frontend will be accessible from, usually port `80`, or `443` if using SSL. Only used when running in development mode. |
+| `FRONTEND_DEV_PORT` | Should be the port where Vite's 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. Only used when running in development mode. |
+| `FRONTEND_MODE` | Should be either `production` or `development`. |
+| `FRONTEND_PROD_DEVTOOLS` | Whether to enable Vue dev tools in production builds. [^1] |
+| `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. |
+| `MONGO_DATA_LOCATION` | The location where MongoDB stores its data. Usually the `.db` folder inside the `Musare` folder. |
+| `MONGO_VERSION` | The MongoDB version to use for scripts and docker compose. Must be numerical. Currently supported MongoDB versions are 4.0+. Always make a backup before changing this value. |
+| `REDIS_HOST` | Redis container host. |
+| `REDIS_PORT` | Redis container port. |
+| `REDIS_PASSWORD` | Redis password. |
+| `REDIS_DATA_LOCATION` | The location where Redis stores its data. Usually the `.redis` folder inside the `Musare` folder. |
+| `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`. |
+| `MUSARE_SITENAME` | Should be the name of the site. [^1] |
+| `MUSARE_DEBUG_VERSION` | Log/expose the current package.json version. [^1] |
+| `MUSARE_DEBUG_GIT_REMOTE` | Log/expose the current Git repository's remote. [^1] |
+| `MUSARE_DEBUG_GIT_REMOTE_URL` | Log/expose the current Git repository's remote URL. [^1] |
+| `MUSARE_DEBUG_GIT_BRANCH` | Log/expose the current Git repository's branch. [^1] |
+| `MUSARE_DEBUG_GIT_LATEST_COMMIT` | Log/expose the current Git repository's latest commit hash. [^1] |
+| `MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT` | Log/expose the current Git repository's latest commit hash (short). [^1] |
+
+[^1]: If value is changed the frontend will require a rebuild in production mode.
+
+## Backend Config
+
+The backend config serves as the primary configuration means of the application.
+The default values can be found in `backend/config/default.json`.
+
+To overwrite these, create a local config e.g. `backend/config/local.json` and
+define key/values.
+A basic template can be found in `backend/config/template.json`.
+
+If the default configuration changes, so will the `configVersion`.
+When updating, please refer to the `default.json`, make any required
+changes to your `local.json`, and update your `configVersion`.
+
+Some configuration values are overwritten by
+[Environment Variables](#environment-variables) and can not be
+overwritten with `local.json`.
+These values can be found in `backend/config/custom-environment-variables.json`.
+
+For more information on configuration files please refer to the
+[config package documentation](https://github.com/node-config/node-config/wiki/Configuration-Files).
 
 | 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. |
-| `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. |
+| `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
+| `migration` | 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' session module. |
+| `port` | The port the backend will listen on. Should always be `8080` for Docker. |
+| `url.host` | Hostname used to access application, e.g. `localhost`. |
+| `url.secure` | Should be `true` if your site is using SSL, otherwise it should be `false`. |
+| `cookie` | Name of the `SID` cookie used for storing login sessions. |
+| `sitename` | Should be the name of the site. |
 | `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. |
@@ -22,139 +101,81 @@ Location: `backend/config/default.json`
 | `apis.youtube.quotas.type` | YouTube API quota type, should be one of `QUERIES_PER_DAY`, `QUERIES_PER_MINUTE` or `QUERIES_PER_100_SECONDS`. |
 | `apis.youtube.quotas.title` | YouTube API quota title. |
 | `apis.youtube.quotas.limit` | YouTube API quota limit. |
-| `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.youtube.maxPlaylistPages` | Maximum pages to fetch from YouTube playlists. |
+| `apis.spotify.clientId` | Spotify API clientId, obtained from [here](https://developer.spotify.com/documentation/web-api/tutorials/getting-started). |
+| `apis.spotify.clientSecret` | Spotify API clientSecret, obtained with clientId. |
+| `apis.spotify.rateLimit` | Minimum interval between Spotify API requests in milliseconds. |
+| `apis.spotify.requestTimeout` | Spotify API requests timeout in milliseconds. |
+| `apis.spotify.retryAmount` | The amount of retries to perform of a failed Spotify API request. |
+| `apis.soundcloud.rateLimit` | Minimum interval between SoundCloud API requests in milliseconds. |
+| `apis.soundcloud.requestTimeout` | SoundCloud API requests timeout in milliseconds. |
+| `apis.soundcloud.retryAmount` | The amount of retries to perform of a failed SoundCloud API request. |
+| `apis.recaptcha.enabled` | Whether to enable ReCaptcha in the regular (email) registration form. |
+| `apis.recaptcha.key` | ReCaptcha Site v3 key, obtained from [here](https://www.google.com/recaptcha/admin). |
+| `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained with key. |
 | `apis.github.enabled` | Whether to enable GitHub authentication. |
 | `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.github.redirect_uri` | The backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. This is configured based on the `url` config option by default. |
+| `apis.discogs.enabled` | Whether to enable Discogs API usage. |
 | `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. |
-| `mail.from` | The from field for mails sent from backend. |
-| `redis.url` | Should be left as default for Docker installations, else changed to `redis://localhost:6379/0`. |
+| `cors.origin` | Array of additional allowed request origin urls, for example `["http://localhost"]`. The URL specified with the `url` config option is inserted by default. |
+| `mail.enabled` | Whether sending emails and related functionality (e.g. password resets) is enabled. |
+| `mail.from` | The from field for mails sent from backend. By default, this is configured based on config options `sitename` and `url`. `{sitename} <noreply@{url.host}>` is the default format. |
+| `mail.smtp.host` | SMTP Host |
+| `mail.smtp.port` | SMTP Port |
+| `mail.smtp.auth.user` | SMTP Username |
+| `mail.smtp.auth.pass` | SMTP Password |
+| `mail.smtp.secure` | Whether SMTP is using TLS/SSL. |
+| `redis` | Redis connection object. |
+| `redis.url` | Should be left as default for Docker installations, otherwise it can be changed to for example `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. |
+| `mongo.user` | MongoDB username. |
+| `mongo.password` | MongoDB password. |
+| `mongo.host` | MongoDB host. |
+| `mongo.port` | MongoDB port. |
+| `mongo.database` | MongoDB database name. |
 | `blacklistedCommunityStationNames` | Array of blacklisted community station names. |
 | `featuredPlaylists` | Array of featured playlist id's. Playlist privacy must be public. |
+| `messages.accountRemoval` | Message to display to users when they request their account to be removed. |
+| `siteSettings.christmas` | Whether to enable christmas theme. |
+| `footerLinks` | Add custom links to footer by specifying `"title": "url"`, e.g. `"GitHub": "https://github.com/Musare/Musare"`. You can disable about, team and news links (but not the pages themselves) by setting them to false, e.g. `"about": false`. |
+| `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
+| `registrationDisabled` | If set to `true`, users can't register accounts. |
+| `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. Requires mail to be enabled and configured. |
 | `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.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 capture 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`. |
+| `debug.git.remote` | Log/expose the current Git repository's remote. |
+| `debug.git.remoteUrl` | Log/expose the current Git repository's remote URL. |
+| `debug.git.branch` | Log/expose the current Git repository's branch. |
+| `debug.git.latestCommit` | Log/expose the current Git repository's latest commit hash. |
+| `debug.git.latestCommitShort` | Log/expose the current Git repository's latest commit hash (short). |
+| `debug.version` | Log/expose the current package.json version. |
 | `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. |
-| `experimental.weight_stations` | Experimental option to use weights when autofilling stations, looking at the weight[X] tag for songs. If true, enables for all stations using default tag name. If an object, key msut b station id's, and if true enables for those stations with default weight tag name, or you can specify an alternative tag name by setting the value to a string. |
-| `experimental.weight_stations` | Experimental option to use weights when autofilling stations, looking at the weight[X] tag for songs. Must be an object, key must be station id's, value can be true or a string. If true, it uses tag name `weight`. If a string, it uses that string as the tag name. |
+| `experimental.weight_stations` | Experimental option to use weights when autofilling stations, looking at the weight[X] tag for songs. Must be an object, key must be station id's, value can be either `true` or a string. If `true`, it uses tag name `weight`. If a string, it uses that string as the tag name. |
 | `experimental.queue_autofill_skip_last_x_played` | Experimental option to not autofill songs that were played recently. Must be an object, key must be station id's, value must be a number. The number equals how many songs it will consider recent and use when checking if it can autofill. |
-| `experimental.queue_add_before_autofilled` | Experimental option to have requested songs in queue appear before autofilled songs, based on the autofill number. Must be true or an object. If true, it's enabled for all stations. If an object, key must be station id's, value must be true to enable for that station. |
-| `experimental.disable_youtube_search` | Experimental option to disable YouTube search on the backend. If true, this option is enabled. |
-
-## 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.hmrClientPort` | Should be the port on which the frontend will be accessible from, usually port `80`, or `443` if using SSL. Only used when running in dev mode. |
-| `devServer.port` | Should be the port where Vite's 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. Only used when running in dev mode. |
-| `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.logo_small` | Path to the small white logo image, by default it is `/assets/favicon/mstile-144x144.png`. |
-| `siteSettings.sitename` | Should be the name of the site. |
-| `siteSettings.footerLinks` | Add custom links to footer by specifying `"title": "url"`, e.g. `"GitHub": "https://github.com/Musare/Musare"`. You can disable about, team and news links (but not the pages themselves) by setting them to false, e.g. `"about": false`. |
-| `siteSettings.christmas` | Whether to enable christmas theming. |
-| `siteSettings.registrationDisabled` | If set to true, users can't register accounts. |
-| `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 }`. |
-| `debug.git.remote` | Allow the website/users to view the current Git repository's remote. [^1] |
-| `debug.git.remoteUrl` | Allow the website/users to view the current Git repository's remote URL. [^1] |
-| `debug.git.branch` | Allow the website/users to view the current Git repository's branch. [^1] |
-| `debug.git.latestCommit` | Allow the website/users to view the current Git repository's latest commit hash. [^1] |
-| `debug.git.latestCommitShort` | Allow the website/users to view the current Git repository's latest commit hash (short). [^1] |
-| `debug.version` | Allow the website/users to view the current package.json version. [^1] |
-| `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. |
-| `experimental.changable_listen_mode` | Experimental option to allows users on stations to close the player. If true, enables for all stations. If an array of station id's, enable for just those stations. |
-| `experimental.disable_youtube_search` | Experimental option to disable YouTube search on the frontend. If true, this option is enabled. |
+| `experimental.queue_add_before_autofilled` | Experimental option to have requested songs in queue appear before autofilled songs, based on the autofill number. Must be `true` or an object. If `true`, it's enabled for all stations. If an object, key must be a station's id, and value must be `true` to enable for that station. |
+| `experimental.disable_youtube_search` | Experimental option to disable YouTube search. |
+| `experimental.registration_email_whitelist` | Experimental option to limit registration to users with an email matching any regex pattern defined in an array. |
+| `experimental.changable_listen_mode` | Experimental option to allows users on stations to close the player whilst maintaing ActivityWatch functionality and users list playback state. If `true`, it's enabled for all stations. If it's an array of station id's, it's enabled for just those stations. |
 | `experimental.media_session` | Experimental option to enable media session functionality. |
-
-[^1]: Requires a frontend restart to update. The data will be available from the frontend console and by the frontend code.
-
-## 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. |
-| `RESTART_POLICY` | Restart policy for docker containers, values can be found [here](https://docs.docker.com/config/containers/start-containers-automatically/). |
-| `CONTAINER_MODE` | Should be either `prod` or `dev`.  |
-| `DOCKER_COMMAND` | Should be either `docker` or `podman`.  |
-| `BACKEND_HOST` | Backend container host. |
-| `BACKEND_PORT` | Backend container port. |
-| `BACKEND_MODE` | Should be either `prod` or `dev`. |
-| `BACKEND_DEBUG` | Should be either `true` or `false`. If enabled backend will await debugger connection and trigger to start. |
-| `BACKEND_DEBUG_PORT` | Backend container debug port, if enabled. |
-| `FRONTEND_HOST` | Frontend container host. |
-| `FRONTEND_PORT` | Frontend container port. |
-| `FRONTEND_MODE` | Should be either `prod` or `dev`. |
-| `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. |
-| `MONGO_DATA_LOCATION` | The location where MongoDB stores its data. Usually the `.db` folder inside the `Musare` folder. |
-| `MONGO_VERSION` | The MongoDB version to use for scripts and docker-compose. Must be numerical. Currently supported MongoDB versions are 4.0+. Always backup before changing this value. |
-| `REDIS_HOST` | Redis container host. |
-| `REDIS_PORT` | Redis container port. |
-| `REDIS_PASSWORD` | Redis password. |
-| `REDIS_DATA_LOCATION` | The location where Redis stores its data. Usually the `.redis` folder inside the `Musare` folder. |
-| `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`. |
+| `experimental.station_history` | Experimental feature to store and display songs played in a station, in addition to allowing preventing playing recently played songs. |
+| `experimental.soundcloud` | Experimental SoundCloud integration. |
+| `experimental.spotify` | Experimental Spotify integration. |
 
 ## Docker-compose override
 
 You may want to override the docker-compose files in some specific cases.
 For this, you can create a `docker-compose.override.yml` file.
 
-### Run backend on its own domain
-
-One example usecase for the override is to expose the backend port so you can
-run it separately from the frontend. An example file for this is as follows:
+For example, to expose the backend port:
 
 ```yml
 services:

+ 10 - 33
.wiki/Installation.md

@@ -11,36 +11,16 @@ To update an existing installation please see [Upgrading](./Upgrading.md).
 
 - [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/Musare.git`
 2. `cd Musare`
-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).
-
----
+3. [Configure](./Configuration.md)
+4. `./musare.sh build`
+5. `./musare.sh start`
+6. **(optional)** Register a new user on the website and grant the admin role
+by running `./musare.sh admin add USERNAME`
 
 ## Non-Docker
 
@@ -59,13 +39,10 @@ by running `./musare.sh admin add USERNAME`.
 2. `cd Musare`
 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. `cd frontend && npm install && cd ..`
-8. `cd backend && npm install && cd ..`
-9. Start services
+5. [Configure](./Configuration.md)
+6. `cd frontend && npm install && cd ..`
+7. `cd backend && npm install && cd ..`
+8. Start services
     - **Linux**
         1. Execute `systemctl start redis mongod`
         2. Execute `cd frontend && npm run dev` and
@@ -77,7 +54,7 @@ and configure as per [Configuration](./Configuration.md#Frontend)
             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.
-10. **(optional)** Register a new user on the website and grant the admin role
+9. **(optional)** Register a new user on the website and grant the admin role
 by running the following in the mongodb shell.
 
     ```bash

+ 15 - 17
.wiki/Upgrading.md

@@ -11,24 +11,23 @@ To install a new instance please see [Installation](./Installation.md).
 1. Make a backup! `./musare.sh backup`
 2. Execute `./musare.sh update`. If an update requires any configuration changes
 or database migrations, you will be notified.
-    - To update configuration compare example configs against your own and
+    - To update configuration, compare the example config against your own and
     add/update/remove any properties as needed. For more information on
-    properties see [Configuration](./Configuration.md). Frontend and backend
+    properties see [Configuration](./Configuration.md). Backend
     configuration updates always update the `configVersion` property.
-        - Backend, compare `backend/config/template.json` against
-        `backend/config/default.json`.
-        - Frontend, compare `frontend/dist/config/template.json` against
-        `frontend/dist/config/default.json`.
-        - Environment, compare `.env.example` against `.env`.
+        - Backend, compare `backend/config/default.json` against
+        `backend/config/local.json`
+        - Environment, compare `.env.example` against `.env`
     - To migrate database;
         - `./musare.sh stop backend`
-        - Set `migration` to `true` in  `backend/config/default.json`
-        - `./musare.sh start backend`.
+        - Set `migration` to `true` in  `backend/config/local.json`
+        - `./musare.sh start backend`
         - Follow backend logs and await migration completion notice
         `./musare.sh attach backend`.
+        You can also look at logs with `./musare.sh logs backend`
         - `./musare.sh stop backend`
-        - Set `migration` to `false` in  `backend/config/default.json`
-        - `./musare.sh start backend`.
+        - Set `migration` to `false` in  `backend/config/local.json`
+        - `./musare.sh start backend`
 
 ---
 
@@ -41,18 +40,17 @@ or database migrations, you will be notified.
 3. `git pull`
 4. `cd frontend && npm install`
 5. `cd ../backend && npm install`
-6. Compare example configs against your own and add/update/remove any properties
+6. Compare the example config against your own and add/update/remove any properties
 as needed. For more information on properties see [Configuration](./Configuration.md).
-Frontend and backend configuration updates always update the `configVersion` property.
-    - Backend, compare `backend/config/template.json` against `backend/config/default.json`.
-    - Frontend, compare `frontend/dist/config/template.json` against `frontend/dist/config/default.json`.
+Backend configuration updates always update the `configVersion` property.
+    - Backend, compare `backend/config/default.json` against `backend/config/local.json`.
 7. Start MongoDB and Redis services.
 8. Run database migration;
-    - Set `migration` to `true` in  `backend/config/default.json`
+    - Set `migration` to `true` in  `backend/config/local.json`
     - Start backend service.
     - Follow backend logs and await migration completion notice.
     - Stop backend service.
-    - Set `migration` to `false` in  `backend/config/default.json`
+    - Set `migration` to `false` in  `backend/config/local.json`
 9. Start backend and frontend services.
 
 ## Upgrade/downgrade MongoDB

+ 1 - 1
.wiki/Value_Formats.md

@@ -41,7 +41,7 @@ Every input needs validation, below is the required formatting of each value.
 - **Playlist**
   - Display Name
     - Description: Any ASCII character.
-    - Length: From 1 to 32 characters.
+    - Length: From 1 to 64 characters.
     - Regex: ```/^[\x00-\x7F]+$/```
 - **Song**
   - Title

+ 121 - 0
CHANGELOG.md

@@ -1,5 +1,126 @@
 # Changelog
 
+## [v3.10.0] - 2023-05-21
+
+This release includes all changes from v3.10.0-rc1, v3.10.0-rc2 and v3.10.0-rc3,
+in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Fixed
+
+- fix: Stations created with empty song object
+
+## [v3.10.0-rc3] - 2023-05-14
+
+This release includes all changes from v3.10.0-rc1 and v3.10.0-rc2,
+in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Finished basic implementation of showing jobs on statistics admin page
+- feat: Exclude disliked songs from being autorequested,
+if "Automatically vote to skip disliked songs" preference is enabled
+
+### Changed
+
+- refactor: Increased playlist displayname max length to 64
+- refactor: Improved song thumbnail fallback logic
+
+### Fixed
+
+- fix: SoundCloud player not destroyed properly
+- fix: getPlayerState is not a function thrown in browser console
+- fix: Activity items `<youtubeId>` payload message not migrated
+- fix: Import playlist from file never finishes
+- fix: Tippy can be null and throw an error in console
+
+## [v3.10.0-rc2] - 2023-04-30
+
+This release includes all changes from v3.10.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: SoundcloudPlayer component
+- feat: Add extra user station state for unavailable media
+- feat: Add support for SoundCloud media track state in user station state
+
+### Changed
+
+- refactor: Change youtubeId to mediaSource in some GET_DATA special properties
+- refactor: Improve login, register and reset password form and autocompletion
+- refactor: Improve SoundCloud unavailable track handling, to match YouTube
+
+### Fixed
+
+- fix: Unable to add YouTube search result song to station queue
+- fix: Large Docker build context
+- fix: Some jobs available in run admin page job dropdowns did not return result
+- fix: Skipping station can pause/resume local station
+- fix: Cookie expiry not refreshed causes issues in some browsers
+- fix: Autofilling playlist skips station in some cases
+- fix: Unable to open View Media modal from SoundCloud tracks admin page
+- fix: Password managers submitting login form before inputs filled
+- fix: Song unavailable toast does not automatically disappear
+- fix: Don't count participating users in vote to skip users
+(unless they vote-skipped themselves)
+- fix: SoundCloud player doesn't work correctly twice on the same page
+
+## [v3.10.0-rc1] - 2023-04-15
+
+### **Breaking Changes**
+
+This release includes breaking changes to our configuration handling.
+The `backend/config/default.json` previously used as the means of configuring
+the backend is now tracked and serves as the source of all default values.
+
+Before updating or pulling changes please make a full backup and rename or
+remove the `backend/config/default.json` file to avoid it being overwritten.
+Please refer to the [Configuration documentation](.wiki/Configuration.md)
+for more information on how you should now configure the application.
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Import playlist media from export file
+- feat: Added additional station settings to configure autorequest functionality:
+  - Allow autorequest toggle
+  - Per user autorequest limit
+  - Disallow recent functionality toggle
+  (requires experimental station history to be enabled)
+  - Disallow recent amount
+  (requires experimental station history to be enabled)
+- feat: Display count of station users and autorequesting playlist in tags
+within tabs
+- feat: Added experimental station history
+- feat: Store and display user playback state in station users tab
+- feat: Added experimental option to restrict registrations to emails matching
+specific regex patterns
+- feat: Allow DJ's in official stations
+- feat: Display the reason media was added to queue
+- feat: Added "Add song to queue" button to station queue
+- feat: Added link to a user's own playlists in header/navbar
+- feat: Added experimental support for SoundCloud media
+- feat: Added experimental support for parsing and converting Spotify media
+- feat: Added experimental support for storing YouTube channel API data
+(requires experimental Spotify integration to be enabled)
+
+### Changed
+
+- refactor: Replace youtubeId usage with mediaSource internally
+- refactor: Serve application configuration from backend
+  - Replaced frontend config with both environment variables and backend config
+  - Define default backend config values in `default.json` and
+  overwrite with `local.json` files or environment variables
+- style: Changed font to Nunito
+
+### Fixed
+
+- fix: Git debug not functional in production
+- fix: Successfully saving station settings via Manage Station does not show a toast
+
 ## [v3.9.0] - 2023-01-01
 
 This release includes all changes from v3.9.0-rc1, in addition to the following.

+ 0 - 3
backend/.dockerignore

@@ -1,3 +0,0 @@
-node_modules/
-Dockerfile
-config/default.json

+ 1 - 2
backend/.eslintignore

@@ -1,4 +1,3 @@
 .git/
-.parent_git/
 build/
-node_modules/
+node_modules/

+ 0 - 1
backend/.prettierignore

@@ -1,4 +1,3 @@
 .git/
-.parent_git/
 build/
 node_modules/

+ 9 - 6
backend/Dockerfile

@@ -3,23 +3,26 @@ FROM node:18 AS backend_node_modules
 RUN mkdir -p /opt/app
 WORKDIR /opt/app
 
-COPY package.json /opt/app/package.json
-COPY package-lock.json /opt/app/package-lock.json
+COPY backend/package.json backend/package-lock.json /opt/app/
 
 RUN npm install --silent
 
 FROM node:18 AS musare_backend
 
-ARG BACKEND_MODE=prod
+ARG CONTAINER_MODE=production
+ARG BACKEND_MODE=production
+ENV CONTAINER_MODE=${CONTAINER_MODE}
 ENV BACKEND_MODE=${BACKEND_MODE}
 
-RUN mkdir -p /opt/app /opt/types
+RUN mkdir -p /opt/.git /opt/types /opt/app
 WORKDIR /opt/app
 
-COPY . /opt/app
+COPY .git /opt/.git
+COPY types /opt/types
+COPY backend /opt/app
 COPY --from=backend_node_modules /opt/app/node_modules node_modules
 
-RUN bash -c '([[ "${BACKEND_MODE}" == "dev" ]] && exit 0) || npm run build'
+RUN bash -c '([[ "${BACKEND_MODE}" == "development" ]] && exit 0) || npm run build'
 
 RUN chmod u+x entrypoint.sh
 

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

@@ -57,7 +57,6 @@ export default class Timer {
 
 	/**
 	 * Gets the amount of time the timer has been paused
-	 *
 	 * @returns {Date} - the amount of time the timer has been paused
 	 */
 	getTimePaused() {

+ 20 - 0
backend/config/custom-environment-variables.json

@@ -0,0 +1,20 @@
+{
+	"sitename": "MUSARE_SITENAME",
+	"redis": {
+		"password": "REDIS_PASSWORD"
+	},
+	"mongo": {
+		"user": "MONGO_USER_USERNAME",
+		"password": "MONGO_USER_PASSWORD"
+	},
+	"debug": {
+		"git": {
+			"remote": "MUSARE_DEBUG_GIT_REMOTE",
+			"remoteUrl": "MUSARE_DEBUG_GIT_REMOTE_URL",
+			"branch": "MUSARE_DEBUG_GIT_BRANCH",
+			"latestCommit": "MUSARE_DEBUG_GIT_LATEST_COMMIT",
+			"latestCommitShort": "MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT"
+		},
+		"version": "MUSARE_DEBUG_VERSION"
+	}
+}

+ 154 - 0
backend/config/default.json

@@ -0,0 +1,154 @@
+{
+	"configVersion": 12,
+	"migration": false,
+	"secret": "default",
+	"port": 8080,
+	"url": {
+		"host": "localhost",
+		"secure": false
+	},
+	"cookie": "SID",
+	"sitename": "Musare",
+	"apis": {
+		"youtube": {
+			"key": "",
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2,
+			"quotas": [
+				{
+					"type": "QUERIES_PER_DAY",
+					"title": "Queries Per Day",
+					"limit": 10000
+				},
+				{
+					"type": "QUERIES_PER_MINUTE",
+					"title": "Queries Per Minute",
+					"limit": 1800000
+				},
+				{
+					"type": "QUERIES_PER_100_SECONDS",
+					"title": "Queries Per 100 Seconds",
+					"limit": 3000000
+				}
+			],
+			"maxPlaylistPages": 20
+		},
+		"spotify": {
+			"enabled": false,
+			"clientId": "",
+			"clientSecret": "",
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2
+		},
+		"soundcloud": {
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2
+		},
+		"recaptcha": {
+			"enabled": false,
+			"key": "",
+			"secret": ""
+		},
+		"github": {
+			"enabled": false,
+			"client": "",
+			"secret": "",
+			"redirect_uri": ""
+		},
+		"discogs": {
+			"enabled": false,
+			"client": "",
+			"secret": ""
+		}
+	},
+	"cors": {
+		"origin": []
+	},
+	"mail": {
+		"enabled": false,
+		"from": "",
+		"smtp": {
+			"host": "",
+			"port": 587,
+			"auth": {
+				"user": "",
+				"pass": ""
+			},
+			"secure": false
+		}
+	},
+	"redis": {
+		"url": "redis://redis:6379/0",
+		"password": "PASSWORD"
+	},
+	"mongo": {
+		"user": "musare",
+		"password": "OTHER_PASSWORD_HERE",
+		"host": "mongo",
+		"port": 27017,
+		"database": "musare"
+	},
+	"blacklistedCommunityStationNames": ["musare"],
+	"featuredPlaylists": [],
+	"messages": {
+		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
+	},
+	"christmas": false,
+	"footerLinks": {
+		"about": true,
+		"team": true,
+		"news": true,
+		"GitHub": "https://github.com/Musare/Musare"
+	},
+	"shortcutOverrides": {},
+	"registrationDisabled": false,
+	"sendDataRequestEmails": true,
+	"skipConfigVersionCheck": false,
+	"skipDbDocumentsVersionCheck": false,
+	"debug": {
+		"stationIssue": false,
+		"traceUnhandledPromises": false,
+		"captureJobs": [],
+		"git": {
+			"remote": false,
+			"remoteUrl": false,
+			"branch": true,
+			"latestCommit": true,
+			"latestCommitShort": true
+		},
+		"version": true
+	},
+	"defaultLogging": {
+		"hideType": ["INFO"],
+		"blacklistedTerms": []
+	},
+	"customLoggingPerModule": {
+		"migration": {
+			"hideType": [],
+			"blacklistedTerms": [
+				"Ran job",
+				"Running job",
+				"Queuing job",
+				"Pausing job",
+				"is queued",
+				"is re-queued",
+				"Requeing"
+			]
+		}
+	},
+	"experimental": {
+		"weight_stations": {},
+		"queue_autofill_skip_last_x_played": {},
+		"queue_add_before_autofilled": [],
+		"disable_youtube_search": false,
+		"registration_email_whitelist": false,
+		"changable_listen_mode": [],
+		"media_session": false,
+		"station_history": false,
+		"soundcloud": false,
+		"spotify": false
+	}
+}

+ 20 - 121
backend/config/template.json

@@ -1,132 +1,31 @@
 {
-	"mode": "development",
+	"configVersion": 12,
 	"migration": false,
-	"secret": "default",
-	"domain": "http://localhost",
-	"frontendPort": 80,
-	"serverDomain": "http://localhost/backend",
-	"serverPort": 8080,
-	"registrationDisabled": true,
-	"sendDataRequestEmails": true,
+	"secret": "CHANGE_ME",
+	"url": {
+		"host": "my.domain",
+		"secure": true
+	},
 	"apis": {
 		"youtube": {
-			"key": "",
-			"rateLimit": 500,
-			"requestTimeout": 5000,
-			"retryAmount": 2,
-			"quotas": [
-				{
-					"type": "QUERIES_PER_DAY",
-					"title": "Queries Per Day",
-					"limit": 10000
-				},
-				{
-					"type": "QUERIES_PER_MINUTE",
-					"title": "Queries Per Minute",
-					"limit": 1800000
-				},
-				{
-					"type": "QUERIES_PER_100_SECONDS",
-					"title": "Queries Per 100 Seconds",
-					"limit": 3000000
-				}
-			]
-		},
-		"recaptcha": {
-			"secret": "",
-			"enabled": false
-		},
-		"github": {
-			"enabled": false,
-			"client": "",
-			"secret": "",
-			"redirect_uri": ""
+			"key": "CHANGE_ME"
 		},
 		"discogs": {
-			"client": "",
-			"secret": "",
-			"enabled": false
+			"enabled": true,
+			"client": "CHANGE_ME",
+			"secret": "CHANGE_ME"
 		}
 	},
-	"cors": {
-		"origin": [
-			"http://localhost"
-		]
-	},
-	"smtp": {
-		"host": "smtp.mailgun.org",
-		"port": 587,
-		"auth": {
-			"user": "",
-			"pass": ""
-		},
-		"secure": false,
-		"enabled": false
-	},
 	"mail": {
-		"from": "Musare <noreply@localhost>"
-	},
-	"redis": {
-		"url": "redis://redis:6379/0",
-		"password": "PASSWORD"
-	},
-	"mongo": {
-		"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
-	},
-	"cookie": {
-		"domain": "localhost",
-		"secure": false,
-		"SIDname": "SID"
-	},
-	"blacklistedCommunityStationNames": [
-		"musare"
-	],
-	"featuredPlaylists": [],
-	"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"
-			]
+		"enabled": true,
+		"smtp": {
+			"host": "smtp.my.domain",
+			"port": 587,
+			"auth": {
+				"user": "CHANGE_ME",
+				"pass": "CHANGE_ME"
+			},
+			"secure": true
 		}
-	},
-	"configVersion": 11,
-	"experimental": {
-		"weight_stations": {
-			"STATION_ID": true,
-			"STATION_ID_2": "alternative_weight"
-		},
-		"queue_autofill_skip_last_x_played": {
-			"STATION_ID": 5,
-			"STATION_ID_2": 10
-		},
-		"queue_add_before_autofilled": [
-			"STATION_ID"
-		],
-		"disable_youtube_search": true
 	}
-}
+}

+ 14 - 32
backend/core.js

@@ -51,7 +51,6 @@ class Queue {
 
 	/**
 	 * Returns the amount of jobs in the queue.
-	 *
 	 * @returns {number} - amount of jobs in queue
 	 */
 	lengthQueue() {
@@ -60,7 +59,6 @@ class Queue {
 
 	/**
 	 * Returns the amount of running jobs.
-	 *
 	 * @returns {number} - amount of running jobs
 	 */
 	lengthRunning() {
@@ -69,7 +67,6 @@ class Queue {
 
 	/**
 	 * Returns the amount of running jobs.
-	 *
 	 * @returns {number} - amount of running jobs
 	 */
 	lengthPaused() {
@@ -78,7 +75,6 @@ class Queue {
 
 	/**
 	 * 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
@@ -92,7 +88,6 @@ class Queue {
 
 	/**
 	 * Removes a job currently running from the queue.
-	 *
 	 * @param {object} job - the job to be removed
 	 */
 	removeRunningJob(job) {
@@ -101,7 +96,6 @@ class Queue {
 
 	/**
 	 * Pauses a job currently running from the queue.
-	 *
 	 * @param {object} job - the job to be pauses
 	 */
 	pauseRunningJob(job) {
@@ -118,7 +112,6 @@ class Queue {
 
 	/**
 	 * 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) {
@@ -157,7 +150,6 @@ class Queue {
 
 	/**
 	 * Handles a task, calling the handleTaskFunction provided in the constructor
-	 *
 	 * @param {object} task - the task to be handled
 	 */
 	_handleTask(task) {
@@ -198,7 +190,6 @@ class Job {
 
 	/**
 	 * Adds a child job to this job
-	 *
 	 * @param {object} childJob - the child job
 	 */
 	addChildJob(childJob) {
@@ -207,7 +198,6 @@ class Job {
 
 	/**
 	 * Sets the job status
-	 *
 	 * @param {string} status - the new status
 	 */
 	setStatus(status) {
@@ -216,7 +206,6 @@ class Job {
 
 	/**
 	 * Sets the task for a job
-	 *
 	 * @param {string} task - the job task
 	 */
 	setTask(task) {
@@ -225,7 +214,6 @@ class Job {
 
 	/**
 	 * Returns the UUID of the job, allowing you to compare jobs with toString
-	 *
 	 * @returns {string} - the job's UUID/uniqueId
 	 */
 	toString() {
@@ -234,7 +222,6 @@ class Job {
 
 	/**
 	 * 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) {
@@ -243,7 +230,6 @@ class Job {
 
 	/**
 	 * 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) {
@@ -259,7 +245,6 @@ class Job {
 
 	/**
 	 * 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) {
@@ -267,18 +252,25 @@ class Job {
 		this.module.log(...args);
 	}
 
+	/**
+	 * Set whether this job is a long job.
+	 */
 	keepLongJob() {
 		this.longJob = true;
 	}
 
+	/**
+	 * Forget long job.
+	 */
 	forgetLongJob() {
 		this.longJob = false;
 		this.module.moduleManager.jobManager.removeJob(this);
 	}
 
 	/**
-	 * 
+	 * Update and emit progress of job
 	 * @param {data} data - Data to publish upon progress
+	 * @param {boolean} notALongJob - Whether job is not a long job
 	 */
 	publishProgress(data, notALongJob) {
 		if (this.longJob || notALongJob) {
@@ -289,7 +281,7 @@ class Job {
 					this.lastProgressData = data;
 
 					if (data.status === "update") {
-						if ((Date.now() - this.lastProgressTime) > 1000) {
+						if (Date.now() - this.lastProgressTime > 1000) {
 							this.lastProgressTime = Date.now();
 						} else {
 							if (this.lastProgressTimeout) clearTimeout(this.lastProgressTimeout);
@@ -303,11 +295,11 @@ class Job {
 					} else if (data.status === "success" || data.status === "error")
 						if (this.lastProgressTimeout) clearTimeout(this.lastProgressTimeout);
 
-					if (data.title)	this.longJobTitle = data.title;
+					if (data.title) this.longJobTitle = data.title;
 
 					this.onProgress.emit("progress", data);
 				}
-			} else this.log("Progress published, but no onProgress specified.")
+			} else this.log("Progress published, but no onProgress specified.");
 		} else {
 			this.parentJob.publishProgress(data);
 		}
@@ -323,7 +315,6 @@ class MovingAverageCalculator {
 
 	/**
 	 * Updates the mean average
-	 *
 	 * @param {number} newValue - the new time it took to complete a job
 	 */
 	update(newValue) {
@@ -334,7 +325,6 @@ class MovingAverageCalculator {
 
 	/**
 	 * Returns the mean average
-	 *
 	 * @returns {number} - returns the mean average
 	 */
 	get mean() {
@@ -378,7 +368,6 @@ export default class CoreClass {
 
 	/**
 	 * Sets the status of a module
-	 *
 	 * @param {string} status - the new status of a module
 	 */
 	setStatus(status) {
@@ -390,7 +379,6 @@ export default class CoreClass {
 
 	/**
 	 * Returns the status of a module
-	 *
 	 * @returns {string} - the status of a module
 	 */
 	getStatus() {
@@ -399,7 +387,6 @@ export default class CoreClass {
 
 	/**
 	 * Changes the current stage of a module
-	 *
 	 * @param {string} stage - the new stage of a module
 	 */
 	setStage(stage) {
@@ -408,7 +395,6 @@ export default class CoreClass {
 
 	/**
 	 * Returns the current stage of a module
-	 *
 	 * @returns {string} - the current stage of a module
 	 */
 	getStage() {
@@ -435,7 +421,6 @@ export default class CoreClass {
 
 	/**
 	 * 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) {
@@ -494,7 +479,6 @@ export default class CoreClass {
 
 	/**
 	 * 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
@@ -582,7 +566,6 @@ export default class CoreClass {
 
 	/**
 	 * UNKNOWN
-	 *
 	 * @param {object} moduleManager - UNKNOWN
 	 */
 	setModuleManager(moduleManager) {
@@ -591,7 +574,6 @@ export default class CoreClass {
 
 	/**
 	 * 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
@@ -615,7 +597,8 @@ export default class CoreClass {
 					this[job.name]
 						.apply(job, [job.payload])
 						.then(response => {
-							if (!options.isQuiet) this.log("INFO", `Ran job ${job.name} (${job.toString()}) successfully`);
+							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;
@@ -689,8 +672,7 @@ export default class CoreClass {
 							}
 							resolve();
 						});
-				else
-					this.log("ERROR", `Job not found! ${job.name}`)
+				else this.log("ERROR", `Job not found! ${job.name}`);
 			} else {
 				this.log(
 					"INFO",

+ 7 - 3
backend/entrypoint.sh

@@ -1,8 +1,8 @@
 #!/bin/bash
 
-if [[ "${CONTAINER_MODE}" == "dev" ]]; then
+if [[ "${CONTAINER_MODE}" == "development" ]]; then
     npm install --silent
-    if [[ "${BACKEND_MODE}" == "prod" ]]; then
+    if [[ "${BACKEND_MODE}" == "production" ]]; then
         npm run build
     fi
 fi
@@ -13,4 +13,8 @@ else
     export INSPECT_BRK=""
 fi
 
-npm run "${BACKEND_MODE}"
+if [[ "${BACKEND_MODE}" == "production" ]]; then
+    npm run prod
+else
+    npm run dev
+fi

+ 62 - 46
backend/index.js

@@ -4,9 +4,10 @@ import util from "util";
 import config from "config";
 import fs from "fs";
 
-import package_json from "./package.json" assert { type: "json" };
+import * as readline from "node:readline";
+import packageJson from "./package.json" assert { type: "json" };
 
-const REQUIRED_CONFIG_VERSION = 11;
+const REQUIRED_CONFIG_VERSION = 12;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {
@@ -32,32 +33,37 @@ console.log = (...args) => {
 	if (!blacklisted) oldConsole.log.apply(null, args);
 };
 
-const MUSARE_VERSION = package_json.version;
+const MUSARE_VERSION = packageJson.version;
 
 const printVersion = () => {
 	console.log(`Musare version: ${MUSARE_VERSION}.`);
 
 	try {
-		const head_contents = fs.readFileSync(".parent_git/HEAD").toString().replaceAll("\n", "");
-		const branch = new RegExp("ref: refs/heads/([A-Za-z0-9_.-]+)").exec(head_contents)[1];
-		const config_contents = fs.readFileSync(".parent_git/config").toString().replaceAll("\t", "").split("\n");
-		const remote = new RegExp("remote = (.+)").exec(config_contents[config_contents.indexOf(`[branch "${branch}"]`) + 1])[1];
-		const remote_url = new RegExp("url = (.+)").exec(config_contents[config_contents.indexOf(`[remote "${remote}"]`) + 1])[1];
-		const latest_commit = fs.readFileSync(`.parent_git/refs/heads/${branch}`).toString().replaceAll("\n", "");
-		const latest_commit_short = latest_commit.substr(0, 7);
-
-		console.log(`Git branch: ${remote}/${branch}. Remote url: ${remote_url}. Latest commit: ${latest_commit} (${latest_commit_short}).`);
-	} catch(e) {
+		let gitFolder = null;
+		if (fs.existsSync("../.git/HEAD")) gitFolder = "../.git";
+		else if (fs.existsSync(".git/HEAD")) gitFolder = ".git";
+
+		if (gitFolder) {
+			const headContents = fs.readFileSync(`${gitFolder}/HEAD`).toString().replaceAll("\n", "");
+			const [, branch] = /ref: refs\/heads\/([A-Za-z0-9_.-]+)/.exec(headContents);
+			const configContents = fs.readFileSync(`${gitFolder}/config`).toString().replaceAll("\t", "").split("\n");
+			const [, remote] = /remote = (.+)/.exec(configContents[configContents.indexOf(`[branch "${branch}"]`) + 1]);
+			const [, remoteUrl] = /url = (.+)/.exec(configContents[configContents.indexOf(`[remote "${remote}"]`) + 1]);
+			const latestCommit = fs.readFileSync(`${gitFolder}/refs/heads/${branch}`).toString().replaceAll("\n", "");
+			const latestCommitShort = latestCommit.substr(0, 7);
+
+			console.log(
+				`Git branch: ${remote}/${branch}. Remote url: ${remoteUrl}. Latest commit: ${latestCommit} (${latestCommitShort}).`
+			);
+		} else console.log("Could not find .git folder.");
+	} catch (e) {
 		console.log(`Could not get Git info: ${e.message}.`);
 	}
-}
+};
 
 printVersion();
 
-if (
-	(!config.has("configVersion") || config.get("configVersion") !== REQUIRED_CONFIG_VERSION) &&
-	!(config.has("skipConfigVersionCheck") && config.get("skipConfigVersionCheck"))
-) {
+if (config.get("configVersion") !== REQUIRED_CONFIG_VERSION && !config.get("skipConfigVersionCheck")) {
 	console.log(
 		"CONFIG VERSION IS WRONG. PLEASE UPDATE YOUR CONFIG WITH THE HELP OF THE TEMPLATE FILE AND THE README FILE."
 	);
@@ -66,6 +72,7 @@ if (
 
 if (config.debug && config.debug.traceUnhandledPromises === true) {
 	console.log("Enabled trace-unhandled/register");
+	// eslint-disable-next-line import/no-extraneous-dependencies
 	import("trace-unhandled/register");
 }
 
@@ -77,7 +84,6 @@ class JobManager {
 
 	/**
 	 * Adds a job to the list of jobs
-	 *
 	 * @param {object} job - the job object
 	 */
 	addJob(job) {
@@ -87,7 +93,6 @@ class JobManager {
 
 	/**
 	 * Removes a job from the list of running jobs (after it's completed)
-	 *
 	 * @param {object} job - the job object
 	 */
 	removeJob(job) {
@@ -97,7 +102,6 @@ class JobManager {
 
 	/**
 	 * Returns detail about a job via a identifier
-	 *
 	 * @param {string} uuid - the job identifier
 	 * @returns {object} - the job object
 	 */
@@ -130,7 +134,6 @@ class ModuleManager {
 
 	/**
 	 * 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) {
@@ -168,7 +171,6 @@ class ModuleManager {
 
 	/**
 	 * Called when a module is initialised
-	 *
 	 * @param {object} module - the module object/class
 	 */
 	onInitialize(module) {
@@ -188,7 +190,6 @@ class ModuleManager {
 
 	/**
 	 * Called when a module fails to initialise
-	 *
 	 * @param {object} module - the module object/class
 	 */
 	onFail(module) {
@@ -207,7 +208,6 @@ class ModuleManager {
 
 	/**
 	 * 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) {
@@ -262,6 +262,12 @@ if (!config.get("migration")) {
 	moduleManager.addModule("tasks");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");
+	if (config.get("experimental.soundcloud")) moduleManager.addModule("soundcloud");
+	if (config.get("experimental.spotify")) {
+		moduleManager.addModule("spotify");
+		moduleManager.addModule("musicbrainz");
+		moduleManager.addModule("wikidata");
+	}
 } else {
 	moduleManager.addModule("migration");
 }
@@ -270,7 +276,6 @@ moduleManager.initialize();
 
 /**
  * Prints a job
- *
  * @param {object} job - the job
  * @param {number} layer - the layer
  */
@@ -286,7 +291,6 @@ function printJob(job, layer) {
 
 /**
  * Prints a task
- *
  * @param {object} task - the task
  * @param {number} layer - the layer
  */
@@ -298,43 +302,53 @@ function printTask(task, layer) {
 	});
 }
 
-import * as readline from 'node:readline';
-
-var rl = readline.createInterface({
+const rl = readline.createInterface({
 	input: process.stdin,
 	output: process.stdout,
-	completer: function(command) {
+	completer(command) {
 		const parts = command.split(" ");
-		const commands = ["version", "lockdown", "status", "running ", "queued ", "paused ", "stats ", "jobinfo ", "runjob ", "eval "];
+		const commands = [
+			"version",
+			"lockdown",
+			"status",
+			"running ",
+			"queued ",
+			"paused ",
+			"stats ",
+			"jobinfo ",
+			"runjob ",
+			"eval "
+		];
 		if (parts.length === 1) {
 			const hits = commands.filter(c => c.startsWith(parts[0]));
 			return [hits.length ? hits : commands, command];
-		} else if (parts.length === 2) {
+		}
+		if (parts.length === 2) {
 			if (["queued", "running", "paused", "runjob", "stats"].indexOf(parts[0]) !== -1) {
 				const modules = Object.keys(moduleManager.modules);
-				const hits = modules.filter(module => module.startsWith(parts[1])).map(module => `${parts[0]} ${module}${parts[0] === "runjob" ? " " : ""}`);
-				return  [hits.length ? hits : modules, command];
-			} else {
-				return [];
+				const hits = modules
+					.filter(module => module.startsWith(parts[1]))
+					.map(module => `${parts[0]} ${module}${parts[0] === "runjob" ? " " : ""}`);
+				return [hits.length ? hits : modules, command];
 			}
-		} else if (parts.length === 3) {
+		}
+		if (parts.length === 3) {
 			if (parts[0] === "runjob") {
 				const modules = Object.keys(moduleManager.modules);
 				if (modules.indexOf(parts[1]) !== -1) {
 					const jobs = moduleManager.modules[parts[1]].jobNames;
-					const hits = jobs.filter(job => job.startsWith(parts[2])).map(job => `${parts[0]} ${parts[1]} ${job} `);
-					return  [hits.length ? hits : jobs, command];
+					const hits = jobs
+						.filter(job => job.startsWith(parts[2]))
+						.map(job => `${parts[0]} ${parts[1]} ${job} `);
+					return [hits.length ? hits : jobs, command];
 				}
-			} else {
-				return [];
 			}
-		} else {
-			return [];
 		}
+		return [];
 	}
 });
 
-rl.on("line",function(command) {
+rl.on("line", command => {
 	if (command === "version") {
 		printVersion();
 	}
@@ -426,7 +440,7 @@ rl.on("line",function(command) {
 		console.log(`Eval response: `, response);
 	}
 	if (command.startsWith("debug")) {
-		moduleManager.modules["youtube"].apiCalls.forEach(apiCall => {
+		moduleManager.modules.youtube.apiCalls.forEach(apiCall => {
 			// console.log(`${apiCall.date.toISOString()} - ${apiCall.url} - ${apiCall.quotaCost} - ${JSON.stringify(apiCall.params)}`);
 			console.log(apiCall);
 		});
@@ -434,3 +448,5 @@ rl.on("line",function(command) {
 });
 
 export default moduleManager;
+
+export { MUSARE_VERSION };

+ 0 - 4
backend/logic/actions/activities.js

@@ -33,7 +33,6 @@ CacheModule.runJob("SUB", {
 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
@@ -71,7 +70,6 @@ export default {
 
 	/**
 	 * 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
@@ -127,7 +125,6 @@ export default {
 
 	/**
 	 * Hides an activity for a user
-	 *
 	 * @param session
 	 * @param {string} activityId - the activity which should be hidden
 	 * @param cb
@@ -165,7 +162,6 @@ export default {
 
 	/**
 	 * Removes all activities logged for a logged-in user
-	 *
 	 * @param session
 	 * @param cb
 	 */

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

@@ -11,11 +11,11 @@ import moduleManager from "../../index";
 const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 const YouTubeModule = moduleManager.modules.youtube;
+const SpotifyModule = moduleManager.modules.spotify;
 
 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
@@ -36,7 +36,6 @@ export default {
 
 	/**
 	 * 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
@@ -66,7 +65,6 @@ export default {
 
 	/**
 	 * Gets Discogs data
-	 *
 	 * @param session
 	 * @param query - the query
 	 * @param {Function} cb
@@ -117,9 +115,201 @@ export default {
 		);
 	}),
 
+	/**
+	 * Gets alternative media sources for list of Spotify tracks (media sources)
+	 * @param session
+	 * @param trackId - the trackId
+	 * @param {Function} cb
+	 */
+	getAlternativeMediaSourcesForTracks: useHasPermission(
+		"spotify.getAlternativeMediaSourcesForTracks",
+		function getAlternativeMediaSourcesForTracks(session, mediaSources, collectAlternativeMediaSourcesOrigins, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!mediaSources) {
+							next("Invalid mediaSources provided.");
+							return;
+						}
+
+						next();
+					},
+
+					next => {
+						this.keepLongJob();
+						this.publishProgress({
+							status: "started",
+							title: "Getting alternative media sources for Spotify tracks",
+							message: "Starting up",
+							id: this.toString()
+						});
+
+						SpotifyModule.runJob(
+							"GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACKS",
+							{
+								mediaSources,
+								collectAlternativeMediaSourcesOrigins
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"APIS_GET_ALTERNATIVE_SOURCES",
+							`Getting alternative sources failed for "${mediaSources.join(", ")}". "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
+					this.log(
+						"SUCCESS",
+						"APIS_GET_ALTERNATIVE_SOURCES",
+						`User "${session.userId}" started getting alternatives for "${mediaSources.join(", ")}".`
+					);
+					return cb({
+						status: "success"
+					});
+				}
+			);
+		}
+	),
+
+	/**
+	 * Gets alternative album sources (such as YouTube playlists) for a list of Spotify album ids
+	 * @param session
+	 * @param trackId - the trackId
+	 * @param {Function} cb
+	 */
+	getAlternativeAlbumSourcesForAlbums: useHasPermission(
+		"spotify.getAlternativeAlbumSourcesForAlbums",
+		function getAlternativeAlbumSourcesForAlbums(session, albumIds, collectAlternativeAlbumSourcesOrigins, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!albumIds) {
+							next("Invalid albumIds provided.");
+							return;
+						}
+
+						next();
+					},
+
+					next => {
+						this.keepLongJob();
+						this.publishProgress({
+							status: "started",
+							title: "Getting alternative album sources for Spotify albums",
+							message: "Starting up",
+							id: this.toString()
+						});
+
+						SpotifyModule.runJob(
+							"GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUMS",
+							{ albumIds, collectAlternativeAlbumSourcesOrigins },
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"APIS_GET_ALTERNATIVE_ALBUM_SOURCES",
+							`Getting alternative album sources failed for "${albumIds.join(", ")}". "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
+					this.log(
+						"SUCCESS",
+						"APIS_GET_ALTERNATIVE_ALBUM_SOURCES",
+						`User "${session.userId}" started getting alternative album spirces for "${albumIds.join(
+							", "
+						)}".`
+					);
+					return cb({
+						status: "success"
+					});
+				}
+			);
+		}
+	),
+
+	/**
+	 * Gets a list of alternative artist sources (such as YouTube channels) for a list of Spotify artist ids
+	 * @param session
+	 * @param trackId - the trackId
+	 * @param {Function} cb
+	 */
+	getAlternativeArtistSourcesForArtists: useHasPermission(
+		"spotify.getAlternativeArtistSourcesForArtists",
+		function getAlternativeArtistSourcesForArtists(session, artistIds, collectAlternativeArtistSourcesOrigins, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!artistIds) {
+							next("Invalid artistIds provided.");
+							return;
+						}
+
+						next();
+					},
+
+					next => {
+						this.keepLongJob();
+						this.publishProgress({
+							status: "started",
+							title: "Getting alternative artist sources for Spotify artists",
+							message: "Starting up",
+							id: this.toString()
+						});
+
+						SpotifyModule.runJob(
+							"GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTISTS",
+							{
+								artistIds,
+								collectAlternativeArtistSourcesOrigins
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"APIS_GET_ALTERNATIVE_ARTIST_SOURCES",
+							`Getting alternative artist sources failed for "${artistIds.join(", ")}". "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
+					this.log(
+						"SUCCESS",
+						"APIS_GET_ALTERNATIVE_ARTIST_SOURCES",
+						`User "${session.userId}" started getting alternative artist spirces for "${artistIds.join(
+							", "
+						)}".`
+					);
+					return cb({
+						status: "success"
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Joins a room
-	 *
 	 * @param {object} session - user session
 	 * @param {string} room - the room to join
 	 * @param {Function} cb - callback
@@ -131,7 +321,7 @@ export default {
 			home: null,
 			news: null,
 			profile: null,
-			"view-youtube-video": null,
+			"view-media": null,
 			"manage-station": null,
 			// "manage-station": "stations.view",
 			"edit-song": "songs.update",
@@ -166,7 +356,6 @@ export default {
 
 	/**
 	 * Leaves a room
-	 *
 	 * @param {object} session - user session
 	 * @param {string} room - the room to leave
 	 * @param {Function} cb - callback
@@ -196,7 +385,6 @@ export default {
 
 	/**
 	 * Joins an admin room
-	 *
 	 * @param {object} session - user session
 	 * @param {string} page - the admin room to join
 	 * @param {Function} cb - callback
@@ -213,6 +401,8 @@ export default {
 			page === "punishments" ||
 			page === "youtube" ||
 			page === "youtubeVideos" ||
+			page === "youtubeChannels" ||
+			(config.get("experimental.soundcloud") && (page === "soundcloud" || page === "soundcloudTracks")) ||
 			page === "import" ||
 			page === "dataRequests"
 		) {
@@ -235,7 +425,6 @@ export default {
 
 	/**
 	 * Leaves all rooms
-	 *
 	 * @param {object} session - user session
 	 * @param {Function} cb - callback
 	 */
@@ -247,7 +436,6 @@ export default {
 
 	/**
 	 * Returns current date
-	 *
 	 * @param {object} session - user session
 	 * @param {Function} cb - callback
 	 */

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

@@ -29,7 +29,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Gets data requests, used in the admin users page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -88,7 +87,6 @@ export default {
 
 	/**
 	 * Resolves a data request
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} dataRequestId - the id of the data request to resolve
 	 * @param {boolean} resolved - whether to set to resolved to true or false

+ 4 - 0
backend/logic/actions/index.js

@@ -10,6 +10,8 @@ import news from "./news";
 import punishments from "./punishments";
 import utils from "./utils";
 import youtube from "./youtube";
+import soundcloud from "./soundcloud";
+import spotify from "./spotify";
 import media from "./media";
 
 export default {
@@ -25,5 +27,7 @@ export default {
 	punishments,
 	utils,
 	youtube,
+	soundcloud,
+	spotify,
 	media
 };

+ 91 - 78
backend/logic/actions/media.js

@@ -18,11 +18,11 @@ CacheModule.runJob("SUB", {
 	channel: "ratings.like",
 	cb: data => {
 		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
+			room: `song.${data.mediaSource}`,
 			args: [
 				"event:ratings.liked",
 				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+					data: { mediaSource: data.mediaSource, likes: data.likes, dislikes: data.dislikes }
 				}
 			]
 		});
@@ -31,7 +31,7 @@ CacheModule.runJob("SUB", {
 			sockets.forEach(socket => {
 				socket.dispatch("event:ratings.updated", {
 					data: {
-						youtubeId: data.youtubeId,
+						mediaSource: data.mediaSource,
 						liked: true,
 						disliked: false
 					}
@@ -45,11 +45,11 @@ CacheModule.runJob("SUB", {
 	channel: "ratings.dislike",
 	cb: data => {
 		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
+			room: `song.${data.mediaSource}`,
 			args: [
 				"event:ratings.disliked",
 				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+					data: { mediaSource: data.mediaSource, likes: data.likes, dislikes: data.dislikes }
 				}
 			]
 		});
@@ -58,7 +58,7 @@ CacheModule.runJob("SUB", {
 			sockets.forEach(socket => {
 				socket.dispatch("event:ratings.updated", {
 					data: {
-						youtubeId: data.youtubeId,
+						mediaSource: data.mediaSource,
 						liked: false,
 						disliked: true
 					}
@@ -72,11 +72,11 @@ CacheModule.runJob("SUB", {
 	channel: "ratings.unlike",
 	cb: data => {
 		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
+			room: `song.${data.mediaSource}`,
 			args: [
 				"event:ratings.unliked",
 				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+					data: { mediaSource: data.mediaSource, likes: data.likes, dislikes: data.dislikes }
 				}
 			]
 		});
@@ -85,7 +85,7 @@ CacheModule.runJob("SUB", {
 			sockets.forEach(socket => {
 				socket.dispatch("event:ratings.updated", {
 					data: {
-						youtubeId: data.youtubeId,
+						mediaSource: data.mediaSource,
 						liked: false,
 						disliked: false
 					}
@@ -99,11 +99,11 @@ CacheModule.runJob("SUB", {
 	channel: "ratings.undislike",
 	cb: data => {
 		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
+			room: `song.${data.mediaSource}`,
 			args: [
 				"event:ratings.undisliked",
 				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+					data: { mediaSource: data.mediaSource, likes: data.likes, dislikes: data.dislikes }
 				}
 			]
 		});
@@ -112,7 +112,7 @@ CacheModule.runJob("SUB", {
 			sockets.forEach(socket => {
 				socket.dispatch("event:ratings.updated", {
 					data: {
-						youtubeId: data.youtubeId,
+						mediaSource: data.mediaSource,
 						liked: false,
 						disliked: false
 					}
@@ -125,7 +125,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Recalculates all ratings
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
@@ -188,12 +187,11 @@ export default {
 
 	/**
 	 * Like
-	 *
 	 * @param session
-	 * @param youtubeId - the youtube id
+	 * @param mediaSource - the media source
 	 * @param cb
 	 */
-	like: isLoginRequired(async function like(session, youtubeId, cb) {
+	like: isLoginRequired(async function like(session, mediaSource, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -202,7 +200,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -211,7 +209,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -234,7 +232,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
+								args: [mediaSource, user.dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -254,7 +252,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "addSongToPlaylist",
-								args: [false, youtubeId, likedSongsPlaylist]
+								args: [false, mediaSource, likedSongsPlaylist]
 							},
 							this
 						)
@@ -266,7 +264,7 @@ export default {
 						}),
 
 				(song, next) => {
-					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+					MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 						.then(ratings => next(null, song, ratings))
 						.catch(err => next(err));
 				}
@@ -277,7 +275,7 @@ export default {
 					this.log(
 						"ERROR",
 						"MEDIA_RATINGS_LIKE",
-						`User "${session.userId}" failed to like song ${youtubeId}. "${err}"`
+						`User "${session.userId}" failed to like song ${mediaSource}. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -289,7 +287,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.like",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -300,8 +298,8 @@ export default {
 					userId: session.userId,
 					type: "song__like",
 					payload: {
-						message: `Liked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
-						youtubeId,
+						message: `Liked song <mediaSource>${song.title} by ${song.artists.join(", ")}</mediaSource>`,
+						mediaSource,
 						thumbnail: song.thumbnail
 					}
 				});
@@ -316,12 +314,11 @@ export default {
 
 	/**
 	 * Dislike
-	 *
 	 * @param session
-	 * @param youtubeId - the youtube id
+	 * @param mediaSource - the media source
 	 * @param cb
 	 */
-	dislike: isLoginRequired(async function dislike(session, youtubeId, cb) {
+	dislike: isLoginRequired(async function dislike(session, mediaSource, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -330,7 +327,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -339,7 +336,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -362,7 +359,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.likedSongsPlaylist]
+								args: [mediaSource, user.likedSongsPlaylist]
 							},
 							this
 						)
@@ -382,7 +379,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "addSongToPlaylist",
-								args: [false, youtubeId, dislikedSongsPlaylist]
+								args: [false, mediaSource, dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -394,7 +391,7 @@ export default {
 						}),
 
 				(song, next) => {
-					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+					MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 						.then(ratings => next(null, song, ratings))
 						.catch(err => next(err));
 				}
@@ -405,7 +402,7 @@ export default {
 					this.log(
 						"ERROR",
 						"MEDIA_RATINGS_DISLIKE",
-						`User "${session.userId}" failed to dislike song ${youtubeId}. "${err}"`
+						`User "${session.userId}" failed to dislike song ${mediaSource}. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -417,7 +414,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.dislike",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -428,8 +425,8 @@ export default {
 					userId: session.userId,
 					type: "song__dislike",
 					payload: {
-						message: `Disliked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
-						youtubeId,
+						message: `Disliked song <mediaSource>${song.title} by ${song.artists.join(", ")}</mediaSource>`,
+						mediaSource,
 						thumbnail: song.thumbnail
 					}
 				});
@@ -444,12 +441,11 @@ export default {
 
 	/**
 	 * Undislike
-	 *
 	 * @param session
-	 * @param youtubeId - the youtube id
+	 * @param mediaSource - the media source
 	 * @param cb
 	 */
-	undislike: isLoginRequired(async function undislike(session, youtubeId, cb) {
+	undislike: isLoginRequired(async function undislike(session, mediaSource, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -458,7 +454,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -467,7 +463,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -490,7 +486,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
+								args: [mediaSource, user.dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -510,7 +506,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
+								args: [mediaSource, likedSongsPlaylist]
 							},
 							this
 						)
@@ -523,7 +519,7 @@ export default {
 				},
 
 				(song, next) => {
-					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+					MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 						.then(ratings => next(null, song, ratings))
 						.catch(err => next(err));
 				}
@@ -534,7 +530,7 @@ export default {
 					this.log(
 						"ERROR",
 						"MEDIA_RATINGS_UNDISLIKE",
-						`User "${session.userId}" failed to undislike song ${youtubeId}. "${err}"`
+						`User "${session.userId}" failed to undislike song ${mediaSource}. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -546,7 +542,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.undislike",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -557,10 +553,10 @@ export default {
 					userId: session.userId,
 					type: "song__undislike",
 					payload: {
-						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+						message: `Removed <mediaSource>${song.title} by ${song.artists.join(
 							", "
-						)}</youtubeId> from your Disliked Songs`,
-						youtubeId,
+						)}</mediaSource> from your Disliked Songs`,
+						mediaSource,
 						thumbnail: song.thumbnail
 					}
 				});
@@ -575,12 +571,11 @@ export default {
 
 	/**
 	 * Unlike
-	 *
 	 * @param session
-	 * @param youtubeId - the youtube id
+	 * @param mediaSource - the media source
 	 * @param cb
 	 */
-	unlike: isLoginRequired(async function unlike(session, youtubeId, cb) {
+	unlike: isLoginRequired(async function unlike(session, mediaSource, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -589,7 +584,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -598,7 +593,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -621,7 +616,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
+								args: [mediaSource, user.dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -641,7 +636,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
+								args: [mediaSource, likedSongsPlaylist]
 							},
 							this
 						)
@@ -654,7 +649,7 @@ export default {
 				},
 
 				(song, next) => {
-					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+					MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 						.then(ratings => next(null, song, ratings))
 						.catch(err => next(err));
 				}
@@ -665,7 +660,7 @@ export default {
 					this.log(
 						"ERROR",
 						"MEDIA_RATINGS_UNLIKE",
-						`User "${session.userId}" failed to unlike song ${youtubeId}. "${err}"`
+						`User "${session.userId}" failed to unlike song ${mediaSource}. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -677,7 +672,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.unlike",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -688,10 +683,10 @@ export default {
 					userId: session.userId,
 					type: "song__unlike",
 					payload: {
-						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+						message: `Removed <mediaSource>${song.title} by ${song.artists.join(
 							", "
-						)}</youtubeId> from your Liked Songs`,
-						youtubeId,
+						)}</mediaSource> from your Liked Songs`,
+						mediaSource,
 						thumbnail: song.thumbnail
 					}
 				});
@@ -706,17 +701,16 @@ export default {
 
 	/**
 	 * Get ratings
-	 *
 	 * @param session
-	 * @param youtubeId - the youtube id
+	 * @param mediaSource - the media source
 	 * @param cb
 	 */
 
-	async getRatings(session, youtubeId, cb) {
+	async getRatings(session, mediaSource, cb) {
 		async.waterfall(
 			[
 				next => {
-					MediaModule.runJob("GET_RATINGS", { youtubeId, createMissing: true }, this)
+					MediaModule.runJob("GET_RATINGS", { mediaSource, createMissing: true }, this)
 						.then(res => next(null, res.ratings))
 						.catch(next);
 				},
@@ -734,7 +728,7 @@ export default {
 					this.log(
 						"ERROR",
 						"MEDIA_GET_RATINGS",
-						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+						`User "${session.userId}" failed to get ratings for ${mediaSource}. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -754,12 +748,11 @@ export default {
 
 	/**
 	 * Gets user's own ratings
-	 *
 	 * @param session
-	 * @param youtubeId - the youtube id
+	 * @param mediaSource - the media source
 	 * @param cb
 	 */
-	getOwnRatings: isLoginRequired(async function getOwnRatings(session, youtubeId, cb) {
+	getOwnRatings: isLoginRequired(async function getOwnRatings(session, mediaSource, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
 		async.waterfall(
@@ -768,7 +761,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -787,7 +780,7 @@ export default {
 
 							Object.values(playlist.songs).forEach(song => {
 								// song is found in 'liked songs' playlist
-								if (song.youtubeId === youtubeId) isLiked = true;
+								if (song.mediaSource === mediaSource) isLiked = true;
 							});
 
 							return next(null, isLiked);
@@ -805,7 +798,7 @@ export default {
 
 							Object.values(playlist.songs).forEach(song => {
 								// song is found in 'disliked songs' playlist
-								if (song.youtubeId === youtubeId) ratings.isDisliked = true;
+								if (song.mediaSource === mediaSource) ratings.isDisliked = true;
 							});
 
 							return next(null, ratings);
@@ -818,7 +811,7 @@ export default {
 					this.log(
 						"ERROR",
 						"MEDIA_GET_OWN_RATINGS",
-						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+						`User "${session.userId}" failed to get ratings for ${mediaSource}. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -828,7 +821,7 @@ export default {
 				return cb({
 					status: "success",
 					data: {
-						youtubeId,
+						mediaSource,
 						liked: isLiked,
 						disliked: isDisliked
 					}
@@ -839,7 +832,6 @@ export default {
 
 	/**
 	 * Gets importJobs, used in the admin import page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -898,7 +890,6 @@ export default {
 
 	/**
 	 * Remove import jobs
-	 *
 	 * @returns {{status: string, data: object}}
 	 */
 	removeImportJobs: useHasPermission("media.removeImportJobs", function removeImportJobs(session, jobIds, cb) {
@@ -913,5 +904,27 @@ export default {
 				this.log("ERROR", "MEDIA_REMOVE_IMPORT_JOBS", `Removing import jobs failed. "${err}"`);
 				return cb({ status: "error", message: err });
 			});
+	}),
+
+	/**
+	 * Gets an array of media from media sources
+	 * @returns {{status: string, data: object}}
+	 */
+	getMediaFromMediaSources: isLoginRequired(function getMediaFromMediaSources(session, mediaSources, cb) {
+		MediaModule.runJob("GET_MEDIA_FROM_MEDIA_SOURCES", { mediaSources }, this)
+			.then(songMap => {
+				this.log("SUCCESS", "MEDIA_GET_MEDIA_FROM_MEDIA_SOURCES", `GET_MEDIA_FROM_MEDIA_SOURCES successful.`);
+
+				return cb({ status: "success", message: "GET_MEDIA_FROM_MEDIA_SOURCES success", data: { songMap } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"MEDIA_GET_MEDIA_FROM_MEDIA_SOURCES",
+					`GET_MEDIA_FROM_MEDIA_SOURCES failed. "${err}"`
+				);
+				return cb({ status: "error", message: err });
+			});
 	})
 };

+ 0 - 7
backend/logic/actions/news.js

@@ -59,7 +59,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Gets news items, used in the admin news page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -164,7 +163,6 @@ 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
 	 */
@@ -192,7 +190,6 @@ export default {
 
 	/**
 	 * 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
@@ -221,7 +218,6 @@ export default {
 	},
 	/**
 	 * 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
@@ -257,7 +253,6 @@ export default {
 
 	/**
 	 * Gets the latest news item
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {boolean} newUser - whether the user requesting the newest news is a new user
 	 * @param {Function} cb - gets called with the result
@@ -283,7 +278,6 @@ export default {
 
 	/**
 	 * 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
@@ -327,7 +321,6 @@ export default {
 
 	/**
 	 * 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

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 434 - 227
backend/logic/actions/playlists.js


+ 0 - 5
backend/logic/actions/punishments.js

@@ -30,7 +30,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Gets punishments, used in the admin punishments page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -200,7 +199,6 @@ export default {
 
 	/**
 	 * 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
@@ -231,7 +229,6 @@ export default {
 
 	/**
 	 * 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
@@ -256,7 +253,6 @@ export default {
 
 	/**
 	 * 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
@@ -357,7 +353,6 @@ export default {
 
 	/**
 	 * Deactivates a punishment
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} punishmentId - the MongoDB id of the punishment
 	 * @param {Function} cb - gets called with the result

+ 8 - 16
backend/logic/actions/reports.js

@@ -90,7 +90,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Gets reports, used in the admin reports page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -195,7 +194,6 @@ export default {
 
 	/**
 	 * 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
@@ -244,7 +242,6 @@ export default {
 
 	/**
 	 * 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
@@ -306,7 +303,6 @@ export default {
 
 	/**
 	 * 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
@@ -380,7 +376,6 @@ export default {
 
 	/**
 	 * 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 {boolean} resolved - whether to set to resolved to true or false
@@ -445,7 +440,6 @@ export default {
 
 	/**
 	 * 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
@@ -509,10 +503,9 @@ export default {
 
 	/**
 	 * 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 {string} report.mediaSource - the media source 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
 	 */
@@ -520,11 +513,11 @@ export default {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 
-		const { youtubeId } = report;
+		const { mediaSource } = report;
 
 		async.waterfall(
 			[
-				next => songModel.findOne({ youtubeId }).exec(next),
+				next => songModel.findOne({ mediaSource }).exec(next),
 
 				(song, next) => {
 					if (!song) return next("Song not found.");
@@ -537,10 +530,10 @@ export default {
 				(song, next) => {
 					if (!song) return next("Song not found.");
 
-					delete report.youtubeId;
+					delete report.mediaSource;
 					report.song = {
 						_id: song._id,
-						youtubeId: song.youtubeId
+						mediaSource: song.mediaSource
 					};
 
 					return next(null, { title: song.title, artists: song.artists, thumbnail: song.thumbnail });
@@ -571,8 +564,8 @@ export default {
 					userId: session.userId,
 					type: "song__report",
 					payload: {
-						message: `Created a <reportId>${report._id}</reportId> for song <youtubeId>${song.title}</youtubeId>`,
-						youtubeId: report.song.youtubeId,
+						message: `Created a <reportId>${report._id}</reportId> for song <mediaSource>${song.title}</mediaSource>`,
+						mediaSource: report.song.mediaSource,
 						reportId: report._id,
 						thumbnail: song.thumbnail
 					}
@@ -583,7 +576,7 @@ export default {
 					value: report
 				});
 
-				this.log("SUCCESS", "REPORTS_CREATE", `User "${session.userId}" created report for "${youtubeId}".`);
+				this.log("SUCCESS", "REPORTS_CREATE", `User "${session.userId}" created report for "${mediaSource}".`);
 
 				return cb({
 					status: "success",
@@ -595,7 +588,6 @@ export default {
 
 	/**
 	 * Removes a report
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {object} reportId - the id of the report item we want to remove
 	 * @param {Function} cb - gets called with the result

+ 53 - 61
backend/logic/actions/songs.js

@@ -44,7 +44,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Returns the length of the songs list
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
@@ -70,7 +69,6 @@ export default {
 
 	/**
 	 * Gets songs, used in the admin songs page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -209,7 +207,6 @@ export default {
 
 	/**
 	 * Updates all songs
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
@@ -265,7 +262,6 @@ export default {
 
 	/**
 	 * 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
@@ -294,51 +290,52 @@ export default {
 	/**
 	 * Gets multiple songs from the Musare song ids
 	 * At this time only used in bulk EditSong
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Array} youtubeIds - the song ids
+	 * @param {Array} mediaSources - the song media sources
 	 * @param {Function} cb
 	 */
-	getSongsFromYoutubeIds: useHasPermission("songs.get", function getSongsFromYoutubeIds(session, youtubeIds, cb) {
-		async.waterfall(
-			[
-				next => {
-					SongsModule.runJob(
-						"GET_SONGS",
-						{
-							youtubeIds,
-							properties: [
-								"youtubeId",
-								"title",
-								"artists",
-								"thumbnail",
-								"duration",
-								"verified",
-								"_id",
-								"youtubeVideoId"
-							]
-						},
-						this
-					)
-						.then(response => next(null, response.songs))
-						.catch(err => next(err));
-				}
-			],
-			async (err, songs) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "SONGS_GET_SONGS_FROM_MUSARE_IDS", `Failed to get songs. "${err}"`);
-					return cb({ status: "error", message: err });
+	getSongsFromMediaSources: useHasPermission(
+		"songs.get",
+		function getSongsFromMediaSources(session, mediaSources, cb) {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.runJob(
+							"GET_SONGS",
+							{
+								mediaSources,
+								properties: [
+									"mediaSource",
+									"title",
+									"artists",
+									"thumbnail",
+									"duration",
+									"verified",
+									"_id",
+									"youtubeVideoId"
+								]
+							},
+							this
+						)
+							.then(response => next(null, response.songs))
+							.catch(err => next(err));
+					}
+				],
+				async (err, songs) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "SONGS_GET_SONGS_FROM_MUSARE_IDS", `Failed to get songs. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "SONGS_GET_SONGS_FROM_MUSARE_IDS", `Got songs successfully.`);
+					return cb({ status: "success", data: { songs } });
 				}
-				this.log("SUCCESS", "SONGS_GET_SONGS_FROM_MUSARE_IDS", `Got songs successfully.`);
-				return cb({ status: "success", data: { songs } });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Creates a song
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {object} newSong - the song object
 	 * @param {Function} cb
@@ -374,7 +371,6 @@ export default {
 
 	/**
 	 * 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
@@ -452,7 +448,6 @@ export default {
 
 	/**
 	 * Removes a song
-	 *
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
@@ -468,8 +463,13 @@ export default {
 				},
 
 				(song, next) => {
-					YouTubeModule.runJob("GET_VIDEO", { identifier: song.youtubeId, createMissing: true }, this)
-						.then(res => next(null, song, res.video))
+					// TODO replace for spotify support
+					YouTubeModule.runJob(
+						"GET_VIDEOS",
+						{ identifiers: [song.mediaSource.split(":")[1]], createMissing: true },
+						this
+					)
+						.then(res => next(null, song, res.videos[0]))
 						.catch(() => next(null, song, false));
 				},
 
@@ -524,7 +524,7 @@ export default {
 													session,
 													namespace: "playlists",
 													action: "removeSongFromPlaylist",
-													args: [song.youtubeId, playlistId]
+													args: [song.mediaSource, playlistId]
 												},
 												this
 											)
@@ -627,7 +627,7 @@ export default {
 							if (!youtubeVideo)
 								StationsModule.runJob(
 									"REMOVE_FROM_QUEUE",
-									{ stationId, youtubeId: song.youtubeId },
+									{ stationId, mediaSource: song.mediaSource },
 									this
 								)
 									.then(() => next())
@@ -656,7 +656,11 @@ export default {
 						1,
 						(stationId, next) => {
 							if (!youtubeVideo)
-								StationsModule.runJob("SKIP_STATION", { stationId, natural: false }, this)
+								StationsModule.runJob(
+									"SKIP_STATION",
+									{ stationId, natural: false, skipReason: "other" },
+									this
+								)
 									.then(() => {
 										next();
 									})
@@ -716,7 +720,6 @@ export default {
 
 	/**
 	 * Removes many songs
-	 *
 	 * @param session
 	 * @param songIds - array of song ids
 	 * @param cb
@@ -816,7 +819,6 @@ export default {
 
 	/**
 	 * 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
@@ -859,7 +861,6 @@ export default {
 
 	/**
 	 * Verifies a song
-	 *
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
@@ -918,7 +919,6 @@ export default {
 
 	/**
 	 * Verify many songs
-	 *
 	 * @param session
 	 * @param songIds - array of song ids
 	 * @param cb
@@ -1014,7 +1014,6 @@ export default {
 
 	/**
 	 * Un-verifies a song
-	 *
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
@@ -1079,7 +1078,6 @@ export default {
 
 	/**
 	 * Unverify many songs
-	 *
 	 * @param session
 	 * @param songIds - array of song ids
 	 * @param cb
@@ -1183,7 +1181,6 @@ export default {
 
 	/**
 	 * Gets a list of all genres
-	 *
 	 * @param session
 	 * @param cb
 	 */
@@ -1219,7 +1216,6 @@ export default {
 
 	/**
 	 * Bulk update genres for selected songs
-	 *
 	 * @param session
 	 * @param method Whether to add, remove or replace genres
 	 * @param genres Array of genres to apply
@@ -1316,7 +1312,6 @@ export default {
 
 	/**
 	 * Gets a list of all artists
-	 *
 	 * @param session
 	 * @param cb
 	 */
@@ -1352,7 +1347,6 @@ export default {
 
 	/**
 	 * Bulk update artists for selected songs
-	 *
 	 * @param session
 	 * @param method Whether to add, remove or replace artists
 	 * @param artists Array of artists to apply
@@ -1449,7 +1443,6 @@ export default {
 
 	/**
 	 * Gets a list of all tags
-	 *
 	 * @param session
 	 * @param cb
 	 */
@@ -1485,7 +1478,6 @@ export default {
 
 	/**
 	 * Bulk update tags for selected songs
-	 *
 	 * @param session
 	 * @param method Whether to add, remove or replace tags
 	 * @param tags Array of tags to apply

+ 260 - 0
backend/logic/actions/soundcloud.js

@@ -0,0 +1,260 @@
+import async from "async";
+
+import isLoginRequired from "../hooks/loginRequired";
+import { useHasPermission } from "../hooks/hasPermission";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const SoundcloudModule = moduleManager.modules.soundcloud;
+const CacheModule = moduleManager.modules.cache;
+
+export default {
+	/**
+	 * Fetches new SoundCloud API key
+	 * @returns {{status: string, data: object}}
+	 */
+	fetchNewApiKey: useHasPermission("soundcloud.fetchNewApiKey", async function fetchNewApiKey(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Fetch new SoundCloud API key",
+			message: "Fetching new SoundCloud API key.",
+			id: this.toString()
+		});
+		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "longJob.added",
+				value: { jobId: this.toString(), userId: session.userId }
+			},
+			this
+		);
+
+		SoundcloudModule.runJob("GENERATE_SOUNDCLOUD_API_KEY", {}, this)
+			.then(response => {
+				this.log("SUCCESS", "SOUNDCLOUD_FETCH_NEW_API_KEY", `Fetching new API key was successful.`);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully fetched new SoundCloud API key."
+				});
+				return cb({
+					status: "success",
+					data: { response }
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SOUNDCLOUD_FETCH_NEW_API_KEY", `Fetching new API key failed. "${err}"`);
+				this.publishProgress({
+					status: "error",
+					message: err
+				});
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Tests SoundCloud API key
+	 * @returns {{status: string, data: object}}
+	 */
+	testApiKey: useHasPermission("soundcloud.testApiKey", async function testApiKey(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Test SoundCloud API key",
+			message: "Testing SoundCloud API key.",
+			id: this.toString()
+		});
+		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "longJob.added",
+				value: { jobId: this.toString(), userId: session.userId }
+			},
+			this
+		);
+
+		SoundcloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, this)
+			.then(response => {
+				this.log(
+					"SUCCESS",
+					"SOUNDCLOUD_TEST_API_KEY",
+					`Testing API key was successful. Response: ${response}.`
+				);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully tested SoundCloud API key."
+				});
+				return cb({
+					status: "success",
+					data: { status: response.status }
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SOUNDCLOUD_TEST_API_KEY", `Testing API key failed. "${err}"`);
+				this.publishProgress({
+					status: "error",
+					message: err
+				});
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Get a Soundcloud artist from ID
+	 * @returns {{status: string, data: object}}
+	 */
+	getArtist: useHasPermission("soundcloud.getArtist", function getArtist(session, userPermalink, cb) {
+		return SoundcloudModule.runJob("GET_ARTISTS_FROM_PERMALINKS", { userPermalinks: [userPermalink] }, this)
+			.then(res => {
+				if (res.artists.length === 0) {
+					this.log("ERROR", "SOUNDCLOUD_GET_ARTISTS_FROM_PERMALINKS", `Fetching artist failed.`);
+					return cb({ status: "error", message: "Failed to get artist" });
+				}
+
+				this.log("SUCCESS", "SOUNDCLOUD_GET_ARTISTS_FROM_PERMALINKS", `Fetching artist was successful.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched Soundcloud artist",
+					data: res.artists[0]
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SOUNDCLOUD_GET_ARTISTS_FROM_PERMALINKS", `Fetching artist failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Gets videos, used in the admin youtube page by the AdvancedTable component
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each news item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getTracks: useHasPermission(
+		"admin.view.soundcloudTracks",
+		async function getTracks(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "soundcloudTrack",
+								blacklistedProperties: [],
+								specialProperties: {
+									songId: [
+										// Fetch songs from songs collection with a matching mediaSource, which we first need to assemble
+										{
+											$lookup: {
+												from: "songs",
+												let: {
+													mediaSource: { $concat: ["soundcloud:", { $toString: "$trackId" }] }
+												},
+												pipeline: [
+													{
+														$match: {
+															$expr: { $eq: ["$mediaSource", "$$mediaSource"] }
+														}
+													}
+												],
+												as: "song"
+											}
+										},
+										// Turn the array of songs returned in the last step into one object, since only one song should have been returned maximum
+										{
+											$unwind: {
+												path: "$song",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										// Add new field songId, which grabs the song object's _id and tries turning it into a string
+										{
+											$addFields: {
+												songId: {
+													$convert: {
+														input: "$song._id",
+														to: "string",
+														onError: "",
+														onNull: ""
+													}
+												}
+											}
+										},
+										// Cleanup, don't return the song object for any further steps
+										{
+											$project: {
+												song: 0
+											}
+										}
+									]
+								},
+								specialQueries: {},
+								specialFilters: {}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "SOUNDCLOUD_GET_TRACKS", `Failed to get SoundCloud tracks. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "SOUNDCLOUD_GET_TRACKS", `Fetched SoundCloud tracks successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched SoundCloud tracks.",
+						data: response
+					});
+				}
+			);
+		}
+	),
+
+	/**
+	 * Get a SoundCloud track
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param identifier - the identifier of the SoundCloud track
+	 * @param createMissing - whether to create/fetch the SoundCloud track if it's missing
+	 * @returns {{status: string, data: object}}
+	 */
+	getTrack: isLoginRequired(function getTrack(session, identifier, createMissing, cb) {
+		return SoundcloudModule.runJob("GET_TRACK", { identifier, createMissing }, this)
+			.then(res => {
+				this.log("SUCCESS", "SOUNDCLOUD_GET_TRACK", `Fetching track was successful.`);
+
+				return cb({ status: "success", message: "Successfully fetched SoundCloud track", data: res.track });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SOUNDCLOUD_GET_TRACK", `Fetching track failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	})
+};

+ 80 - 0
backend/logic/actions/spotify.js

@@ -0,0 +1,80 @@
+import { useHasPermission } from "../hooks/hasPermission";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const UtilsModule = moduleManager.modules.utils;
+const SpotifyModule = moduleManager.modules.spotify;
+
+export default {
+	/**
+	 * Fetches tracks from media sources
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} mediaSources - the media sources to get tracks for
+	 * @returns {{status: string, data: object}}
+	 */
+	getTracksFromMediaSources: useHasPermission(
+		"spotify.getTracksFromMediaSources",
+		function getTracksFromMediaSources(session, mediaSources, cb) {
+			SpotifyModule.runJob("GET_TRACKS_FROM_MEDIA_SOURCES", { mediaSources }, this)
+				.then(response => {
+					this.log(
+						"SUCCESS",
+						"SPOTIFY_GET_TRACKS_FROM_MEDIA_SOURCES",
+						`Getting tracks from media sources was successful.`
+					);
+					return cb({ status: "success", data: { tracks: response.tracks } });
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SPOTIFY_GET_TRACKS_FROM_MEDIA_SOURCES",
+						`Getting tracks from media sources failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				});
+		}
+	),
+
+	/**
+	 * Fetches albums from ids
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} albumIds - the ids of the Spotify albums to get
+	 * @returns {{status: string, data: object}}
+	 */
+	getAlbumsFromIds: useHasPermission("spotify.getAlbumsFromIds", function getAlbumsFromIds(session, albumIds, cb) {
+		SpotifyModule.runJob("GET_ALBUMS_FROM_IDS", { albumIds }, this)
+			.then(albums => {
+				this.log("SUCCESS", "SPOTIFY_GET_ALBUMS_FROM_IDS", `Getting albums from ids was successful.`);
+				return cb({ status: "success", data: { albums } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SPOTIFY_GET_ALBUMS_FROM_IDS", `Getting albums from ids failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Fetches artists from ids
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} artistIds - the ids of the Spotify artists to get
+	 * @returns {{status: string, data: object}}
+	 */
+	getArtistsFromIds: useHasPermission(
+		"spotify.getArtistsFromIds",
+		function getArtistsFromIds(session, artistIds, cb) {
+			SpotifyModule.runJob("GET_ARTISTS_FROM_IDS", { artistIds }, this)
+				.then(artists => {
+					this.log("SUCCESS", "SPOTIFY_GET_ARTISTS_FROM_IDS", `Getting artists from ids was successful.`);
+					return cb({ status: "success", data: { artists } });
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SPOTIFY_GET_ARTISTS_FROM_IDS", `Getting artists from ids failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				});
+		}
+	)
+};

+ 173 - 66
backend/logic/actions/stations.js

@@ -355,7 +355,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Get a list of all the stations
-	 *
 	 * @param {object} session - user session
 	 * @param {boolean} adminFilter - whether to filter out stations admins do not own
 	 * @param {Function} cb - callback
@@ -459,7 +458,6 @@ export default {
 
 	/**
 	 * Gets stations, used in the admin stations page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -467,7 +465,7 @@ export default {
 	 * @param sort - the sort object
 	 * @param queries - the queries array
 	 * @param operator - the operator for queries
-	 * @param cb
+	 * @param {Function} cb - gets called with the result
 	 */
 	getData: useHasPermission(
 		"admin.view.stations",
@@ -562,7 +560,6 @@ export default {
 
 	/**
 	 * 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
@@ -606,7 +603,6 @@ export default {
 
 	/**
 	 * Verifies that a station exists from its name
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationName - the station name
 	 * @param {Function} cb - callback
@@ -651,7 +647,6 @@ export default {
 
 	/**
 	 * Verifies that a station exists from its id
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -696,7 +691,6 @@ export default {
 
 	/**
 	 * Gets the official playlist for a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -779,7 +773,6 @@ export default {
 
 	/**
 	 * Joins the station by its name
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationIdentifier - the station name or station id
 	 * @param {Function} cb - callback
@@ -864,7 +857,7 @@ export default {
 
 					WSModule.runJob("SOCKET_JOIN_SONG_ROOM", {
 						socketId: session.socketId,
-						room: `song.${data.currentSong.youtubeId}`
+						room: `song.${data.currentSong.mediaSource}`
 					});
 
 					data.currentSong.skipVotes = data.currentSong.skipVotes.length;
@@ -908,7 +901,6 @@ export default {
 
 	/**
 	 * Gets a station by id
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -996,6 +988,74 @@ export default {
 		);
 	},
 
+	/**
+	 * Gets station history
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getHistory(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!config.get("experimental.station_history")) return next("Station history is not enabled");
+					return next();
+				},
+
+				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 access station history.");
+							else next();
+						})
+						.catch(err => next(err));
+				},
+
+				next => {
+					StationsModule.stationHistoryModel
+						.find({ stationId }, { documentVersion: 0, __v: 0 })
+						.sort({ "payload.skippedAt": -1 })
+						.limit(250)
+						.then(response => next(null, response))
+						.catch(next);
+				}
+			],
+			async (err, history) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_HISTORY",
+						`Getting station history for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_HISTORY",
+					`Got station history for station "${stationId}" successfully.`
+				);
+				return cb({ status: "success", data: { history } });
+			}
+		);
+	},
+
 	getStationAutofillPlaylistsById(session, stationId, cb) {
 		async.waterfall(
 			[
@@ -1140,10 +1200,9 @@ export default {
 
 	/**
 	 * Toggle votes to skip a station
-	 *
-	 * @param session
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param stationId - the station id
-	 * @param cb
+	 *  @param {Function} cb - gets called with the result
 	 */
 	toggleSkipVote: isLoginRequired(async function toggleSkipVote(session, stationId, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
@@ -1224,7 +1283,6 @@ export default {
 
 	/**
 	 * Force skips a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -1257,7 +1315,7 @@ export default {
 					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 });
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false, skipReason: "force_skip" });
 				this.log("SUCCESS", "STATIONS_FORCE_SKIP", `Force skipped station "${stationId}" successfully.`);
 				return cb({
 					status: "success",
@@ -1269,7 +1327,6 @@ export default {
 
 	/**
 	 * Leaves the user's current station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - id of station to leave
 	 * @param {Function} cb - callback
@@ -1313,7 +1370,6 @@ export default {
 
 	/**
 	 * Updates a station's settings
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {object} newStation - updated station object
@@ -1331,6 +1387,12 @@ export default {
 						.catch(next);
 				},
 
+				next => {
+					if (newStation.requests.autorequestLimit > newStation.requests.limit)
+						next("The autorequest limit cannot be higher than the request limit.");
+					else next();
+				},
+
 				next => {
 					stationModel.findOne({ _id: stationId }, next);
 				},
@@ -1422,7 +1484,6 @@ export default {
 
 	/**
 	 * Pauses a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -1491,7 +1552,6 @@ export default {
 
 	/**
 	 * Resumes a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -1567,7 +1627,6 @@ export default {
 
 	/**
 	 * Removes a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -1651,10 +1710,9 @@ export default {
 
 	/**
 	 * Create a station
-	 *
-	 * @param session
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param data - the station data
-	 * @param cb
+	 *  @param {Function} cb - gets called with the result
 	 */
 	create: isLoginRequired(async function create(session, data, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
@@ -1701,8 +1759,7 @@ export default {
 			"station"
 		];
 
-		if (data.type === "community" && config.has("blacklistedCommunityStationNames"))
-			blacklist = [...blacklist, ...config.get("blacklistedCommunityStationNames")];
+		if (data.type === "community") blacklist = [...blacklist, ...config.get("blacklistedCommunityStationNames")];
 
 		async.waterfall(
 			[
@@ -1786,6 +1843,21 @@ export default {
 							next
 						);
 					}
+				},
+
+				// This extra step is needed because Mongoose decides to create an object with empty arrays for currentSong for some reason
+				(station, next) => {
+					stationModel.updateOne(
+						{ _id: station._id },
+						{
+							$set: {
+								currentSong: null
+							}
+						},
+						err => {
+							next(err, station);
+						}
+					);
 				}
 			],
 			async (err, station) => {
@@ -1821,13 +1893,13 @@ export default {
 
 	/**
 	 * Adds song to station queue
-	 *
-	 * @param session
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param stationId - the station id
-	 * @param youtubeId - the song id
-	 * @param cb
+	 * @param mediaSource - the song id
+	 * @param requestType - whether the song was autorequested or requested normally
+	 *  @param {Function} cb - gets called with the result
 	 */
-	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
+	addToQueue: isLoginRequired(async function addToQueue(session, stationId, mediaSource, requestType, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1874,8 +1946,9 @@ export default {
 						"ADD_TO_QUEUE",
 						{
 							stationId,
-							youtubeId,
-							requestUser: session.userId
+							mediaSource,
+							requestUser: session.userId,
+							requestType
 						},
 						this
 					)
@@ -1888,7 +1961,7 @@ export default {
 					this.log(
 						"ERROR",
 						"STATIONS_ADD_SONG_TO_QUEUE",
-						`Adding song "${youtubeId}" to station "${stationId}" queue failed. "${err}"`
+						`Adding song "${mediaSource}" to station "${stationId}" queue failed. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -1896,7 +1969,7 @@ export default {
 				this.log(
 					"SUCCESS",
 					"STATIONS_ADD_SONG_TO_QUEUE",
-					`Added song "${youtubeId}" to station "${stationId}" successfully.`
+					`Added song "${mediaSource}" to station "${stationId}" successfully.`
 				);
 
 				return cb({
@@ -1909,13 +1982,12 @@ export default {
 
 	/**
 	 * Removes song from station queue
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
-	 * @param {string} youtubeId - the youtube id
+	 * @param {string} mediaSource - the media source
 	 * @param {Function} cb - callback
 	 */
-	async removeFromQueue(session, stationId, youtubeId, cb) {
+	async removeFromQueue(session, stationId, mediaSource, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1925,8 +1997,8 @@ export default {
 				},
 
 				next => {
-					if (!youtubeId) return next("Invalid youtube id.");
-					return StationsModule.runJob("REMOVE_FROM_QUEUE", { stationId, youtubeId }, this)
+					if (!mediaSource) return next("Invalid media source.");
+					return StationsModule.runJob("REMOVE_FROM_QUEUE", { stationId, mediaSource }, this)
 						.then(() => next())
 						.catch(next);
 				}
@@ -1937,7 +2009,7 @@ export default {
 					this.log(
 						"ERROR",
 						"STATIONS_REMOVE_SONG_TO_QUEUE",
-						`Removing song "${youtubeId}" from station "${stationId}" queue failed. "${err}"`
+						`Removing song "${mediaSource}" from station "${stationId}" queue failed. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -1945,7 +2017,7 @@ export default {
 				this.log(
 					"SUCCESS",
 					"STATIONS_REMOVE_SONG_TO_QUEUE",
-					`Removed song "${youtubeId}" from station "${stationId}" successfully.`
+					`Removed song "${mediaSource}" from station "${stationId}" successfully.`
 				);
 
 				return cb({
@@ -1958,7 +2030,6 @@ export default {
 
 	/**
 	 * Gets the queue from a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
@@ -2012,11 +2083,10 @@ export default {
 
 	/**
 	 * Reposition a song in station queue
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {object} song - contains details about the song that is to be repositioned
-	 * @param {string} song.youtubeId - the youtube id of the song
+	 * @param {string} song.mediaSource - the media source 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 {Function} cb - callback
@@ -2033,7 +2103,7 @@ export default {
 				},
 
 				next => {
-					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
+					if (!song || !song.mediaSource) return next("You must provide a song to reposition.");
 					return next();
 				},
 
@@ -2041,7 +2111,7 @@ export default {
 				next => {
 					stationModel.updateOne(
 						{ _id: stationId },
-						{ $pull: { queue: { youtubeId: song.youtubeId } } },
+						{ $pull: { queue: { mediaSource: song.mediaSource } } },
 						next
 					);
 				},
@@ -2068,7 +2138,7 @@ export default {
 					this.log(
 						"ERROR",
 						"STATIONS_REPOSITION_SONG_IN_QUEUE",
-						`Repositioning song ${song.youtubeId} in queue of station "${stationId}" failed. "${err}"`
+						`Repositioning song ${song.mediaSource} in queue of station "${stationId}" failed. "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -2076,14 +2146,14 @@ export default {
 				this.log(
 					"SUCCESS",
 					"STATIONS_REPOSITION_SONG_IN_QUEUE",
-					`Repositioned song ${song.youtubeId} in queue of station "${stationId}" successfully.`
+					`Repositioned song ${song.mediaSource} in queue of station "${stationId}" successfully.`
 				);
 
 				CacheModule.runJob("PUB", {
 					channel: "station.repositionSongInQueue",
 					value: {
 						song: {
-							youtubeId: song.youtubeId,
+							mediaSource: song.mediaSource,
 							oldIndex: song.oldIndex,
 							newIndex: song.newIndex
 						},
@@ -2101,7 +2171,6 @@ export default {
 
 	/**
 	 * Autofill a playlist in a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {string} playlistId - the playlist id
@@ -2123,7 +2192,29 @@ export default {
 				},
 
 				(station, next) => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, station, playlist))
+						.catch(next);
+				},
+
+				(station, playlist, next) => {
+					if (!playlist) return next("Playlist not found");
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId)
+						return hasPermission("playlists.get", session)
+							.then(() => next(null, station, playlist))
+							.catch(() => next("User unauthorised to view playlist."));
+					return next(null, station, playlist);
+				},
+
+				(station, playlist, next) => {
 					if (!station) return next("Station not found.");
+					if (station.type === "official" && ["genre", "admin"].indexOf(playlist.type) === -1)
+						return next("Official statuibs are only allowed to autofill genre and admin playlists.");
+					if (
+						station.type === "community" &&
+						["user", "user-liked", "user-disliked", "genre", "admin"].indexOf(playlist.type) === -1
+					)
+						return next("Community stations are only allowed to autofill user, genre and admin playlists.");
 					if (station.autofill.playlists.indexOf(playlistId) !== -1)
 						return next("That playlist is already autofilling.");
 					if (station.autofill.mode === "sequential" && station.autofill.playlists.length > 0)
@@ -2176,7 +2267,6 @@ export default {
 
 	/**
 	 * Remove autofilled playlist from a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {string} playlistId - the playlist id
@@ -2249,7 +2339,6 @@ export default {
 
 	/**
 	 * Blacklist a playlist in a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {string} playlistId - the playlist id
@@ -2322,7 +2411,6 @@ export default {
 
 	/**
 	 * Remove blacklisted a playlist from a station
-	 *
 	 * @param {object} session - user session
 	 * @param {string} stationId - the station id
 	 * @param {string} playlistId - the playlist id
@@ -2392,6 +2480,12 @@ export default {
 		);
 	},
 
+	/**
+	 * Favorites a station
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} stationId - the station to favorite
+	 * @param {Function} cb - gets called with the result
+	 */
 	favoriteStation: isLoginRequired(async function favoriteStation(session, stationId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		async.waterfall(
@@ -2459,6 +2553,12 @@ export default {
 		);
 	}),
 
+	/**
+	 * Unfavorites a station
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} stationId - the station to unfavorite
+	 * @param {Function} cb - gets called with the result
+	 */
 	unfavoriteStation: isLoginRequired(async function unfavoriteStation(session, stationId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -2515,8 +2615,7 @@ export default {
 
 	/**
 	 * Clears every station queue
-	 *
-	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
 	clearEveryStationQueue: useHasPermission(
@@ -2570,8 +2669,7 @@ export default {
 
 	/**
 	 * Reset a station queue
-	 *
-	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -2604,11 +2702,10 @@ export default {
 
 	/**
 	 * Gets skip votes for a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param stationId - the song id to get skipvotes for
-	 * @param cb
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} stationId - the station id
+	 * @param {string} songId - the song id to get skipvotes for
+	 * @param {Function} cb - gets called with the result
 	 */
 
 	getSkipVotes: isLoginRequired(async function getSkipVotes(session, stationId, songId, cb) {
@@ -2656,8 +2753,7 @@ export default {
 
 	/**
 	 * Add DJ to station
-	 *
-	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} stationId - the station id
 	 * @param {string} userId - the dj user id
 	 * @param {Function} cb - gets called with the result
@@ -2691,8 +2787,7 @@ export default {
 
 	/**
 	 * Remove DJ from station
-	 *
-	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} stationId - the station id
 	 * @param {string} userId - the dj user id
 	 * @param {Function} cb - gets called with the result
@@ -2722,5 +2817,17 @@ export default {
 				return cb({ status: "success", message: "Successfully removed DJ." });
 			}
 		);
+	},
+
+	/**
+	 * Sets the state of the current user session
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} newStationState - the new state
+	 * @param {Function} cb - gets called with the result
+	 */
+	setStationState(session, newStationState, cb) {
+		session.stationState = newStationState;
+
+		cb({ status: "success" });
 	}
 };

+ 26 - 48
backend/logic/actions/users.js

@@ -247,7 +247,6 @@ CacheModule.runJob("SUB", {
 export default {
 	/**
 	 * Gets users, used in the admin users page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -328,7 +327,6 @@ export default {
 
 	/**
 	 * 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
 	 */
@@ -394,7 +392,7 @@ export default {
 					if (!playlist) return next();
 
 					playlist.songs.forEach(song =>
-						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+						songsToAdjustRatings.push({ songId: song._id, mediaSource: song.mediaSource })
 					);
 
 					return next();
@@ -408,7 +406,7 @@ export default {
 				(playlist, next) => {
 					if (!playlist) return next();
 
-					playlist.songs.forEach(song => songsToAdjustRatings.push({ youtubeId: song.youtubeId }));
+					playlist.songs.forEach(song => songsToAdjustRatings.push({ mediaSource: song.mediaSource }));
 
 					return next();
 				},
@@ -422,9 +420,9 @@ export default {
 					async.each(
 						songsToAdjustRatings,
 						(song, next) => {
-							const { youtubeId } = song;
+							const { mediaSource } = song;
 
-							MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+							MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 								.then(() => next())
 								.catch(next);
 						},
@@ -556,7 +554,6 @@ export default {
 
 	/**
 	 * Removes all data held on a user, including their ability to login, by userId
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} userId - the user id that is going to be banned
 	 * @param {Function} cb - gets called with the result
@@ -625,7 +622,7 @@ export default {
 					if (!playlist) return next();
 
 					playlist.songs.forEach(song =>
-						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+						songsToAdjustRatings.push({ songId: song._id, mediaSource: song.mediaSource })
 					);
 
 					return next();
@@ -639,7 +636,7 @@ export default {
 				(playlist, next) => {
 					if (!playlist) return next();
 
-					playlist.songs.forEach(song => songsToAdjustRatings.push({ youtubeId: song.youtubeId }));
+					playlist.songs.forEach(song => songsToAdjustRatings.push({ mediaSource: song.mediaSource }));
 
 					return next();
 				},
@@ -653,9 +650,9 @@ export default {
 					async.each(
 						songsToAdjustRatings,
 						(song, next) => {
-							const { youtubeId } = song;
+							const { mediaSource } = song;
 
-							MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+							MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 								.then(() => next())
 								.catch(next);
 						},
@@ -782,7 +779,6 @@ export default {
 
 	/**
 	 * Logs user in
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} identifier - the username or email of the user
 	 * @param {string} password - the plaintext of the user
@@ -861,7 +857,6 @@ export default {
 
 	/**
 	 * 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
@@ -870,7 +865,7 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	async register(session, username, email, password, recaptcha, cb) {
-		email = email.toLowerCase();
+		email = email.toLowerCase().trim();
 		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
 
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
@@ -881,6 +876,21 @@ export default {
 				next => {
 					if (config.get("registrationDisabled") === true)
 						return next("Registration is not allowed at this time.");
+					if (config.get("experimental.registration_email_whitelist")) {
+						const experimentalRegistrationEmailWhitelist = config.get(
+							"experimental.registration_email_whitelist"
+						);
+						if (!Array.isArray(experimentalRegistrationEmailWhitelist)) return next();
+
+						let anyPassed = false;
+
+						experimentalRegistrationEmailWhitelist.forEach(regex => {
+							const newRegex = new RegExp(regex);
+							if (newRegex.test(email)) anyPassed = true;
+						});
+
+						if (!anyPassed) next("Your email is not allowed to register.");
+					}
 					return next();
 				},
 
@@ -1071,7 +1081,6 @@ export default {
 
 	/**
 	 * Logs out a user
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -1122,7 +1131,6 @@ export default {
 
 	/**
 	 * 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
@@ -1193,7 +1201,6 @@ export default {
 
 	/**
 	 * 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
 	 */
@@ -1251,7 +1258,6 @@ export default {
 
 	/**
 	 * 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
@@ -1337,7 +1343,6 @@ export default {
 
 	/**
 	 * 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
@@ -1397,7 +1402,6 @@ export default {
 
 	/**
 	 * 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
@@ -1453,7 +1457,6 @@ export default {
 
 	/**
 	 * 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
@@ -1556,7 +1559,6 @@ export default {
 
 	/**
 	 * Retrieves a user's preferences
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -1604,7 +1606,6 @@ export default {
 
 	/**
 	 * Gets user object from ObjectId or username (only a few properties)
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} identifier - the ObjectId or username of the user we are trying to find
 	 * @param {Function} cb - gets called with the result
@@ -1654,7 +1655,6 @@ export default {
 
 	/**
 	 * Gets a list of long jobs, including onprogress events when those long jobs have progress
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -1730,7 +1730,6 @@ export default {
 
 	/**
 	 * Gets a specific long job, including onprogress events when that long job has progress
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} jobId - the if id the long job
 	 * @param {Function} cb - gets called with the result
@@ -1804,7 +1803,6 @@ export default {
 
 	/**
 	 * Removes active long job for a user
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} jobId - array of playlist ids (with a specific order)
 	 * @param {Function} cb - gets called with the result
@@ -1865,7 +1863,6 @@ export default {
 
 	/**
 	 * 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
@@ -1919,7 +1916,6 @@ export default {
 
 	/**
 	 * Gets user info from session
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -1987,7 +1983,6 @@ export default {
 
 	/**
 	 * 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
@@ -2075,7 +2070,6 @@ export default {
 
 	/**
 	 * 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
@@ -2183,7 +2177,6 @@ export default {
 
 	/**
 	 * 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
@@ -2253,7 +2246,6 @@ export default {
 
 	/**
 	 * 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
@@ -2329,7 +2321,6 @@ export default {
 
 	/**
 	 * 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
@@ -2393,7 +2384,6 @@ export default {
 
 	/**
 	 * 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
@@ -2461,7 +2451,6 @@ export default {
 
 	/**
 	 * 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
@@ -2534,7 +2523,6 @@ export default {
 
 	/**
 	 * 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
@@ -2611,7 +2599,6 @@ export default {
 
 	/**
 	 * 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
@@ -2689,7 +2676,6 @@ export default {
 
 	/**
 	 * 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
@@ -2733,7 +2719,6 @@ export default {
 
 	/**
 	 * 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
@@ -2818,7 +2803,6 @@ export default {
 
 	/**
 	 * Unlinks password from user
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -2871,7 +2855,6 @@ export default {
 
 	/**
 	 * Unlinks GitHub from user
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -2923,7 +2906,6 @@ export default {
 
 	/**
 	 * 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
@@ -2941,6 +2923,8 @@ export default {
 		async.waterfall(
 			[
 				next => {
+					if (!config.get("mail.enabled")) return next("Password resets are disabled.");
+
 					if (!email || typeof email !== "string") return next("Invalid email.");
 					email = email.toLowerCase();
 					return userModel.findOne({ "email.address": email }, next);
@@ -3002,7 +2986,6 @@ export default {
 
 	/**
 	 * Requests a password reset for a a user as an admin
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} email - the email of the user for which the password reset is intended
 	 * @param {Function} cb - gets called with the result
@@ -3080,7 +3063,6 @@ export default {
 
 	/**
 	 * 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
@@ -3119,7 +3101,6 @@ export default {
 
 	/**
 	 * 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
@@ -3192,7 +3173,6 @@ export default {
 
 	/**
 	 * Resends the verify email email
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} userId - the user id of the person to resend the email to
 	 * @param {Function} cb - gets called with the result
@@ -3245,7 +3225,6 @@ export default {
 
 	/**
 	 * 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
@@ -3350,7 +3329,6 @@ export default {
 
 	/**
 	 * Search for a user by username or name
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} query - the query
 	 * @param {string} page - page

+ 19 - 5
backend/logic/actions/utils.js

@@ -56,9 +56,26 @@ export default {
 			[
 				next => {
 					next(null, UtilsModule.moduleManager.modules[moduleName]);
+				},
+
+				({ jobStatistics, jobQueue }, next) => {
+					const taskMapFn = runningTask => ({
+						name: runningTask.job.name,
+						uniqueId: runningTask.job.uniqueId,
+						status: runningTask.job.status,
+						priority: runningTask.priority,
+						parentUniqueId: runningTask.job.parentJob?.uniqueId ?? "N/A",
+						parentName: runningTask.job.parentJob?.name ?? "N/A"
+					});
+					next(null, {
+						jobStatistics,
+						runningTasks: jobQueue.runningTasks.map(taskMapFn),
+						pausedTasks: jobQueue.pausedTasks.map(taskMapFn),
+						queuedTasks: jobQueue.queue.map(taskMapFn)
+					});
 				}
 			],
-			async (err, module) => {
+			async (err, data) => {
 				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}'`);
@@ -68,9 +85,7 @@ export default {
 					cb({
 						status: "success",
 						message: "Successfully got module info.",
-						data: {
-							jobStatistics: module.jobStatistics
-						}
+						data
 					});
 				}
 			}
@@ -98,7 +113,6 @@ export default {
 
 	/**
 	 * Get permissions
-	 *
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {string} stationId - optional, the station id
 	 * @param {Function} cb - gets called with the result

+ 220 - 21
backend/logic/actions/youtube.js

@@ -16,7 +16,6 @@ const MediaModule = moduleManager.modules.media;
 export default {
 	/**
 	 * Returns details about the YouTube quota usage
-	 *
 	 * @returns {{status: string, data: object}}
 	 */
 	getQuotaStatus: useHasPermission("admin.view.youtube", function getQuotaStatus(session, fromDate, cb) {
@@ -34,7 +33,6 @@ export default {
 
 	/**
 	 * Returns YouTube quota chart data
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param timePeriod - either hours or days
 	 * @param startDate - beginning date
@@ -64,7 +62,6 @@ export default {
 
 	/**
 	 * Gets api requests, used in the admin youtube page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -123,7 +120,6 @@ export default {
 
 	/**
 	 * Returns a specific api request
-	 *
 	 * @returns {{status: string, data: object}}
 	 */
 	getApiRequest: useHasPermission("youtube.getApiRequest", function getApiRequest(session, apiRequestId, cb) {
@@ -152,7 +148,6 @@ export default {
 
 	/**
 	 * Reset stored API requests
-	 *
 	 * @returns {{status: string, data: object}}
 	 */
 	resetStoredApiRequests: useHasPermission(
@@ -206,7 +201,6 @@ export default {
 
 	/**
 	 * Remove stored API requests
-	 *
 	 * @returns {{status: string, data: object}}
 	 */
 	removeStoredApiRequest: useHasPermission(
@@ -236,7 +230,6 @@ export default {
 
 	/**
 	 * Gets videos, used in the admin youtube page by the AdvancedTable component
-	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param page - the page
 	 * @param pageSize - the size per page
@@ -244,7 +237,7 @@ export default {
 	 * @param sort - the sort object
 	 * @param queries - the queries array
 	 * @param operator - the operator for queries
-	 * @param cb
+	 * @param {Function} cb - gets called with the result
 	 */
 	getVideos: useHasPermission(
 		"admin.view.youtubeVideos",
@@ -265,12 +258,20 @@ export default {
 								blacklistedProperties: [],
 								specialProperties: {
 									songId: [
-										// Fetch songs from songs collection with a matching youtubeId
+										// Fetch songs from songs collection with a matching mediaSource, which we first need to assemble
 										{
 											$lookup: {
 												from: "songs",
-												localField: "youtubeId",
-												foreignField: "youtubeId",
+												let: {
+													mediaSource: { $concat: ["youtube:", "$youtubeId"] }
+												},
+												pipeline: [
+													{
+														$match: {
+															$expr: { $eq: ["$mediaSource", "$$mediaSource"] }
+														}
+													}
+												],
 												as: "song"
 											}
 										},
@@ -370,17 +371,79 @@ export default {
 		}
 	),
 
+	/**
+	 * Gets channels, used in the admin youtube page by the AdvancedTable component
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each news item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param {Function} cb - gets called with the result
+	 */
+	getChannels: useHasPermission(
+		"admin.view.youtubeChannels",
+		async function getChannels(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "youtubeChannel",
+								blacklistedProperties: [],
+								specialProperties: {},
+								specialQueries: {},
+								specialFilters: {}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "YOUTUBE_GET_CHANNELS", `Failed to get YouTube channels. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "YOUTUBE_GET_CHANNELS", `Fetched YouTube channels successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched YouTube channels.",
+						data: response
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Get a YouTube video
-	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} identifier - the identifier of the video to get
+	 * @param {string} createMissing - whether to create the video if it doesn't exist yet
+	 * @param {Function} cb - gets called with the result
 	 * @returns {{status: string, data: object}}
 	 */
 	getVideo: isLoginRequired(function getVideo(session, identifier, createMissing, cb) {
-		YouTubeModule.runJob("GET_VIDEO", { identifier, createMissing }, this)
+		return YouTubeModule.runJob("GET_VIDEOS", { identifiers: [identifier], createMissing }, this)
 			.then(res => {
 				this.log("SUCCESS", "YOUTUBE_GET_VIDEO", `Fetching video was successful.`);
 
-				return cb({ status: "success", message: "Successfully fetched YouTube video", data: res.video });
+				return cb({ status: "success", message: "Successfully fetched YouTube video", data: res.videos[0] });
 			})
 			.catch(async err => {
 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
@@ -389,9 +452,40 @@ export default {
 			});
 	}),
 
+	/**
+	 * Get a YouTube channel from ID
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} channelId - the YouTube channel id to get
+	 * @param {Function} cb - gets called with the result
+	 * @returns {{status: string, data: object}}
+	 */
+	getChannel: useHasPermission("youtube.getChannel", function getChannel(session, channelId, cb) {
+		return YouTubeModule.runJob("GET_CHANNELS_FROM_IDS", { channelIds: [channelId] }, this)
+			.then(res => {
+				if (res.channels.length === 0) {
+					this.log("ERROR", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channel failed.`);
+					return cb({ status: "error", message: "Failed to get channel" });
+				}
+
+				this.log("SUCCESS", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channel was successful.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched YouTube channel",
+					data: res.channels[0]
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching video failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	/**
 	 * Remove YouTube videos
-	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} videoIds - the YouTube video ids to remove
+	 * @param {Function} cb - gets called with the result
 	 * @returns {{status: string, data: object}}
 	 */
 	removeVideos: useHasPermission("youtube.removeVideos", async function removeVideos(session, videoIds, cb) {
@@ -432,9 +526,96 @@ export default {
 			});
 	}),
 
+	/**
+	 * Gets missing YouTube video's from all playlists, stations and songs
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 * @returns {{status: string, data: object}}
+	 */
+	getMissingVideos: useHasPermission("youtube.getMissingVideos", async function getMissingVideos(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Get missing YouTube videos",
+			message: "Fetching missing YouTube videos.",
+			id: this.toString()
+		});
+		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "longJob.added",
+				value: { jobId: this.toString(), userId: session.userId }
+			},
+			this
+		);
+
+		return YouTubeModule.runJob("GET_MISSING_VIDEOS", {}, this)
+			.then(response => {
+				this.log("SUCCESS", "YOUTUBE_GET_MISSING_VIDEOS", `Getting missing videos was successful.`);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully fetched missing YouTube videos."
+				});
+				return cb({ status: "success", data: { ...response } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_MISSING_VIDEOS", `Getting missing videos failed. "${err}"`);
+				this.publishProgress({
+					status: "error",
+					message: err
+				});
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Updates YouTube video's from version 1 to version 2, by re-fetching the video's
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 * @returns {{status: string, data: object}}
+	 */
+	updateVideosV1ToV2: useHasPermission("youtube.updateVideosV1ToV2", async function updateVideosV1ToV2(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Update YouTube videos to v2",
+			message: "Updating YouTube videos from v1 to v2.",
+			id: this.toString()
+		});
+		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "longJob.added",
+				value: { jobId: this.toString(), userId: session.userId }
+			},
+			this
+		);
+
+		return YouTubeModule.runJob("UPDATE_VIDEOS_V1_TO_V2", {}, this)
+			.then(response => {
+				this.log("SUCCESS", "YOUTUBE_UPDATE_VIDEOS_V1_TO_V2", `Updating v1 videos to v2 was successful.`);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully updated YouTube videos from v1 to v2."
+				});
+				return cb({ status: "success", data: { ...response } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_UPDATE_VIDEOS_V1_TO_V2", `Updating v1 videos to v2 failed. "${err}"`);
+				this.publishProgress({
+					status: "error",
+					message: err
+				});
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	/**
 	 * Requests a set of YouTube videos
-	 *
 	 * @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
@@ -451,7 +632,7 @@ export default {
 				);
 				return cb({
 					status: "success",
-					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+					message: `Playlist is done importing.`,
 					videos: returnVideos ? response.videos : null
 				});
 			})
@@ -468,7 +649,6 @@ export default {
 
 	/**
 	 * Requests a set of YouTube videos as an admin
-	 *
 	 * @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
@@ -572,16 +752,35 @@ export default {
 
 					this.publishProgress({
 						status: "success",
-						message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
+						message: `Playlist is done importing.`
 					});
 
 					return cb({
 						status: "success",
-						message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+						message: `Playlist is done importing.`,
 						videos: returnVideos ? response.videos : null
 					});
 				}
 			);
 		}
-	)
+	),
+
+	/**
+	 * Gets missing YouTube channels
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 * @returns {{status: string, data: object}}
+	 */
+	getMissingChannels: useHasPermission("youtube.getMissingChannels", function getMissingChannels(session, cb) {
+		return YouTubeModule.runJob("GET_MISSING_CHANNELS", {}, this)
+			.then(response => {
+				this.log("SUCCESS", "YOUTUBE_GET_MISSING_CHANNELS", `Getting missing YouTube channels was successful.`);
+				return cb({ status: "success", data: { ...response } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_MISSING_CHANNELS", `Getting missing YouTube channels failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	})
 };

+ 6 - 11
backend/logic/activities.js

@@ -19,7 +19,6 @@ class _ActivitiesModule extends CoreClass {
 
 	/**
 	 * Initialises the activities module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	initialize() {
@@ -36,14 +35,13 @@ class _ActivitiesModule extends CoreClass {
 
 	/**
 	 * 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.mediaSource - (optional) if relevant, the media source 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
@@ -178,7 +176,6 @@ class _ActivitiesModule extends CoreClass {
 
 	/**
 	 * 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
@@ -285,13 +282,12 @@ class _ActivitiesModule extends CoreClass {
 
 	/**
 	 * 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.type - type of reference. enum: ["mediaSource", "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
+	 * @param {string} payload.mediaSource - (optional) the id of a song
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async REMOVE_ACTIVITY_REFERENCES(payload) {
@@ -302,7 +298,7 @@ class _ActivitiesModule extends CoreClass {
 				[
 					next => {
 						if (
-							(payload.type !== "youtubeId" &&
+							(payload.type !== "mediaSource" &&
 								payload.type !== "stationId" &&
 								payload.type !== "reportId" &&
 								payload.type !== "playlistId") ||
@@ -333,9 +329,9 @@ class _ActivitiesModule extends CoreClass {
 							(activity, next) => {
 								// remove the reference tags
 
-								if (payload.youtubeId) {
+								if (payload.mediaSource) {
 									activity.payload.message = activity.payload.message.replace(
-										/<youtubeId>(.*)<\/youtubeId>/g,
+										/<mediaSource>(.*)<\/mediaSource>/g,
 										"$1"
 									);
 								}
@@ -402,7 +398,6 @@ class _ActivitiesModule extends CoreClass {
 
 	/**
 	 * 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

+ 1 - 2
backend/logic/api.js

@@ -23,7 +23,6 @@ class _APIModule extends CoreClass {
 
 	/**
 	 * Initialises the api module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	initialize() {
@@ -36,7 +35,7 @@ class _APIModule extends CoreClass {
 			CacheModule = this.moduleManager.modules.cache;
 			NotificationsModule = this.moduleManager.modules.notifications;
 
-			const SIDname = config.get("cookie.SIDname");
+			const SIDname = config.get("cookie");
 
 			const isLoggedIn = (req, res, next) => {
 				let SID;

+ 19 - 15
backend/logic/app.js

@@ -29,7 +29,6 @@ class _AppModule extends CoreClass {
 
 	/**
 	 * Initialises the app module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	initialize() {
@@ -42,8 +41,8 @@ class _AppModule extends CoreClass {
 			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"));
+			const SIDname = config.get("cookie");
+			this.server = http.createServer(app).listen(config.get("port"));
 
 			app.use(cookieParser());
 
@@ -57,7 +56,11 @@ class _AppModule extends CoreClass {
 				})
 				.catch(console.error);
 
-			const corsOptions = { ...config.get("cors"), credentials: true };
+			const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
+
+			const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
+			corsOptions.origin.push(appUrl);
+			corsOptions.credentials = true;
 
 			app.use(cors(corsOptions));
 			app.options("*", cors(corsOptions));
@@ -67,7 +70,7 @@ class _AppModule extends CoreClass {
 			 * @param {string} err - custom error message
 			 */
 			function redirectOnErr(res, err) {
-				res.redirect(`${config.get("domain")}?err=${encodeURIComponent(err)}`);
+				res.redirect(`${appUrl}?err=${encodeURIComponent(err)}`);
 			}
 
 			if (config.get("apis.github.enabled")) {
@@ -80,7 +83,10 @@ class _AppModule extends CoreClass {
 					null
 				);
 
-				const redirectUri = `${config.get("apis.github.redirect_uri")}`;
+				const redirectUri =
+					config.get("apis.github.redirect_uri").length > 0
+						? config.get("apis.github.redirect_uri")
+						: `${appUrl}/backend/auth/github/authorize/callback`;
 
 				app.get("/auth/github/authorize", async (req, res) => {
 					if (this.getStatus() !== "READY") {
@@ -94,7 +100,7 @@ class _AppModule extends CoreClass {
 
 					const params = [
 						`client_id=${config.get("apis.github.client")}`,
-						`redirect_uri=${config.get("apis.github.redirect_uri")}`,
+						`redirect_uri=${redirectUri}`,
 						`scope=user:email`
 					].join("&");
 					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
@@ -112,7 +118,7 @@ class _AppModule extends CoreClass {
 
 					const params = [
 						`client_id=${config.get("apis.github.client")}`,
-						`redirect_uri=${config.get("apis.github.redirect_uri")}`,
+						`redirect_uri=${redirectUri}`,
 						`scope=user:email`,
 						`state=${req.cookies[SIDname]}`
 					].join("&");
@@ -222,7 +228,7 @@ class _AppModule extends CoreClass {
 													value: { userId: user._id }
 												});
 
-												res.redirect(`${config.get("domain")}/settings?tab=security`);
+												res.redirect(`${appUrl}/settings?tab=security`);
 											}
 										],
 										next
@@ -428,9 +434,9 @@ class _AppModule extends CoreClass {
 
 									res.cookie(SIDname, sessionId, {
 										expires: date,
-										secure: config.get("cookie.secure"),
+										secure: config.get("url.secure"),
 										path: "/",
-										domain: config.get("cookie.domain")
+										domain: config.get("url.host")
 									});
 
 									this.log(
@@ -439,7 +445,7 @@ class _AppModule extends CoreClass {
 										`User "${userId}" successfully authorized with GitHub.`
 									);
 
-									res.redirect(`${config.get("domain")}/`);
+									res.redirect(appUrl);
 								})
 								.catch(err => redirectOnErr(res, err.message));
 						}
@@ -502,7 +508,7 @@ class _AppModule extends CoreClass {
 
 						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
 
-						return res.redirect(`${config.get("domain")}?toast=Thank you for verifying your email`);
+						return res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
 					}
 				);
 			});
@@ -513,7 +519,6 @@ class _AppModule extends CoreClass {
 
 	/**
 	 * Returns the express server
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	SERVER() {
@@ -524,7 +529,6 @@ class _AppModule extends CoreClass {
 
 	/**
 	 * Returns the app object
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_APP() {

+ 78 - 26
backend/logic/cache/index.js

@@ -22,7 +22,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Initialises the cache/redis module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -43,14 +42,10 @@ class _CacheModule extends CoreClass {
 		};
 
 		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,
+				...config.get("redis"),
 				reconnectStrategy: retries => {
 					if (this.getStatus() !== "LOCKDOWN") {
 						if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
@@ -99,7 +94,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Quits redis client
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	QUIT() {
@@ -113,9 +107,49 @@ class _CacheModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Sets a single value
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key -  name of the key to set
+	 * @param {*} payload.value - the value we want to set
+	 * @param {number} payload.ttl -  ttl of the key in seconds
+	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	SET(payload) {
+		return new Promise((resolve, reject) => {
+			let { key, value } = payload;
+			const { ttl } = 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);
+
+			let options = null;
+			if (ttl) {
+				options = {
+					EX: ttl
+				};
+			}
+
+			CacheModule.client
+				.SET(key, value, options)
+				.then(() => {
+					let parsed = value;
+					try {
+						parsed = JSON.parse(value);
+					} catch {
+						// Do nothing
+					}
+
+					resolve(parsed);
+				})
+				.catch(err => reject(new Error(err)));
+		});
+	}
+
 	/**
 	 * 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
@@ -139,9 +173,43 @@ class _CacheModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets a single value
+	 * @param {object} payload - object containing payload
+	 * @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)
+	 */
+	GET(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+
+			if (!key) {
+				reject(new Error("Invalid key!"));
+				return;
+			}
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+			CacheModule.client
+				.GET(key, payload.value)
+				.then(value => {
+					if (value && !value.startsWith("{") && !value.startsWith("[")) return resolve(value);
+
+					let parsedValue;
+					try {
+						parsedValue = JSON.parse(value);
+					} catch (err) {
+						return reject(err);
+					}
+
+					return resolve(parsedValue);
+				})
+				.catch(err => reject(new Error(err)));
+		});
+	}
+
 	/**
 	 * 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
@@ -180,7 +248,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * 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
@@ -210,7 +277,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * 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
@@ -239,7 +305,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Deletes a single value
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key - name of the key to delete
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -264,7 +329,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * 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
@@ -295,7 +359,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * 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
@@ -310,10 +373,7 @@ class _CacheModule extends CoreClass {
 
 			if (subs[payload.channel] === undefined) {
 				subs[payload.channel] = {
-					client: redis.createClient({
-						url: CacheModule.url,
-						password: CacheModule.password
-					}),
+					client: redis.createClient(config.get("redis")),
 					cbs: []
 				};
 				subs[payload.channel].client.connect().then(() => {
@@ -340,7 +400,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Gets a full list from Redis
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key - name of the table to get the value from (table === redis hash)
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -364,7 +423,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Adds a value to a list in Redis
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @param {*} payload.value - the value we want to set
@@ -388,7 +446,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Adds a value to a list in Redis using LPUSH
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @param {*} payload.value - the value we want to set
@@ -412,7 +469,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Gets the length of a Redis list
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -430,7 +486,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Removes an item from a list using RPOP
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -448,7 +503,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Removes a value from a list in Redis
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @param {*} payload.value - the value we want to remove
@@ -472,7 +526,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * Gets a list of keys in Redis with a matching pattern
-	 *
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.pattern -  pattern to search for
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -490,7 +543,6 @@ class _CacheModule extends CoreClass {
 
 	/**
 	 * 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)

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

@@ -2,7 +2,6 @@
  * Schema for a playlist stored / cached in redis,
  * gets created when a playlist is in use
  * and therefore is put into the redis cache
- *
  * @param {object} playlist - object containing the playlist
  * @returns {object} - returns same object
  */

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

@@ -2,7 +2,6 @@
  * Schema for a station stored / cached in redis,
  * gets created when a station is in use
  * and therefore is put into the redis cache
- *
  * @param {object} station -  object containing the station
  * @returns {object} - returns same object
  */

+ 90 - 28
backend/logic/db/index.js

@@ -6,19 +6,26 @@ import async from "async";
 import CoreClass from "../../core";
 
 const REQUIRED_DOCUMENT_VERSIONS = {
-	activity: 2,
+	activity: 4,
 	news: 3,
-	playlist: 6,
+	playlist: 7,
 	punishment: 1,
 	queueSong: 1,
-	report: 6,
-	song: 9,
-	station: 9,
+	report: 7,
+	song: 10,
+	station: 10,
 	user: 4,
 	youtubeApiRequest: 1,
-	youtubeVideo: 1,
-	ratings: 1,
-	importJob: 1
+	youtubeVideo: [1, 2],
+	youtubeChannel: 1,
+	ratings: 2,
+	importJob: 1,
+	stationHistory: 2,
+	soundcloudTrack: 1,
+	spotifyTrack: 1,
+	spotifyAlbum: 1,
+	spotifyArtist: 1,
+	genericApiRequest: 1
 };
 
 const regex = {
@@ -46,7 +53,6 @@ class _DBModule extends CoreClass {
 
 	/**
 	 * Initialises the database module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	initialize() {
@@ -54,10 +60,10 @@ class _DBModule extends CoreClass {
 			this.schemas = {};
 			this.models = {};
 
-			const mongoUrl = config.get("mongo").url;
+			const { user, password, host, port, database } = config.get("mongo");
 
 			mongoose
-				.connect(mongoUrl, {
+				.connect(`mongodb://${user}:${password}@${host}:${port}/${database}`, {
 					useNewUrlParser: true,
 					useUnifiedTopology: true
 				})
@@ -75,7 +81,14 @@ class _DBModule extends CoreClass {
 						punishment: {},
 						youtubeApiRequest: {},
 						youtubeVideo: {},
-						ratings: {}
+						youtubeChannel: {},
+						ratings: {},
+						stationHistory: {},
+						soundcloudTrack: {},
+						spotifyTrack: {},
+						spotifyAlbum: {},
+						spotifyArtist: {},
+						genericApiRequest: {}
 					};
 
 					const importSchema = schemaName =>
@@ -98,8 +111,15 @@ class _DBModule extends CoreClass {
 					await importSchema("punishment");
 					await importSchema("youtubeApiRequest");
 					await importSchema("youtubeVideo");
+					await importSchema("youtubeChannel");
 					await importSchema("ratings");
 					await importSchema("importJob");
+					await importSchema("stationHistory");
+					await importSchema("soundcloudTrack");
+					await importSchema("spotifyTrack");
+					await importSchema("spotifyAlbum");
+					await importSchema("spotifyArtist");
+					await importSchema("genericApiRequest");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -114,8 +134,15 @@ class _DBModule extends CoreClass {
 						punishment: mongoose.model("punishment", this.schemas.punishment),
 						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),
 						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),
+						youtubeChannel: mongoose.model("youtubeChannel", this.schemas.youtubeChannel),
 						ratings: mongoose.model("ratings", this.schemas.ratings),
-						importJob: mongoose.model("importJob", this.schemas.importJob)
+						importJob: mongoose.model("importJob", this.schemas.importJob),
+						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),
+						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack),
+						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack),
+						spotifyAlbum: mongoose.model("spotifyAlbum", this.schemas.spotifyAlbum),
+						spotifyArtist: mongoose.model("spotifyArtist", this.schemas.spotifyArtist),
+						genericApiRequest: mongoose.model("genericApiRequest", this.schemas.genericApiRequest)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -194,7 +221,24 @@ class _DBModule extends CoreClass {
 						message: "User already has 25 stations."
 					});
 
+					this.schemas.station
+						.path("requests.autorequestLimit")
+						.validate(function validateRequestsAutorequestLimit(autorequestLimit) {
+							const { limit } = this.get("requests");
+
+							if (autorequestLimit > limit) return false;
+
+							return true;
+						}, "Autorequest limit cannot be higher than the request limit.");
+
 					// Song
+					this.schemas.song.path("mediaSource").validate(mediaSource => {
+						if (mediaSource.startsWith("youtube:")) return true;
+						if (mediaSource.startsWith("soundcloud:")) return true;
+						if (mediaSource.startsWith("spotify:")) return true;
+						return false;
+					});
+
 					const songTitle = title => isLength(title, 1, 100);
 					this.schemas.song.path("title").validate(songTitle, "Invalid title.");
 
@@ -221,7 +265,7 @@ class _DBModule extends CoreClass {
 
 					const songThumbnail = thumbnail => {
 						if (!isLength(thumbnail, 1, 256)) return false;
-						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
+						if (config.get("url.secure") === true) return thumbnail.startsWith("https://");
 						return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
 					};
 					this.schemas.song.path("thumbnail").validate(songThumbnail, "Invalid thumbnail.");
@@ -230,7 +274,7 @@ class _DBModule extends CoreClass {
 					this.schemas.playlist
 						.path("displayName")
 						.validate(
-							displayName => isLength(displayName, 1, 32) && regex.ascii.test(displayName),
+							displayName => isLength(displayName, 1, 64) && regex.ascii.test(displayName),
 							"Invalid display name."
 						);
 
@@ -259,8 +303,14 @@ class _DBModule extends CoreClass {
 					this.models.user.syncIndexes();
 					this.models.youtubeApiRequest.syncIndexes();
 					this.models.youtubeVideo.syncIndexes();
+					this.models.youtubeChannel.syncIndexes();
 					this.models.ratings.syncIndexes();
 					this.models.importJob.syncIndexes();
+					this.models.stationHistory.syncIndexes();
+					this.models.soundcloudTrack.syncIndexes();
+					this.models.spotifyTrack.syncIndexes();
+					this.models.spotifyArtist.syncIndexes();
+					this.models.genericApiRequest.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {
@@ -282,24 +332,40 @@ class _DBModule extends CoreClass {
 
 	/**
 	 * 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) => {
+				async modelName => {
 					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();
+					const count = await model.countDocuments({
+						documentVersion: {
+							$nin: Array.isArray(requiredDocumentVersion)
+								? requiredDocumentVersion
+								: [requiredDocumentVersion]
+						}
 					});
+
+					if (count > 0)
+						throw new Error(
+							`Collection "${modelName}" has ${count} documents with a wrong document version. Run migration.`
+						);
+
+					if (Array.isArray(requiredDocumentVersion)) {
+						const count2 = await model.countDocuments({
+							documentVersion: {
+								$ne: requiredDocumentVersion[requiredDocumentVersion.length - 1]
+							}
+						});
+
+						if (count2 > 0)
+							console.warn(
+								`Collection "${modelName}" has ${count2} documents with a outdated document version. Run steps manually to update these.`
+							);
+					}
 				},
 				err => {
 					if (err) reject(new Error(err));
@@ -311,7 +377,6 @@ class _DBModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -324,7 +389,6 @@ class _DBModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -337,7 +401,6 @@ class _DBModule extends CoreClass {
 
 	/**
 	 * Gets data
-	 *
 	 * @param {object} payload - object containing the payload
 	 * @param {string} payload.page - the page
 	 * @param {string} payload.pageSize - the page size
@@ -550,7 +613,6 @@ class _DBModule extends CoreClass {
 
 	/**
 	 * 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)
 	 */

+ 2 - 2
backend/logic/db/schemas/activity.js

@@ -48,10 +48,10 @@ export default {
 	payload: {
 		message: { type: String, default: "", required: true },
 		thumbnail: { type: String, required: false },
-		youtubeId: { type: String, required: false },
+		mediaSource: { 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 }
+	documentVersion: { type: Number, default: 4, required: true }
 };

+ 6 - 0
backend/logic/db/schemas/genericApiRequest.js

@@ -0,0 +1,6 @@
+export default {
+	url: { type: String, required: true },
+	params: { type: Object },
+	responseData: { type: Object, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

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

@@ -1,11 +1,11 @@
 import mongoose from "mongoose";
 
 export default {
-	displayName: { type: String, min: 2, max: 32, trim: true, required: true },
+	displayName: { type: String, min: 1, max: 64, trim: true, required: true },
 	songs: [
 		{
 			_id: { type: mongoose.Schema.Types.ObjectId },
-			youtubeId: { type: String, required: true },
+			mediaSource: { type: String, required: true },
 			title: { type: String },
 			artists: [{ type: String }],
 			duration: { type: Number },
@@ -19,5 +19,12 @@ export default {
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
 	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "station", "admin"], required: true },
-	documentVersion: { type: Number, default: 6, required: true }
+	replacements: [
+		{
+			oldMediaSource: { type: String, required: true },
+			newMediaSource: { type: String, required: true },
+			replacedAt: { type: Date, required: true }
+		}
+	],
+	documentVersion: { type: Number, default: 7, required: true }
 };

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

@@ -1,3 +1,5 @@
+// Legacy file, not used atm, just exists for migration module
+
 export default {
 	songId: { type: String, min: 11, max: 11, required: true, index: true },
 	title: { type: String, required: true },

+ 2 - 2
backend/logic/db/schemas/ratings.js

@@ -1,6 +1,6 @@
 export default {
-	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
+	mediaSource: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
 	likes: { type: Number, default: 0, required: true },
 	dislikes: { type: Number, default: 0, required: true },
-	documentVersion: { type: Number, default: 1, required: true }
+	documentVersion: { type: Number, default: 2, required: true }
 };

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

@@ -2,7 +2,7 @@ export default {
 	resolved: { type: Boolean, default: false, required: true },
 	song: {
 		_id: { type: String, required: true },
-		youtubeId: { type: String, required: true }
+		mediaSource: { type: String, required: true }
 	},
 	issues: [
 		{
@@ -18,5 +18,5 @@ export default {
 	],
 	createdBy: { type: String, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
-	documentVersion: { type: Number, default: 6, required: true }
+	documentVersion: { type: Number, default: 7, required: true }
 };

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

@@ -1,5 +1,5 @@
 export default {
-	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
+	mediaSource: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
 	title: { type: String, trim: true, required: true },
 	artists: [{ type: String, trim: true, default: [] }],
 	genres: [{ type: String, trim: true, default: [] }],
@@ -14,5 +14,5 @@ export default {
 	verifiedBy: { type: String },
 	verifiedAt: { type: Date },
 	discogs: { type: Object },
-	documentVersion: { type: Number, default: 9, required: true }
+	documentVersion: { type: Number, default: 10, required: true }
 };

+ 27 - 0
backend/logic/db/schemas/soundcloudTrack.js

@@ -0,0 +1,27 @@
+export default {
+	trackId: { type: Number, unique: true },
+	title: { type: String },
+	artworkUrl: { type: String },
+	soundcloudCreatedAt: { type: Date },
+	duration: { type: Number },
+	genre: { type: String },
+	kind: { type: String },
+	license: { type: String },
+	likesCount: { type: Number },
+	playbackCount: { type: Number },
+	public: { type: Boolean },
+	tagList: { type: String },
+	userId: { type: Number },
+	username: { type: String },
+	userPermalink: { type: String },
+	trackFormat: { type: String },
+	permalink: { type: String },
+	monetizationModel: { type: String },
+	policy: { type: String },
+	streamable: { type: Boolean },
+	sharing: { type: String },
+	state: { type: String },
+	embeddableBy: { type: String },
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 8 - 0
backend/logic/db/schemas/spotifyAlbum.js

@@ -0,0 +1,8 @@
+export default {
+	albumId: { type: String, unique: true },
+
+	rawData: { type: Object },
+
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 8 - 0
backend/logic/db/schemas/spotifyArtist.js

@@ -0,0 +1,8 @@
+export default {
+	artistId: { type: String, unique: true },
+
+	rawData: { type: Object },
+
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 18 - 0
backend/logic/db/schemas/spotifyTrack.js

@@ -0,0 +1,18 @@
+export default {
+	trackId: { type: String, unique: true },
+	name: { type: String },
+
+	albumId: { type: String },
+	albumTitle: { type: String },
+	albumImageUrl: { type: String },
+	artists: [{ type: String }],
+	artistIds: [{ type: String }],
+	duration: { type: Number },
+	explicit: { type: Boolean },
+	externalIds: { type: Object },
+	popularity: { type: Number },
+	isLocal: { type: Boolean },
+
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 23 - 14
backend/logic/db/schemas/station.js

@@ -7,17 +7,21 @@ export default {
 	description: { type: String, minlength: 2, maxlength: 128, trim: true, required: true },
 	paused: { type: Boolean, default: false, required: true },
 	currentSong: {
-		_id: { type: mongoose.Schema.Types.ObjectId },
-		youtubeId: { type: String },
-		title: { type: String },
-		artists: [{ type: String }],
-		duration: { type: Number },
-		skipDuration: { type: Number },
-		thumbnail: { type: String },
-		skipVotes: [{ type: String }],
-		requestedBy: { type: String },
-		requestedAt: { type: Date },
-		verified: { type: Boolean }
+		type: {
+			_id: { type: mongoose.Schema.Types.ObjectId },
+			mediaSource: { type: String },
+			title: { type: String },
+			artists: [{ type: String }],
+			duration: { type: Number },
+			skipDuration: { type: Number },
+			thumbnail: { type: String },
+			skipVotes: [{ type: String }],
+			requestedBy: { type: String },
+			requestedAt: { type: Date },
+			requestedType: { type: String, enum: ["manual", "autorequest", "autofill"] },
+			verified: { type: Boolean }
+		},
+		default: null
 	},
 	currentSongIndex: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
@@ -28,7 +32,7 @@ export default {
 	queue: [
 		{
 			_id: { type: mongoose.Schema.Types.ObjectId },
-			youtubeId: { type: String, required: true },
+			mediaSource: { type: String, required: true },
 			title: { type: String },
 			artists: [{ type: String }],
 			duration: { type: Number },
@@ -36,6 +40,7 @@ export default {
 			thumbnail: { type: String },
 			requestedBy: { type: String },
 			requestedAt: { type: Date },
+			requestedType: { type: String, enum: ["manual", "autorequest", "autofill"] },
 			verified: { type: Boolean }
 		}
 	],
@@ -43,7 +48,11 @@ export default {
 	requests: {
 		enabled: { type: Boolean, default: true },
 		access: { type: String, enum: ["owner", "user"], default: "owner" },
-		limit: { type: Number, min: 1, max: 50, default: 5 }
+		limit: { type: Number, min: 1, max: 50, default: 5 },
+		allowAutorequest: { type: Boolean, default: true, required: true },
+		autorequestLimit: { type: Number, min: 1, max: 50, default: 3, required: true },
+		autorequestDisallowRecentlyPlayedEnabled: { type: Boolean, default: true, required: true },
+		autorequestDisallowRecentlyPlayedNumber: { type: Number, min: 1, max: 250, default: 50, required: true }
 	},
 	autofill: {
 		enabled: { type: Boolean, default: true },
@@ -55,5 +64,5 @@ export default {
 	blacklist: [{ type: mongoose.Schema.Types.ObjectId, ref: "playlists" }],
 	djs: [{ type: mongoose.Schema.Types.ObjectId, ref: "users" }],
 	skipVoteThreshold: { type: Number, min: 0, max: 100, default: 50, required: true },
-	documentVersion: { type: Number, default: 9, required: true }
+	documentVersion: { type: Number, default: 10, required: true }
 };

+ 22 - 0
backend/logic/db/schemas/stationHistory.js

@@ -0,0 +1,22 @@
+import mongoose from "mongoose";
+
+export default {
+	stationId: { type: mongoose.Schema.Types.ObjectId, required: true },
+	type: { type: String, enum: ["song_played"], required: true },
+	payload: {
+		song: {
+			_id: { type: mongoose.Schema.Types.ObjectId },
+			mediaSource: { type: String, min: 11, max: 11, required: true },
+			title: { type: String, trim: true, required: true },
+			artists: [{ type: String, trim: true, default: [] }],
+			duration: { type: Number },
+			thumbnail: { type: String },
+			requestedBy: { type: String },
+			requestedAt: { type: Date },
+			verified: { type: Boolean }
+		},
+		skippedAt: { type: Date },
+		skipReason: { type: String, enum: ["natural", "force_skip", "vote_skip", "other"] }
+	},
+	documentVersion: { type: Number, default: 2, required: true }
+};

+ 9 - 0
backend/logic/db/schemas/youtubeChannel.js

@@ -0,0 +1,9 @@
+export default {
+	channelId: { type: String, required: true, index: true, unique: true },
+	title: { type: String, trim: true, required: true },
+	customUrl: { type: String, trim: true },
+	rawData: { type: Object },
+	createdAt: { type: Date, default: Date.now, required: true },
+	updatedAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 3 - 1
backend/logic/db/schemas/youtubeVideo.js

@@ -4,6 +4,8 @@ export default {
 	author: { type: String, trim: true, required: true },
 	duration: { type: Number, required: true },
 	uploadedAt: { type: Date },
+	rawData: { type: Object },
+	updatedAt: { type: Date, default: Date.now, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
-	documentVersion: { type: Number, default: 1, required: true }
+	documentVersion: { type: Number, default: 2, required: true }
 };

+ 41 - 7
backend/logic/hooks/hasPermission.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -36,7 +37,7 @@ permissions.moderator = {
 	"admin.view.stations": true,
 	"admin.view.users": true,
 	"admin.view.youtubeVideos": true,
-	"apis.searchDiscogs": true,
+	"apis.searchDiscogs": config.get("apis.discogs.enabled"),
 	"news.create": true,
 	"news.update": true,
 	"playlists.create.admin": true,
@@ -61,10 +62,30 @@ permissions.moderator = {
 	"stations.remove": false,
 	"users.get": true,
 	"users.ban": true,
-	"users.requestPasswordReset": true,
-	"users.resendVerifyEmail": true,
+	"users.requestPasswordReset": config.get("mail.enabled"),
+	"users.resendVerifyEmail": config.get("mail.enabled"),
 	"users.update": true,
-	"youtube.requestSetAdmin": true
+	"youtube.requestSetAdmin": true,
+	...(config.get("experimental.soundcloud")
+		? {
+				"admin.view.soundcloudTracks": true,
+				"admin.view.soundcloud": true,
+				"soundcloud.getArtist": true
+		  }
+		: {}),
+	...(config.get("experimental.spotify")
+		? {
+				"admin.view.spotify": true,
+				"spotify.getTracksFromMediaSources": true,
+				"spotify.getAlbumsFromIds": true,
+				"spotify.getArtistsFromIds": true,
+				"spotify.getAlternativeArtistSourcesForArtists": true,
+				"spotify.getAlternativeAlbumSourcesForAlbums": true,
+				"spotify.getAlternativeMediaSourcesForTracks": true,
+				"admin.view.youtubeChannels": true,
+				"youtube.getChannel": true
+		  }
+		: {})
 };
 permissions.admin = {
 	...permissions.moderator,
@@ -92,9 +113,22 @@ permissions.admin = {
 	"users.update.restricted": true,
 	"utils.getModules": true,
 	"youtube.getApiRequest": true,
+	"youtube.getMissingVideos": true,
 	"youtube.resetStoredApiRequests": true,
 	"youtube.removeStoredApiRequest": true,
-	"youtube.removeVideos": true
+	"youtube.removeVideos": true,
+	"youtube.updateVideosV1ToV2": true,
+	...(config.get("experimental.soundcloud")
+		? {
+				"soundcloud.fetchNewApiKey": true,
+				"soundcloud.testApiKey": true
+		  }
+		: {}),
+	...(config.get("experimental.spotify")
+		? {
+				"youtube.getMissingChannels": true
+		  }
+		: {})
 };
 
 export const hasPermission = async (permission, session, stationId) => {
@@ -136,7 +170,7 @@ export const hasPermission = async (permission, session, stationId) => {
 							if (!station) return next("Station not found.");
 							if (station.type === "community" && station.owner === user._id.toString())
 								return next(null, [user.role, "owner"]);
-							if (station.type === "community" && station.djs.find(dj => dj === user._id.toString()))
+							if (station.djs.find(dj => dj === user._id.toString()))
 								return next(null, [user.role, "dj"]);
 							if (user.role === "admin" || user.role === "moderator") return next(null, [user.role]);
 							return next("Invalid permissions.");
@@ -251,7 +285,7 @@ export const getUserPermissions = async (session, stationId) => {
 							if (!station) return next("Station not found.");
 							if (station.type === "community" && station.owner === user._id.toString())
 								return next(null, [user.role, "owner"]);
-							if (station.type === "community" && station.djs.find(dj => dj === user._id.toString()))
+							if (station.djs.find(dj => dj === user._id.toString()))
 								return next(null, [user.role, "dj"]);
 							if (user.role === "admin" || user.role === "moderator") return next(null, [user.role]);
 							return next("Invalid permissions.");

+ 8 - 14
backend/logic/mail/index.js

@@ -16,7 +16,6 @@ class _MailModule extends CoreClass {
 
 	/**
 	 * Initialises the mail module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -32,18 +31,9 @@ class _MailModule extends CoreClass {
 			dataRequest: await importSchema("dataRequest")
 		};
 
-		this.enabled = config.get("smtp.enabled");
+		this.enabled = config.get("mail.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")
-				}
-			});
+		if (this.enabled) this.transporter = nodemailer.createTransport(config.get("mail.smtp"));
 
 		return new Promise(resolve => {
 			resolve();
@@ -52,7 +42,6 @@ class _MailModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -60,6 +49,12 @@ class _MailModule extends CoreClass {
 	SEND_MAIL(payload) {
 		return new Promise((resolve, reject) => {
 			if (MailModule.enabled) {
+				const { data } = payload;
+				if (!data.from)
+					data.from =
+						config.get("mail.from").length > 0
+							? config.get("mail.from")
+							: `${config.get("sitename")} <noreply@${config.get("url.host")}>`;
 				MailModule.transporter
 					.sendMail(payload.data)
 					.then(info => {
@@ -79,7 +74,6 @@ class _MailModule extends CoreClass {
 
 	/**
 	 * 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)

+ 4 - 5
backend/logic/mail/schemas/dataRequest.js

@@ -4,7 +4,6 @@ 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
@@ -12,7 +11,6 @@ import mail from "../index";
  */
 export default (to, userId, type, cb) => {
 	const data = {
-		from: config.get("mail.from"),
 		to,
 		subject: `Data Request - ${type}`,
 		html: `
@@ -22,9 +20,10 @@ export default (to, userId, type, cb) => {
 				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.
+				This request can be viewed and resolved in the
+				<a href="${config.get("url.secure") ? "https" : "http"}://${config.get(
+			"url.host"
+		)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
 			`
 	};
 

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

@@ -1,9 +1,7 @@
-import config from "config";
 import mail from "../index";
 
 /**
  * Sends a request password email
- *
  * @param {string} to - the email address of the recipient
  * @param {string} username - the username of the recipient
  * @param {string} code - the password code of the recipient
@@ -11,7 +9,6 @@ import mail from "../index";
  */
 export default (to, username, code, cb) => {
 	const data = {
-		from: config.get("mail.from"),
 		to,
 		subject: "Password request",
 		html: `

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

@@ -1,9 +1,7 @@
-import config from "config";
 import mail from "../index";
 
 /**
  * Sends a request password reset email
- *
  * @param {string} to - the email address of the recipient
  * @param {string} username - the username of the recipient
  * @param {string} code - the password reset code of the recipient
@@ -11,7 +9,6 @@ import mail from "../index";
  */
 export default (to, username, code, cb) => {
 	const data = {
-		from: config.get("mail.from"),
 		to,
 		subject: "Password reset request",
 		html: `

+ 2 - 5
backend/logic/mail/schemas/verifyEmail.js

@@ -3,24 +3,21 @@ import mail from "../index";
 
 /**
  * Sends a verify email email
- *
  * @param {string} to - the email address of the recipient
  * @param {string} username - the username of the recipient
  * @param {string} code - the email reset code of the recipient
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  */
 export default (to, username, code, cb) => {
+	const url = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}/backend`;
 	const data = {
-		from: config.get("mail.from"),
 		to,
 		subject: "Please verify your email",
 		html: `
 				Hello there ${username},
 				<br>
 				<br>
-				To verify your email, please visit <a href="${config.get("serverDomain")}/auth/verify_email?code=${code}">${config.get(
-			"serverDomain"
-		)}/auth/verify_email?code=${code}</a>.
+				To verify your email, please visit <a href="${url}/auth/verify_email?code=${code}">${url}/auth/verify_email?code=${code}</a>.
 			`
 	};
 

+ 211 - 50
backend/logic/media.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 import CoreClass from "../core";
 
 let MediaModule;
@@ -6,6 +7,8 @@ let CacheModule;
 let DBModule;
 let UtilsModule;
 let YouTubeModule;
+let SoundCloudModule;
+let SpotifyModule;
 let SongsModule;
 let WSModule;
 
@@ -19,7 +22,6 @@ class _MediaModule extends CoreClass {
 
 	/**
 	 * Initialises the media module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -29,6 +31,8 @@ class _MediaModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
 		YouTubeModule = this.moduleManager.modules.youtube;
+		SoundCloudModule = this.moduleManager.modules.soundcloud;
+		SpotifyModule = this.moduleManager.modules.spotify;
 		SongsModule = this.moduleManager.modules.songs;
 		WSModule = this.moduleManager.modules.ws;
 
@@ -75,17 +79,17 @@ class _MediaModule extends CoreClass {
 
 						if (!ratings) return next();
 
-						const youtubeIds = Object.keys(ratings);
+						const mediaSources = Object.keys(ratings);
 
 						return async.each(
-							youtubeIds,
-							(youtubeId, next) => {
-								MediaModule.RatingsModel.findOne({ youtubeId }, (err, rating) => {
+							mediaSources,
+							(mediaSource, next) => {
+								MediaModule.RatingsModel.findOne({ mediaSource }, (err, rating) => {
 									if (err) next(err);
 									else if (!rating)
 										CacheModule.runJob("HDEL", {
 											table: "ratings",
-											key: youtubeId
+											key: mediaSource
 										})
 											.then(() => next())
 											.catch(next);
@@ -108,7 +112,7 @@ class _MediaModule extends CoreClass {
 							(rating, next) => {
 								CacheModule.runJob("HSET", {
 									table: "ratings",
-									key: rating.youtubeId,
+									key: rating.mediaSource,
 									value: MediaModule.RatingsSchemaCache(rating)
 								})
 									.then(() => next())
@@ -130,9 +134,8 @@ class _MediaModule extends CoreClass {
 
 	/**
 	 * Recalculates dislikes and likes
-	 *
 	 * @param {object} payload - returns an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	async RECALCULATE_RATINGS(payload) {
@@ -143,7 +146,7 @@ class _MediaModule extends CoreClass {
 				[
 					next => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-liked" },
+							{ songs: { $elemMatch: { mediaSource: payload.mediaSource } }, type: "user-liked" },
 							(err, likes) => {
 								if (err) return next(err);
 								return next(null, likes);
@@ -153,7 +156,7 @@ class _MediaModule extends CoreClass {
 
 					(likes, next) => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-disliked" },
+							{ songs: { $elemMatch: { mediaSource: payload.mediaSource } }, type: "user-disliked" },
 							(err, dislikes) => {
 								if (err) return next(err);
 								return next(err, { likes, dislikes });
@@ -163,7 +166,7 @@ class _MediaModule extends CoreClass {
 
 					({ likes, dislikes }, next) => {
 						MediaModule.RatingsModel.findOneAndUpdate(
-							{ youtubeId: payload.youtubeId },
+							{ mediaSource: payload.mediaSource },
 							{
 								$set: {
 									likes,
@@ -180,7 +183,7 @@ class _MediaModule extends CoreClass {
 							"HSET",
 							{
 								table: "ratings",
-								key: payload.youtubeId,
+								key: payload.mediaSource,
 								value: ratings
 							},
 							this
@@ -199,7 +202,6 @@ class _MediaModule extends CoreClass {
 
 	/**
 	 * Recalculates all dislikes and likes
-	 *
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	RECALCULATE_ALL_RATINGS() {
@@ -207,30 +209,31 @@ class _MediaModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.SongModel.find({}, { youtubeId: true }, next);
+						SongsModule.SongModel.find({}, { mediaSource: true }, next);
 					},
 
 					(songs, next) => {
+						// TODO support spotify
 						YouTubeModule.youtubeVideoModel.find({}, { youtubeId: true }, (err, videos) => {
 							if (err) next(err);
 							else
 								next(null, [
-									...songs.map(song => song.youtubeId),
-									...videos.map(video => video.youtubeId)
+									...songs.map(song => song.mediaSource),
+									...videos.map(video => `youtube:${video.youtubeId}`)
 								]);
 						});
 					},
 
-					(youtubeIds, next) => {
+					(mediaSources, next) => {
 						async.eachLimit(
-							youtubeIds,
+							mediaSources,
 							2,
-							(youtubeId, next) => {
+							(mediaSource, next) => {
 								this.publishProgress({
 									status: "update",
-									message: `Recalculating ratings for ${youtubeId}`
+									message: `Recalculating ratings for ${mediaSource}`
 								});
-								MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId }, this)
+								MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
 									.then(() => {
 										next();
 									})
@@ -254,9 +257,8 @@ class _MediaModule extends CoreClass {
 
 	/**
 	 * Gets ratings 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.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @param {string} payload.createMissing - whether to create missing ratings
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
@@ -265,13 +267,13 @@ class _MediaModule extends CoreClass {
 			async.waterfall(
 				[
 					next =>
-						CacheModule.runJob("HGET", { table: "ratings", key: payload.youtubeId }, this)
+						CacheModule.runJob("HGET", { table: "ratings", key: payload.mediaSource }, this)
 							.then(ratings => next(null, ratings))
 							.catch(next),
 
 					(ratings, next) => {
 						if (ratings) return next(true, ratings);
-						return MediaModule.RatingsModel.findOne({ youtubeId: payload.youtubeId }, next);
+						return MediaModule.RatingsModel.findOne({ mediaSource: payload.mediaSource }, next);
 					},
 
 					(ratings, next) => {
@@ -280,7 +282,7 @@ class _MediaModule extends CoreClass {
 								"HSET",
 								{
 									table: "ratings",
-									key: payload.youtubeId,
+									key: payload.mediaSource,
 									value: ratings
 								},
 								this
@@ -288,13 +290,13 @@ class _MediaModule extends CoreClass {
 
 						if (!payload.createMissing) return next("Ratings not found.");
 
-						return MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId: payload.youtubeId }, this)
+						return MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: payload.mediaSource }, this)
 							.then(() => next())
 							.catch(next);
 					},
 
 					next =>
-						MediaModule.runJob("GET_RATINGS", { youtubeId: payload.youtubeId }, this)
+						MediaModule.runJob("GET_RATINGS", { mediaSource: payload.mediaSource }, this)
 							.then(res => next(null, res.ratings))
 							.catch(next)
 				],
@@ -308,31 +310,30 @@ class _MediaModule extends CoreClass {
 
 	/**
 	 * Remove ratings by id from the cache and Mongo
-	 *
 	 * @param {object} payload - object containing the payload
-	 * @param {string} payload.youtubeIds - the youtube id
+	 * @param {string} payload.mediaSources - the media source
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	REMOVE_RATINGS(payload) {
 		return new Promise((resolve, reject) => {
-			let { youtubeIds } = payload;
-			if (!Array.isArray(youtubeIds)) youtubeIds = [youtubeIds];
+			let { mediaSources } = payload;
+			if (!Array.isArray(mediaSources)) mediaSources = [mediaSources];
 
 			async.eachLimit(
-				youtubeIds,
+				mediaSources,
 				1,
-				(youtubeId, next) => {
+				(mediaSource, next) => {
 					async.waterfall(
 						[
 							next => {
-								MediaModule.RatingsModel.deleteOne({ youtubeId }, err => {
+								MediaModule.RatingsModel.deleteOne({ mediaSource }, err => {
 									if (err) next(err);
 									else next();
 								});
 							},
 
 							next => {
-								CacheModule.runJob("HDEL", { table: "ratings", key: youtubeId }, this)
+								CacheModule.runJob("HDEL", { table: "ratings", key: mediaSource }, this)
 									.then(() => {
 										next();
 									})
@@ -351,10 +352,9 @@ class _MediaModule extends CoreClass {
 	}
 
 	/**
-	 * Get song or youtube video by youtubeId
-	 *
+	 * Get song or youtube video by mediaSource
 	 * @param {object} payload - an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube id of the song/video
+	 * @param {string} payload.mediaSource - the media source of the song/video
 	 * @param {string} payload.userId - the user id
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
@@ -363,23 +363,96 @@ class _MediaModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
+						SongsModule.SongModel.findOne({ mediaSource: payload.mediaSource }, next);
 					},
 
 					(song, next) => {
-						if (song && song.duration > 0) next(true, song);
-						else {
-							YouTubeModule.runJob(
-								"GET_VIDEO",
-								{ identifier: payload.youtubeId, createMissing: true },
+						if (song && song.duration > 0) return next(true, song);
+
+						if (payload.mediaSource.startsWith("youtube:")) {
+							const youtubeId = payload.mediaSource.split(":")[1];
+
+							return YouTubeModule.runJob(
+								"GET_VIDEOS",
+								{ identifiers: [youtubeId], createMissing: true },
 								this
 							)
 								.then(response => {
-									const { youtubeId, title, author, duration } = response.video;
-									next(null, song, { youtubeId, title, artists: [author], duration });
+									if (response.videos.length === 0) {
+										next("Media not found.");
+										return;
+									}
+									const { youtubeId, title, author, duration } = response.videos[0];
+									next(null, song, {
+										mediaSource: `youtube:${youtubeId}`,
+										title,
+										artists: [author],
+										duration
+									});
+								})
+								.catch(next);
+						}
+
+						if (config.get("experimental.soundcloud")) {
+							if (payload.mediaSource.startsWith("soundcloud:")) {
+								const trackId = payload.mediaSource.split(":")[1];
+
+								return SoundCloudModule.runJob(
+									"GET_TRACK",
+									{ identifier: trackId, createMissing: true },
+									this
+								)
+									.then(response => {
+										const { trackId, title, username, artworkUrl, duration } = response.track;
+										next(null, song, {
+											mediaSource: `soundcloud:${trackId}`,
+											title,
+											artists: [username],
+											thumbnail: artworkUrl,
+											duration
+										});
+									})
+									.catch(next);
+							}
+
+							if (payload.mediaSource.indexOf("soundcloud.com") !== -1) {
+								return SoundCloudModule.runJob(
+									"GET_TRACK_FROM_URL",
+									{ identifier: payload.mediaSource, createMissing: true },
+									this
+								)
+									.then(response => {
+										const { trackId, title, username, artworkUrl, duration } = response.track;
+										next(null, song, {
+											mediaSource: `soundcloud:${trackId}`,
+											title,
+											artists: [username],
+											thumbnail: artworkUrl,
+											duration
+										});
+									})
+									.catch(next);
+							}
+						}
+
+						if (config.get("experimental.spotify") && payload.mediaSource.startsWith("spotify:")) {
+							const trackId = payload.mediaSource.split(":")[1];
+
+							return SpotifyModule.runJob("GET_TRACK", { identifier: trackId, createMissing: true }, this)
+								.then(response => {
+									const { trackId, name, artists, albumImageUrl, duration } = response.track;
+									next(null, song, {
+										mediaSource: `spotify:${trackId}`,
+										title: name,
+										artists,
+										thumbnail: albumImageUrl,
+										duration
+									});
 								})
 								.catch(next);
 						}
+
+						return next("Invalid media source provided.");
 					},
 
 					(song, youtubeVideo, next) => {
@@ -408,9 +481,98 @@ class _MediaModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets media from media sources
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.mediaSources - the media sources
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_MEDIA_FROM_MEDIA_SOURCES(payload) {
+		return new Promise((resolve, reject) => {
+			const songMap = {};
+			const youtubeMediaSources = payload.mediaSources.filter(mediaSource => mediaSource.startsWith("youtube:"));
+			const soundcloudMediaSources = payload.mediaSources.filter(mediaSource =>
+				mediaSource.startsWith("soundcloud:")
+			);
+
+			async.waterfall(
+				[
+					next => {
+						const allPromises = [];
+
+						youtubeMediaSources.forEach(mediaSource => {
+							const youtubeId = mediaSource.split(":")[1];
+
+							const promise = YouTubeModule.runJob(
+								"GET_VIDEOS",
+								{ identifiers: [youtubeId], createMissing: true },
+								this
+							)
+								.then(response => {
+									const { youtubeId, title, author, duration } = response.videos[0];
+									songMap[mediaSource] = {
+										mediaSource: `youtube:${youtubeId}`,
+										title,
+										artists: [author],
+										duration
+									};
+								})
+								.catch(err => {
+									MediaModule.log(
+										"ERROR",
+										`Failed to get media in GET_MEDIA_FROM_MEDIA_SOURCES with mediaSource ${mediaSource} and error`,
+										typeof err === "string" ? err : err.message
+									);
+								});
+
+							allPromises.push(promise);
+						});
+
+						if (config.get("experimental.soundcloud"))
+							soundcloudMediaSources.forEach(mediaSource => {
+								const trackId = mediaSource.split(":")[1];
+
+								const promise = SoundCloudModule.runJob(
+									"GET_TRACK",
+									{ identifier: trackId, createMissing: true },
+									this
+								)
+									.then(response => {
+										const { trackId, title, username, artworkUrl, duration } = response.track;
+										songMap[mediaSource] = {
+											mediaSource: `soundcloud:${trackId}`,
+											title,
+											artists: [username],
+											thumbnail: artworkUrl,
+											duration
+										};
+									})
+									.catch(err => {
+										MediaModule.log(
+											"ERROR",
+											`Failed to get media in GET_MEDIA_FROM_MEDIA_SOURCES with mediaSource ${mediaSource} and error`,
+											typeof err === "string" ? err : err.message
+										);
+									});
+
+								allPromises.push(promise);
+							});
+
+						Promise.allSettled(allPromises).then(() => {
+							next();
+						});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(songMap);
+				}
+			);
+		});
+	}
+
 	/**
 	 * Remove import job by id from Mongo
-	 *
 	 * @param {object} payload - object containing the payload
 	 * @param {string} payload.jobIds - the job ids
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -455,7 +617,6 @@ class _MediaModule extends CoreClass {
 
 	/**
 	 * Remove import job by id from Mongo
-	 *
 	 * @param {object} payload - object containing the payload
 	 * @param {string} payload.jobIds - the job ids
 	 * @returns {Promise} - returns a promise (resolve, reject)

+ 4 - 6
backend/logic/migration/index.js

@@ -26,17 +26,16 @@ class _MigrationModule extends CoreClass {
 
 	/**
 	 * Initialises the migration module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	initialize() {
 		return new Promise((resolve, reject) => {
 			this.models = {};
 
-			const mongoUrl = config.get("mongo").url;
+			const { user, password, host, port, database } = config.get("mongo");
 
 			mongoose
-				.connect(mongoUrl, {
+				.connect(`mongodb://${user}:${password}@${host}:${port}/${database}`, {
 					useNewUrlParser: true,
 					useUnifiedTopology: true
 				})
@@ -68,7 +67,8 @@ class _MigrationModule extends CoreClass {
 						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 })),
-						ratings: mongoose.model("ratings", new mongoose.Schema({}, { strict: false }))
+						ratings: mongoose.model("ratings", new mongoose.Schema({}, { strict: false })),
+						stationHistory: mongoose.model("stationHistory", new mongoose.Schema({}, { strict: false }))
 					};
 
 					const files = fs.readdirSync(path.join(__dirname, "migrations"));
@@ -99,7 +99,6 @@ class _MigrationModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -112,7 +111,6 @@ class _MigrationModule extends CoreClass {
 
 	/**
 	 * Runs migrations
-	 *
 	 * @param {object} payload - object containing the payload
 	 * @param {object} payload.index - migration index
 	 * @returns {Promise} - returns promise (reject, resolve)

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 12
  *
  * Migration for updated style of reports
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 14
  *
  * Migration for removing some data from stations
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

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

@@ -4,7 +4,6 @@ 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
  */

+ 0 - 1
backend/logic/migration/migrations/migration16.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 16
  *
  * Migration for playlists to remove isUserModifiable
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration17.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 17
  *
  * Migration for songs to add tags property
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration18.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 18
  *
  * Migration for song status property.
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration19.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 19
  *
  * Migration for news showToNewUsers property.
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

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

@@ -4,7 +4,6 @@ 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
  */

+ 0 - 1
backend/logic/migration/migrations/migration20.js

@@ -5,7 +5,6 @@ import mongoose from "mongoose";
  * Migration 20
  *
  * Migration for station overhaul and preventing migration18 from always running
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration21.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 21
  *
  * Migration for song ratings
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration22.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 22
  *
  * Migration to fix issues in a previous migration (12), where report categories were not turned into lowercase
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration23.js

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 23
  *
  * Migration for renaming default user role from default to user
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 0 - 1
backend/logic/migration/migrations/migration24.js

@@ -2,7 +2,6 @@
  * Migration 24
  *
  * Migration for setting station skip vote threshold
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 270 - 0
backend/logic/migration/migrations/migration25.js

@@ -0,0 +1,270 @@
+import async from "async";
+
+/**
+ * Migration 25
+ *
+ * Migration for changing youtubeId to mediaSource
+ * @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);
+	const ratingsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "ratings" }, this);
+	const stationHistoryModel = await MigrationModule.runJob("GET_MODEL", { modelName: "stationHistory" }, this);
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating activity with document version 2.`);
+
+		activityModel.find({ documentVersion: 2 }, (err, activities) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					activities.map(activity => activity._doc),
+					1,
+					(activity, next) => {
+						const updateObject = { $set: { documentVersion: 3 } };
+
+						if (activity.payload.youtubeId) {
+							activity.payload.mediaSource = `youtube:${activity.payload.youtubeId}`;
+							delete activity.payload.youtubeId;
+							updateObject.$set.payload = activity.payload;
+						}
+
+						activityModel.updateOne({ _id: activity._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Activities found: ${activities.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating activity with document version 3.`);
+
+		activityModel.find({ documentVersion: 3 }, (err, activities) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					activities.map(activity => activity._doc),
+					1,
+					(activity, next) => {
+						const updateObject = { $set: { documentVersion: 4 } };
+
+						if (activity.payload.message) {
+							activity.payload.message = activity.payload.message.replaceAll(
+								"<youtubeId>",
+								"<mediaSource>"
+							);
+							activity.payload.message = activity.payload.message.replaceAll(
+								"</youtubeId>",
+								"</mediaSource>"
+							);
+						}
+
+						updateObject.$set.payload = activity.payload;
+
+						activityModel.updateOne({ _id: activity._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Activities found: ${activities.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating playlist with document version 6.`);
+
+		playlistModel.find({ documentVersion: 6 }, (err, documents) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					documents.map(document => document._doc),
+					1,
+					(document, next) => {
+						const updateObject = { $set: { documentVersion: 7 } };
+
+						const songs = document.songs.map(song => {
+							song.mediaSource = `youtube:${song.youtubeId}`;
+							delete song.youtubeId;
+							return song;
+						});
+
+						updateObject.$set.songs = songs;
+
+						playlistModel.updateOne({ _id: document._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Playlists found: ${documents.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating playlist with document version 6.`);
+
+		reportModel.find({ documentVersion: 6 }, (err, documents) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					documents.map(document => document._doc),
+					1,
+					(document, next) => {
+						const updateObject = { $set: { documentVersion: 7 } };
+
+						document.song.mediaSource = `youtube:${document.song.youtubeId}`;
+						delete document.song.youtubeId;
+
+						updateObject.$set.song = document.song;
+
+						reportModel.updateOne({ _id: document._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Reports found: ${documents.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating song with document version 9.`);
+
+		songModel.find({ documentVersion: 9 }, (err, documents) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					documents.map(document => document._doc),
+					1,
+					(document, next) => {
+						const updateObject = { $set: { documentVersion: 10 } };
+
+						updateObject.$set.mediaSource = `youtube:${document.youtubeId}`;
+						updateObject.$unset = { youtubeId: "" };
+
+						songModel.updateOne({ _id: document._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Songs found: ${documents.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating station with document version 9.`);
+
+		stationModel.find({ documentVersion: 9 }, (err, documents) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					documents.map(document => document._doc),
+					1,
+					(document, next) => {
+						const updateObject = { $set: { documentVersion: 10 } };
+
+						if (document.currentSong) {
+							document.currentSong.mediaSource = `youtube:${document.currentSong.youtubeId}`;
+							delete document.currentSong.youtubeId;
+							updateObject.$set.currentSong = document.currentSong;
+						}
+
+						const queue = document.queue.map(song => {
+							song.mediaSource = `youtube:${song.youtubeId}`;
+							delete song.youtubeId;
+							return song;
+						});
+						updateObject.$set.queue = queue;
+
+						stationModel.updateOne({ _id: document._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Stations found: ${documents.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating ratings with document version 1.`);
+
+		ratingsModel.find({ documentVersion: 1 }, (err, documents) => {
+			if (err) reject(err);
+			else {
+				ratingsModel.collection.dropIndexes(() => {
+					async.eachLimit(
+						documents.map(document => document._doc),
+						1,
+						(document, next) => {
+							const updateObject = { $set: { documentVersion: 2 } };
+
+							if (document.youtubeId) {
+								updateObject.$set.mediaSource = `youtube:${document.youtubeId}`;
+								updateObject.$unset = { youtubeId: "" };
+							}
+
+							ratingsModel.updateOne({ _id: document._id }, updateObject, next);
+						},
+						err => {
+							this.log("INFO", `Migration 25. Ratings found: ${documents.length}.`);
+							if (err) reject(err);
+							else resolve();
+						}
+					);
+				});
+			}
+		});
+	});
+
+	await new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 25. Updating station history with document version 1.`);
+
+		stationHistoryModel.find({ documentVersion: 1 }, (err, documents) => {
+			if (err) reject(err);
+			else {
+				async.eachLimit(
+					documents.map(document => document._doc),
+					1,
+					(document, next) => {
+						const updateObject = { $set: { documentVersion: 2 } };
+
+						document.payload.song.mediaSource = `youtube:${document.payload.song.youtubeId}`;
+						delete document.payload.song.youtubeId;
+
+						updateObject.$set.payload = document.payload;
+
+						stationHistoryModel.updateOne({ _id: document._id }, updateObject, next);
+					},
+					err => {
+						this.log("INFO", `Migration 25. Station history found: ${documents.length}.`);
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	});
+}

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 5
  *
  * Migration for song status property.
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

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

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 6
  *
  * Migration for adding activityWatch preference to user object
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ 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
  */

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

@@ -4,7 +4,6 @@ import async from "async";
  * Migration 9
  *
  * Migration for news
- *
  * @param {object} MigrationModule - the MigrationModule
  * @returns {Promise} - returns promise
  */

+ 122 - 0
backend/logic/musicbrainz.js

@@ -0,0 +1,122 @@
+import axios from "axios";
+
+import CoreClass from "../core";
+import { MUSARE_VERSION } from "..";
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 * @param {number} timeBetween - The time between each allowed MusicBrainz request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a MusicBrainz 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 MusicBrainzModule;
+let DBModule;
+
+class _MusicBrainzModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("musicbrainz", {
+			concurrency: 10
+		});
+
+		MusicBrainzModule = this;
+	}
+
+	/**
+	 * Initialises the MusicBrainz module
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		DBModule = this.moduleManager.modules.db;
+
+		this.genericApiRequestModel = this.GenericApiRequestModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "genericApiRequest"
+		});
+
+		this.rateLimiter = new RateLimitter(1100);
+		this.requestTimeout = 5000;
+
+		this.axios = axios.create();
+	}
+
+	/**
+	 * Perform MusicBrainz API call
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - request url
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_CALL(payload) {
+		const { url, params } = payload;
+
+		let genericApiRequest = await MusicBrainzModule.GenericApiRequestModel.findOne({
+			url,
+			params
+		});
+		if (genericApiRequest) {
+			if (genericApiRequest._doc.responseData.error) throw new Error(genericApiRequest._doc.responseData.error);
+			return genericApiRequest._doc.responseData;
+		}
+
+		await MusicBrainzModule.rateLimiter.continue();
+		MusicBrainzModule.rateLimiter.restart();
+
+		const responseData = await new Promise((resolve, reject) => {
+			MusicBrainzModule.axios
+				.get(url, {
+					params,
+					headers: {
+						"User-Agent": `Musare/${MUSARE_VERSION} ( https://github.com/Musare/Musare )` // TODO set this in accordance to https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
+					},
+					timeout: MusicBrainzModule.requestTimeout
+				})
+				.then(({ data: responseData }) => {
+					resolve(responseData);
+				})
+				.catch(err => {
+					if (err.response.status === 404) {
+						resolve(err.response.data);
+					} else reject(err);
+				});
+		});
+
+		if (responseData.error && responseData.error !== "Not Found") throw new Error(responseData.error);
+
+		genericApiRequest = new MusicBrainzModule.GenericApiRequestModel({
+			url,
+			params,
+			responseData,
+			date: Date.now()
+		});
+		genericApiRequest.save();
+
+		if (responseData.error) throw new Error(responseData.error);
+
+		return responseData;
+	}
+}
+
+export default new _MusicBrainzModule();

+ 2 - 11
backend/logic/notifications.js

@@ -19,17 +19,12 @@ class _NotificationsModule extends CoreClass {
 
 	/**
 	 * 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);
-
 			this.pub = redis.createClient({
-				url,
-				password,
+				...config.get("redis"),
 				reconnectStrategy: retries => {
 					if (this.getStatus() !== "LOCKDOWN") {
 						if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
@@ -130,7 +125,6 @@ class _NotificationsModule extends CoreClass {
 	 * 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
@@ -162,7 +156,6 @@ class _NotificationsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -208,9 +201,8 @@ class _NotificationsModule extends CoreClass {
 
 	/**
 	 * Remove a notification subscription
-	 *
 	 * @param {object} payload - object containing the payload
-	 * @param {object} payload.subscription - the subscription object returned by {@link subscribe}
+	 * @param {object} payload.subscription - the subscription object returned by subscribe
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	REMOVE(payload) {
@@ -224,7 +216,6 @@ class _NotificationsModule extends CoreClass {
 
 	/**
 	 * 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)

+ 191 - 52
backend/logic/playlists.js

@@ -21,7 +21,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Initialises the playlists module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -143,7 +142,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -159,9 +157,33 @@ class _PlaylistsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Returns a list of youtube ids in all user playlists of the specified user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the user id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SONG_YOUTUBE_IDS_FROM_USER_PLAYLISTS(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ createdBy: payload.userId }, (err, playlists) => {
+				const youtubeIds = new Set();
+
+				if (err) reject(err);
+				else {
+					playlists.forEach(playlist => {
+						playlist.songs.forEach(song => {
+							youtubeIds.add(song.youtubeId);
+						});
+					});
+
+					resolve({ youtubeIds: Array.from(youtubeIds) });
+				}
+			});
+		});
+	}
+
 	/**
 	 * Creates a playlist owned by a user
-	 *
 	 * @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
@@ -189,7 +211,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -223,7 +244,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -240,7 +260,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -257,7 +276,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -280,7 +298,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Gets all missing genre playlists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_MISSING_GENRE_PLAYLISTS() {
@@ -322,7 +339,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Creates all missing genre playlists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	CREATE_MISSING_GENRE_PLAYLISTS() {
@@ -356,7 +372,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -379,15 +394,14 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Adds a song to a playlist
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
-	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	ADD_SONG_TO_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
-			const { playlistId, youtubeId } = payload;
+			const { playlistId, mediaSource } = payload;
 
 			async.waterfall(
 				[
@@ -401,19 +415,19 @@ class _PlaylistsModule extends CoreClass {
 
 					(playlist, next) => {
 						if (!playlist) return next("Playlist not found.");
-						if (playlist.songs.find(song => song.youtubeId === youtubeId))
+						if (playlist.songs.find(song => song.mediaSource === mediaSource))
 							return next("That song is already in the playlist.");
 						return next();
 					},
 
 					next => {
-						MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+						MediaModule.runJob("GET_MEDIA", { mediaSource }, this)
 							.then(response => {
 								const { song } = response;
 								const { _id, title, artists, thumbnail, duration, verified } = song;
 								next(null, {
 									_id,
-									youtubeId,
+									mediaSource,
 									title,
 									artists,
 									thumbnail,
@@ -461,7 +475,7 @@ class _PlaylistsModule extends CoreClass {
 					(playlist, newSong, next) => {
 						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
 							MediaModule.runJob("RECALCULATE_RATINGS", {
-								youtubeId: newSong.youtubeId
+								mediaSource: newSong.mediaSource
 							})
 								.then(ratings => next(null, playlist, newSong, ratings))
 								.catch(next);
@@ -478,17 +492,134 @@ class _PlaylistsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Replaces a song in a playlist
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.newMediaSource - the new media source
+	 * @param {string} payload.oldMediaSource - the old media source
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REPLACE_SONG_IN_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId, newMediaSource, oldMediaSource } = payload;
+
+			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.songs.find(song => song.mediaSource === newMediaSource))
+							return next("The new song is already in the playlist.");
+						if (!playlist.songs.find(song => song.mediaSource === oldMediaSource))
+							return next("The old song is not in the playlist.");
+						return next();
+					},
+
+					next => {
+						MediaModule.runJob("GET_MEDIA", { mediaSource: newMediaSource }, this)
+							.then(response => {
+								const { song } = response;
+								const { _id, title, artists, thumbnail, duration, verified } = song;
+								next(null, {
+									_id,
+									mediaSource: newMediaSource,
+									title,
+									artists,
+									thumbnail,
+									duration,
+									verified
+								});
+							})
+							.catch(next);
+					},
+
+					(newSong, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: playlistId, "songs.mediaSource": oldMediaSource },
+							{
+								$set: { "songs.$": newSong },
+								$push: { replacements: { oldMediaSource, newMediaSource, replacedAt: new Date() } }
+							},
+							{ runValidators: true },
+							err => {
+								if (err) return next(err);
+								return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+									.then(playlist => next(null, playlist, newSong))
+									.catch(next);
+							}
+						);
+					},
+
+					(playlist, newSong, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
+							.then(response => {
+								async.each(
+									response.stationIds,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
+											.then()
+											.catch();
+										next();
+									},
+									err => {
+										if (err) next(err);
+										else next(null, playlist, newSong);
+									}
+								);
+							})
+							.catch(next);
+					},
+
+					(playlist, newSong, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							MediaModule.runJob("RECALCULATE_RATINGS", {
+								mediaSource: newSong.mediaSource
+							})
+								.then(ratings => next(null, playlist, newSong, ratings))
+								.catch(next);
+						} else {
+							next(null, playlist, newSong, null);
+						}
+					},
+
+					(playlist, newSong, newRatings, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							MediaModule.runJob("RECALCULATE_RATINGS", {
+								mediaSource: oldMediaSource
+							})
+								.then(oldRatings => next(null, playlist, newSong, newRatings, oldRatings))
+								.catch(next);
+						} else {
+							next(null, playlist, newSong, null, null);
+						}
+					}
+				],
+				(err, playlist, song, newRatings, oldRatings) => {
+					if (err) reject(err);
+					else resolve({ playlist, song, newRatings, oldRatings });
+				}
+			);
+		});
+	}
+
 	/**
 	 * Remove from playlist
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
-	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	REMOVE_FROM_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
-			const { playlistId, youtubeId } = payload;
+			const { playlistId, mediaSource } = payload;
 			async.waterfall(
 				[
 					next => {
@@ -501,12 +632,12 @@ class _PlaylistsModule extends CoreClass {
 
 					(playlist, next) => {
 						if (!playlist) return next("Playlist not found.");
-						if (!playlist.songs.find(song => song.youtubeId === youtubeId))
+						if (!playlist.songs.find(song => song.mediaSource === mediaSource))
 							return next("That song is not currently in the playlist.");
 
 						return PlaylistsModule.playlistModel.updateOne(
 							{ _id: playlistId },
-							{ $pull: { songs: { youtubeId } } },
+							{ $pull: { songs: { mediaSource } } },
 							next
 						);
 					},
@@ -539,7 +670,7 @@ class _PlaylistsModule extends CoreClass {
 
 					(playlist, next) => {
 						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-							MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+							MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource })
 								.then(ratings => next(null, playlist, ratings))
 								.catch(next);
 						} else next(null, playlist, null);
@@ -566,18 +697,17 @@ class _PlaylistsModule extends CoreClass {
 	}
 
 	/**
-	 * Deletes a song from a playlist based on the youtube id
-	 *
+	 * Deletes a song from a playlist based on the media source
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
-	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	DELETE_SONG_FROM_PLAYLIST_BY_YOUTUBE_ID(payload) {
+	DELETE_SONG_FROM_PLAYLIST_BY_MEDIA_SOURCE_ID(payload) {
 		return new Promise((resolve, reject) => {
 			PlaylistsModule.playlistModel.updateOne(
 				{ _id: payload.playlistId },
-				{ $pull: { songs: { youtubeId: payload.youtubeId } } },
+				{ $pull: { songs: { mediaSource: payload.mediaSource } } },
 				err => {
 					if (err) reject(new Error(err));
 					else {
@@ -594,7 +724,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Fills a genre playlist with songs
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.genre - the genre
 	 * @param {string} payload.createPlaylist - create playlist if it doesn't exist, default false
@@ -640,10 +769,10 @@ class _PlaylistsModule extends CoreClass {
 
 					(playlistId, _songs, next) => {
 						const songs = _songs.map(song => {
-							const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
+							const { _id, mediaSource, title, artists, thumbnail, duration, verified } = song;
 							return {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -701,7 +830,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Gets orphaned genre playlists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_ORPHANED_GENRE_PLAYLISTS() {
@@ -743,7 +871,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Deletes all orphaned genre playlists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	DELETE_ORPHANED_GENRE_PLAYLISTS() {
@@ -778,7 +905,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Gets a orphaned station playlists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_ORPHANED_STATION_PLAYLISTS() {
@@ -817,7 +943,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Deletes all orphaned station playlists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	DELETE_ORPHANED_STATION_PLAYLISTS() {
@@ -852,7 +977,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -929,17 +1053,17 @@ class _PlaylistsModule extends CoreClass {
 							.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
 							.reduce(
 								(items, item) =>
-									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+									items.find(x => x.mediaSource === item.mediaSource) ? [...items] : [...items, item],
 								[]
 							);
 						const includedSongs = playlists
 							.flatMap(playlist => playlist.songs)
 							.reduce(
 								(songs, song) =>
-									songs.find(x => x.youtubeId === song.youtubeId) ? [...songs] : [...songs, song],
+									songs.find(x => x.mediaSource === song.mediaSource) ? [...songs] : [...songs, song],
 								[]
 							)
-							.filter(song => !blacklistedSongs.find(x => x.youtubeId === song.youtubeId));
+							.filter(song => !blacklistedSongs.find(x => x.mediaSource === song.mediaSource));
 
 						next(null, station, includedSongs);
 					},
@@ -949,23 +1073,36 @@ class _PlaylistsModule extends CoreClass {
 							{ _id: station.playlist },
 							{ $set: { songs: includedSongs } },
 							err => {
-								next(err, includedSongs);
+								next(err);
 							}
 						);
 					},
 
-					(includedSongs, next) => {
+					next => {
 						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: originalPlaylist._id }, this)
 							.then(() => {
-								next(null, includedSongs);
+								next();
 							})
 							.catch(next);
 					},
 
-					(includedSongs, next) => {
-						if (originalPlaylist.songs.length === 0 && includedSongs.length > 0)
-							StationsModule.runJob("SKIP_STATION", { stationId: payload.stationId, natural: false });
-						next();
+					next => {
+						StationsModule.runJob("AUTOFILL_STATION", { stationId: payload.stationId }, this)
+							.then(() => next())
+							.catch(err => {
+								if (err === "Autofill is disabled in this station" || err === "Autofill limit reached")
+									return next();
+								return next(err);
+							});
+					},
+
+					next => {
+						CacheModule.runJob("PUB", {
+							channel: "station.queueUpdate",
+							value: payload.stationId
+						})
+							.then(() => next())
+							.catch(next);
 					}
 				],
 				err => {
@@ -978,7 +1115,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -1049,7 +1185,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -1097,7 +1232,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -1145,7 +1279,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * Searches through playlists
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.query - the query
 	 * @param {string} payload.includePrivate - include private playlists
@@ -1236,7 +1369,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -1281,7 +1413,6 @@ class _PlaylistsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -1323,6 +1454,14 @@ class _PlaylistsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets a list of all media sources from playlist songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALL_MEDIA_SOURCES() {
+		return PlaylistsModule.playlistModel.distinct("songs.mediaSource");
+	}
 }
 
 export default new _PlaylistsModule();

+ 0 - 6
backend/logic/punishments.js

@@ -18,7 +18,6 @@ class _PunishmentsModule extends CoreClass {
 
 	/**
 	 * Initialises the punishments module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -106,7 +105,6 @@ class _PunishmentsModule extends CoreClass {
 
 	/**
 	 * Gets all punishments in the cache that are active, and removes those that have expired
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_PUNISHMENTS() {
@@ -176,7 +174,6 @@ class _PunishmentsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -232,7 +229,6 @@ class _PunishmentsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -265,7 +261,6 @@ class _PunishmentsModule extends CoreClass {
 
 	/**
 	 * 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"])
@@ -318,7 +313,6 @@ class _PunishmentsModule extends CoreClass {
 
 	/**
 	 * Deactivates a punishment
-	 *
 	 * @param {object} payload - object containing the payload
 	 * @param {string} payload.punishmentId - the MongoDB id of the punishment
 	 * @returns {Promise} - returns promise (reject, resolve)

+ 172 - 86
backend/logic/songs.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 import mongoose from "mongoose";
 import CoreClass from "../core";
 
@@ -7,6 +8,7 @@ let CacheModule;
 let DBModule;
 let UtilsModule;
 let YouTubeModule;
+let SoundCloudModule;
 let StationsModule;
 let PlaylistsModule;
 let MediaModule;
@@ -22,7 +24,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Initialises the songs module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -32,6 +33,7 @@ class _SongsModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
 		YouTubeModule = this.moduleManager.modules.youtube;
+		SoundCloudModule = this.moduleManager.modules.soundcloud;
 		StationsModule = this.moduleManager.modules.stations;
 		PlaylistsModule = this.moduleManager.modules.playlists;
 		MediaModule = this.moduleManager.modules.media;
@@ -68,17 +70,17 @@ class _SongsModule extends CoreClass {
 
 						if (!songs) return next();
 
-						const youtubeIds = Object.keys(songs);
+						const mediaSources = Object.keys(songs);
 
 						return async.each(
-							youtubeIds,
-							(youtubeId, next) => {
-								SongsModule.SongModel.findOne({ youtubeId }, (err, song) => {
+							mediaSources,
+							(mediaSource, next) => {
+								SongsModule.SongModel.findOne({ mediaSource }, (err, song) => {
 									if (err) next(err);
 									else if (!song)
 										CacheModule.runJob("HDEL", {
 											table: "songs",
-											key: youtubeId
+											key: mediaSource
 										})
 											.then(() => next())
 											.catch(next);
@@ -101,7 +103,7 @@ class _SongsModule extends CoreClass {
 							(song, next) => {
 								CacheModule.runJob("HSET", {
 									table: "songs",
-									key: song.youtubeId,
+									key: song.mediaSource,
 									value: SongsModule.SongSchemaCache(song)
 								})
 									.then(() => next())
@@ -123,7 +125,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -169,57 +170,126 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets songs by id from Mongo
-	 *
 	 * @param {object} payload - object containing the payload
-	 * @param {string} payload.youtubeIds - the youtube ids of the songs we are trying to get
+	 * @param {string} payload.mediaSources - the media sources of the songs we are trying to get
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_SONGS(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
-					next => SongsModule.SongModel.find({ youtubeId: { $in: payload.youtubeIds } }, next),
+					next => SongsModule.SongModel.find({ mediaSource: { $in: payload.mediaSources } }, next),
 
 					(songs, next) => {
-						const youtubeIds = payload.youtubeIds.filter(
-							youtubeId => !songs.find(song => song.youtubeId === youtubeId)
+						const mediaSources = payload.mediaSources.filter(
+							mediaSource => !songs.find(song => song.mediaSource === mediaSource)
 						);
-						return YouTubeModule.youtubeVideoModel.find(
-							{ youtubeId: { $in: youtubeIds } },
-							(err, videos) => {
-								if (err) next(err);
-								else {
-									const youtubeVideos = videos.map(video => {
-										const { youtubeId, title, author, duration, thumbnail } = video;
-										return {
-											youtubeId,
-											title,
-											artists: [author],
-											genres: [],
-											tags: [],
-											duration,
-											skipDuration: 0,
-											thumbnail:
-												thumbnail || `https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`,
-											requestedBy: null,
-											requestedAt: Date.now(),
-											verified: false,
-											youtubeVideoId: video._id
-										};
-									});
-									next(
-										null,
-										payload.youtubeIds
-											.map(
-												youtubeId =>
-													songs.find(song => song.youtubeId === youtubeId) ||
-													youtubeVideos.find(video => video.youtubeId === youtubeId)
-											)
-											.filter(song => !!song)
-									);
+
+						if (mediaSources.length === 0) return next(null, songs);
+
+						const mediaSourceTypes = [];
+						mediaSources.forEach(mediaSource => {
+							const mediaSourceType = mediaSource.split(":")[0];
+							if (mediaSourceTypes.indexOf(mediaSourceType) === -1)
+								mediaSourceTypes.push(mediaSourceType);
+						});
+
+						if (mediaSourceTypes.length !== 1)
+							return next(`Expected 1 media source types, got ${mediaSourceTypes.length}.`);
+						const [mediaSourceType] = mediaSourceTypes;
+
+						if (mediaSourceType === "youtube")
+							return YouTubeModule.youtubeVideoModel.find(
+								{
+									youtubeId: {
+										$in: mediaSources
+											.filter(mediaSource => mediaSource.startsWith("youtube:"))
+											.map(mediaSource => mediaSource.split(":")[1])
+									}
+								},
+								(err, videos) => {
+									if (err) next(err);
+									else {
+										const youtubeVideos = videos.map(video => {
+											const { youtubeId, title, author, duration, thumbnail } = video;
+											return {
+												mediaSource: `youtube:${youtubeId}`,
+												title,
+												artists: [author],
+												genres: [],
+												tags: [],
+												duration,
+												skipDuration: 0,
+												thumbnail:
+													thumbnail ||
+													`https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`,
+												requestedBy: null,
+												requestedAt: Date.now(),
+												verified: false,
+												youtubeVideoId: video._id
+											};
+										});
+										next(
+											null,
+											payload.mediaSources
+												.map(
+													mediaSource =>
+														songs.find(song => song.mediaSource === mediaSource) ||
+														youtubeVideos.find(video => video.mediaSource === mediaSource)
+												)
+												.filter(song => !!song)
+										);
+									}
 								}
-							}
-						);
+							);
+
+						if (config.get("experimental.soundcloud") && mediaSourceType === "soundcloud")
+							return SoundCloudModule.soundcloudTrackModel.find(
+								{
+									trackId: {
+										$in: mediaSources
+											.filter(mediaSource => mediaSource.startsWith("soundcloud:"))
+											.map(mediaSource => mediaSource.split(":")[1])
+									}
+								},
+								(err, tracks) => {
+									if (err) next(err);
+									else {
+										const soundcloudSongs = tracks.map(track => {
+											const { trackId, title, username, duration, artworkUrl } = track;
+											return {
+												mediaSource: `soundcloud:${trackId}`,
+												title,
+												artists: [username],
+												genres: [],
+												tags: [],
+												duration,
+												skipDuration: 0,
+												thumbnail: artworkUrl,
+												requestedBy: null,
+												requestedAt: Date.now(),
+												verified: false,
+												soundcloudTrackId: track._id
+											};
+										});
+
+										next(
+											null,
+											payload.mediaSources
+												.map(
+													mediaSource =>
+														songs.find(song => song.mediaSource === mediaSource) ||
+														soundcloudSongs.find(
+															soundcloudSong => soundcloudSong.mediaSource === mediaSource
+														)
+												)
+												.filter(song => !!song)
+										);
+									}
+								}
+							);
+
+						return next(`Unknown media source specified: ${mediaSourceType}.`);
 					}
 				],
 				(err, songs) => {
@@ -232,7 +302,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Create song
-	 *
 	 * @param {object} payload - an object containing the payload
 	 * @param {string} payload.song - the song object
 	 * @param {string} payload.userId - the user id of the person requesting the song
@@ -276,7 +345,7 @@ class _SongsModule extends CoreClass {
 					},
 
 					(song, next) => {
-						MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId: song.youtubeId }, this)
+						MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: song.mediaSource }, this)
 							.then(() => next(null, song))
 							.catch(next);
 					},
@@ -300,7 +369,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * 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
 	 * @param {string} payload.oldStatus - old status of song being updated (optional)
@@ -336,11 +404,19 @@ class _SongsModule extends CoreClass {
 							this
 						)
 							.then(() => {
-								const { _id, youtubeId, title, artists, thumbnail, duration, skipDuration, verified } =
-									song;
+								const {
+									_id,
+									mediaSource,
+									title,
+									artists,
+									thumbnail,
+									duration,
+									skipDuration,
+									verified
+								} = song;
 								next(null, {
 									_id,
-									youtubeId,
+									mediaSource,
 									title,
 									artists,
 									thumbnail,
@@ -361,7 +437,7 @@ class _SongsModule extends CoreClass {
 
 					(song, next) => {
 						playlistModel.updateMany(
-							{ "songs.youtubeId": song.youtubeId },
+							{ "songs.mediaSource": song.mediaSource },
 							{ $set: { "songs.$": song } },
 							err => {
 								if (err) next(err);
@@ -410,7 +486,7 @@ class _SongsModule extends CoreClass {
 
 					(song, next) => {
 						stationModel.updateMany(
-							{ "queue.youtubeId": song.youtubeId },
+							{ "queue.mediaSource": song.mediaSource },
 							{ $set: { "queue.$": song } },
 							err => {
 								if (err) next(err);
@@ -483,7 +559,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets multiple songs from id from Mongo and updates the cache with it
-	 *
 	 * @param {object} payload - an object containing the payload
 	 * @param {Array} payload.songIds - the ids of the songs we are trying to update
 	 * @param {string} payload.oldStatus - old status of song being updated (optional)
@@ -563,10 +638,10 @@ class _SongsModule extends CoreClass {
 						this.publishProgress({ status: "update", message: `Updating songs (stage 4)` });
 
 						const trimmedSongs = songs.map(song => {
-							const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
+							const { _id, mediaSource, title, artists, thumbnail, duration, verified } = song;
 							return {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -653,12 +728,12 @@ class _SongsModule extends CoreClass {
 								async.waterfall(
 									[
 										next => {
-											const { youtubeId, title, artists, thumbnail, duration, verified } = song;
+											const { mediaSource, title, artists, thumbnail, duration, verified } = song;
 											stationModel.updateMany(
 												{ "queue._id": song._id },
 												{
 													$set: {
-														"queue.$.youtubeId": youtubeId,
+														"queue.$.mediaSource": mediaSource,
 														"queue.$.title": title,
 														"queue.$.artists": artists,
 														"queue.$.thumbnail": thumbnail,
@@ -779,7 +854,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Updates all songs
-	 *
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	UPDATE_ALL_SONGS() {
@@ -904,7 +978,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Searches through songs
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.query - the query
 	 * @param {string} payload.includeUnverified - include unverified songs
@@ -948,11 +1021,21 @@ class _SongsModule extends CoreClass {
 						const page = payload.page ? payload.page : 1;
 						const pageSize = 15;
 						const skipAmount = pageSize * (page - 1);
+						const query = { $or: filterArray };
+
+						const mediaSources = [];
+						if (!config.get("experimental.soundcloud")) {
+							mediaSources.push(/^soundcloud:/);
+						}
+						if (!config.get("experimental.spotify")) {
+							mediaSources.push(/^spotify:/);
+						}
+						if (mediaSources.length > 0) query.mediaSource = { $nin: mediaSources };
 
-						SongsModule.SongModel.find({ $or: filterArray }).count((err, count) => {
+						SongsModule.SongModel.find(query).count((err, count) => {
 							if (err) next(err);
 							else {
-								SongsModule.SongModel.find({ $or: filterArray })
+								SongsModule.SongModel.find(query)
 									.skip(skipAmount)
 									.limit(pageSize)
 									.exec((err, songs) => {
@@ -976,10 +1059,10 @@ class _SongsModule extends CoreClass {
 						else if (payload.trimmed) {
 							next(null, {
 								songs: data.songs.map(song => {
-									const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
+									const { _id, mediaSource, title, artists, thumbnail, duration, verified } = song;
 									return {
 										_id,
-										youtubeId,
+										mediaSource,
 										title,
 										artists,
 										thumbnail,
@@ -1002,7 +1085,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets an array of all genres
-	 *
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_ALL_GENRES() {
@@ -1037,7 +1119,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -1068,7 +1149,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets a orphaned playlist songs
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_ORPHANED_PLAYLIST_SONGS() {
@@ -1077,7 +1157,7 @@ class _SongsModule extends CoreClass {
 				playlistModel.find({}, (err, playlists) => {
 					if (err) reject(new Error(err));
 					else {
-						SongsModule.SongModel.find({}, { _id: true, youtubeId: true }, (err, songs) => {
+						SongsModule.SongModel.find({}, { _id: true, mediaSource: true }, (err, songs) => {
 							if (err) reject(new Error(err));
 							else {
 								const songIds = songs.map(song => song._id.toString());
@@ -1089,9 +1169,9 @@ class _SongsModule extends CoreClass {
 										playlist.songs.forEach(song => {
 											if (
 												(!song._id || songIds.indexOf(song._id.toString() === -1)) &&
-												!orphanedYoutubeIds.has(song.youtubeId)
+												!orphanedYoutubeIds.has(song.mediaSource)
 											) {
-												orphanedYoutubeIds.add(song.youtubeId);
+												orphanedYoutubeIds.add(song.mediaSource);
 											}
 										});
 										next();
@@ -1110,7 +1190,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Requests all orphaned playlist songs, adding them to the database
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	REQUEST_ORPHANED_PLAYLIST_SONGS() {
@@ -1118,29 +1197,31 @@ class _SongsModule extends CoreClass {
 			DBModule.runJob("GET_MODEL", { modelName: "playlist" })
 				.then(playlistModel => {
 					SongsModule.runJob("GET_ORPHANED_PLAYLIST_SONGS", {}, this).then(response => {
-						const { youtubeIds } = response;
+						const { mediaSources } = response;
 						const playlistsToUpdate = new Set();
 
 						async.eachLimit(
-							youtubeIds,
+							mediaSources,
 							1,
-							(youtubeId, next) => {
+							(mediaSource, next) => {
 								async.waterfall(
 									[
 										next => {
 											this.publishProgress({
 												status: "update",
-												message: `Requesting "${youtubeId}"`
+												message: `Requesting "${mediaSource}"`
 											});
 											console.log(
-												youtubeId,
-												`this is song ${youtubeIds.indexOf(youtubeId) + 1}/${youtubeIds.length}`
+												mediaSource,
+												`this is song ${mediaSources.indexOf(mediaSource) + 1}/${
+													mediaSources.length
+												}`
 											);
 											setTimeout(next, 150);
 										},
 
 										next => {
-											MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+											MediaModule.runJob("GET_MEDIA", { mediaSource }, this)
 												.then(res => next(null, res.song))
 												.catch(next);
 										},
@@ -1149,7 +1230,7 @@ class _SongsModule extends CoreClass {
 											const { _id, title, artists, thumbnail, duration, verified } = song;
 											const trimmedSong = {
 												_id,
-												youtubeId,
+												mediaSource,
 												title,
 												artists,
 												thumbnail,
@@ -1157,7 +1238,7 @@ class _SongsModule extends CoreClass {
 												verified
 											};
 											playlistModel.updateMany(
-												{ "songs.youtubeId": song.youtubeId },
+												{ "songs.mediaSource": song.mediaSource },
 												{ $set: { "songs.$": trimmedSong } },
 												err => {
 													next(err, song);
@@ -1215,7 +1296,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets a list of all genres
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_GENRES() {
@@ -1236,7 +1316,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets a list of all artists
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_ARTISTS() {
@@ -1257,7 +1336,6 @@ class _SongsModule extends CoreClass {
 
 	/**
 	 * Gets a list of all tags
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_TAGS() {
@@ -1275,6 +1353,14 @@ class _SongsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets a list of all media sources
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALL_MEDIA_SOURCES() {
+		return SongsModule.SongModel.distinct("mediaSource");
+	}
 }
 
 export default new _SongsModule();

+ 696 - 0
backend/logic/soundcloud.js

@@ -0,0 +1,696 @@
+import mongoose from "mongoose";
+import async from "async";
+import config from "config";
+import sckey from "soundcloud-key-fetch";
+
+import * as rax from "retry-axios";
+import axios from "axios";
+
+import CoreClass from "../core";
+
+let SoundCloudModule;
+let DBModule;
+let CacheModule;
+let MediaModule;
+
+const soundcloudTrackObjectToMusareTrackObject = soundcloudTrackObject => {
+	const {
+		id,
+		title,
+		artwork_url: artworkUrl,
+		created_at: createdAt,
+		duration,
+		genre,
+		kind,
+		license,
+		likes_count: likesCount,
+		playback_count: playbackCount,
+		public: _public,
+		tag_list: tagList,
+		user_id: userId,
+		user,
+		track_format: trackFormat,
+		permalink,
+		monetization_model: monetizationModel,
+		policy,
+		streamable,
+		sharing,
+		state,
+		embeddable_by: embeddableBy
+	} = soundcloudTrackObject;
+
+	return {
+		trackId: id,
+		title,
+		artworkUrl,
+		soundcloudCreatedAt: new Date(createdAt),
+		duration: duration / 1000,
+		genre,
+		kind,
+		license,
+		likesCount,
+		playbackCount,
+		public: _public,
+		tagList,
+		userId,
+		username: user.username,
+		userPermalink: user.permalink,
+		trackFormat,
+		permalink,
+		monetizationModel,
+		policy,
+		streamable,
+		sharing,
+		state,
+		embeddableBy
+	};
+};
+
+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();
+	}
+}
+
+class _SoundCloudModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("soundcloud");
+
+		SoundCloudModule = this;
+	}
+
+	/**
+	 * Initialises the soundcloud module
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		DBModule = this.moduleManager.modules.db;
+		CacheModule = this.moduleManager.modules.cache;
+		MediaModule = this.moduleManager.modules.media;
+
+		this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "soundcloudTrack"
+		});
+
+		return new Promise((resolve, reject) => {
+			this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));
+			this.requestTimeout = config.get("apis.soundcloud.requestTimeout");
+
+			this.axios = axios.create();
+			this.axios.defaults.raxConfig = {
+				instance: this.axios,
+				retry: config.get("apis.soundcloud.retryAmount"),
+				noResponseRetries: config.get("apis.soundcloud.retryAmount")
+			};
+			rax.attach(this.axios);
+
+			this.apiKey = null;
+
+			SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, null, -1)
+				.then(result => {
+					if (result) {
+						resolve();
+						return;
+					}
+
+					SoundCloudModule.runJob("GENERATE_SOUNDCLOUD_API_KEY", {}, null, -1)
+						.then(() => {
+							resolve();
+						})
+						.catch(reject);
+				})
+				.catch(reject);
+		});
+	}
+
+	/**
+	 * Generates/fetches a new SoundCloud API key
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GENERATE_SOUNDCLOUD_API_KEY() {
+		return new Promise((resolve, reject) => {
+			this.log("INFO", "Fetching new SoundCloud API key.");
+			sckey
+				.fetchKey()
+				.then(soundcloudApiKey => {
+					if (!soundcloudApiKey) {
+						this.log("ERROR", "Couldn't fetch new SoundCloud API key.");
+						reject(new Error("Couldn't fetch SoundCloud key."));
+						return;
+					}
+
+					SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
+
+					CacheModule.runJob("SET", { key: "soundcloudApiKey", value: soundcloudApiKey }, this)
+						.then(() => {
+							SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, this).then(result => {
+								if (!result) {
+									this.log("ERROR", "Fetched SoundCloud API key is invalid.");
+									reject(new Error("SoundCloud key isn't valid."));
+								} else {
+									this.log("INFO", "Fetched new valid SoundCloud API key.");
+									resolve();
+								}
+							});
+						})
+						.catch(err => {
+							this.log("ERROR", `Couldn't set new SoundCloud API key in cache. Error: ${err.message}`);
+							reject(err);
+						});
+				})
+				.catch(err => {
+					this.log("ERROR", `Couldn't fetch new SoundCloud API key. Error: ${err.message}`);
+					reject(new Error("Couldn't fetch SoundCloud key."));
+				});
+		});
+	}
+
+	/**
+	 * Tests the stored SoundCloud API key
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	TEST_SOUNDCLOUD_API_KEY() {
+		return new Promise((resolve, reject) => {
+			this.log("INFO", "Testing SoundCloud API key.");
+			CacheModule.runJob("GET", { key: "soundcloudApiKey" }, this).then(soundcloudApiKey => {
+				if (!soundcloudApiKey) {
+					this.log("ERROR", "No SoundCloud API key found in cache.");
+					resolve(false);
+					return;
+				}
+
+				SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
+
+				sckey
+					.testKey(soundcloudApiKey)
+					.then(res => {
+						this.log("INFO", `Tested SoundCloud API key. Result: ${res}`);
+						resolve(res);
+					})
+					.catch(err => {
+						this.log("ERROR", `Testing SoundCloud API key error: ${err.message}`);
+						reject(err);
+					});
+			});
+		});
+	}
+
+	/**
+	 * Perform SoundCloud API get track request
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.trackId - the SoundCloud track id to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			const { trackId } = payload;
+
+			SoundCloudModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api-v2.soundcloud.com/tracks/${trackId}`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform SoundCloud API call
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - request url
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_CALL(payload) {
+		return new Promise((resolve, reject) => {
+			const { url } = payload;
+
+			const { soundcloudApiKey } = SoundCloudModule;
+
+			const params = {
+				client_id: soundcloudApiKey
+			};
+
+			SoundCloudModule.axios
+				.get(url, {
+					params,
+					timeout: SoundCloudModule.requestTimeout
+				})
+				.then(response => {
+					if (response.data.error) {
+						reject(new Error(response.data.error));
+					} else {
+						resolve({ response });
+					}
+				})
+				.catch(err => {
+					reject(err);
+				});
+			// }
+		});
+	}
+
+	/**
+	 * Create SoundCloud track
+	 * @param {object} payload - an object containing the payload
+	 * @param {object} payload.soundcloudTrack - the soundcloudTrack object
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CREATE_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const { soundcloudTrack } = payload;
+						if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");
+						else {
+							SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);
+						}
+					},
+
+					(soundcloudTracks, next) => {
+						const mediaSources = soundcloudTracks.map(
+							soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`
+						);
+						async.eachLimit(
+							mediaSources,
+							2,
+							(mediaSource, next) => {
+								MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
+									.then(() => next())
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else next(null, soundcloudTracks);
+							}
+						);
+					}
+				],
+				(err, soundcloudTracks) => {
+					if (err) reject(new Error(err));
+					else resolve({ soundcloudTracks });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Get SoundCloud track
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.identifier - the soundcloud track ObjectId or track id
+	 * @param {boolean} payload.createMissing - attempt to fetch and create track if not in db
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const query = mongoose.isObjectIdOrHexString(payload.identifier)
+							? { _id: payload.identifier }
+							: { trackId: payload.identifier };
+						return SoundCloudModule.soundcloudTrackModel.findOne(query, next);
+					},
+
+					(track, next) => {
+						if (track) return next(null, track, false);
+						if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
+							return next("SoundCloud track not found.");
+						return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
+							.then(({ response }) => {
+								const { data } = response;
+								if (!data || !data.id)
+									return next("The specified track does not exist or cannot be publicly accessed.");
+
+								const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
+
+								return next(null, false, soundcloudTrack);
+							})
+							.catch(next);
+					},
+					(track, soundcloudTrack, next) => {
+						if (track) return next(null, track, true);
+						return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
+							.then(res => {
+								if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);
+								else next("SoundCloud track not found.");
+							})
+							.catch(next);
+					}
+				],
+				(err, track, existing) => {
+					if (err) reject(new Error(err));
+					else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));
+					else resolve({ track, existing });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Tries to get a SoundCloud track from a URL
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.identifier - the SoundCloud track URL
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_TRACK_FROM_URL(payload) {
+		return new Promise((resolve, reject) => {
+			const scRegex =
+				/soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;
+
+			async.waterfall(
+				[
+					next => {
+						const match = scRegex.exec(payload.identifier);
+
+						if (!match || !match.groups) {
+							next("Invalid SoundCloud URL.");
+							return;
+						}
+
+						const { userPermalink, permalink } = match.groups;
+
+						SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);
+					},
+
+					(_dbTrack, next) => {
+						if (_dbTrack) {
+							next(null, _dbTrack, true);
+							return;
+						}
+
+						SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)
+							.then(({ response }) => {
+								const { data } = response;
+								if (!data || !data.id) {
+									next("The provided URL does not exist or cannot be accessed.");
+									return;
+								}
+
+								if (data.kind !== "track") {
+									next(`Invalid URL provided. Kind got: ${data.kind}.`);
+									return;
+								}
+
+								// TODO get more data here
+
+								const { id: trackId } = data;
+
+								SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {
+									if (err) next(err);
+									else if (dbTrack) {
+										next(null, dbTrack, true);
+									} else {
+										const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
+
+										SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
+											.then(res => {
+												if (res.soundcloudTracks.length === 1)
+													next(null, res.soundcloudTracks[0], false);
+												else next("SoundCloud track not found.");
+											})
+											.catch(next);
+									}
+								});
+							})
+							.catch(next);
+					}
+				],
+				(err, track, existing) => {
+					if (err) reject(new Error(err));
+					else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));
+					else resolve({ track, existing });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a SoundCloud playlist
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - the url of the SoundCloud playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)
+							.then(async ({ response }) => {
+								const { data } = response;
+								if (!data || !data.id)
+									return next("The provided URL does not exist or cannot be accessed.");
+
+								let tracks;
+
+								if (data.kind === "user")
+									tracks = (
+										await SoundCloudModule.runJob(
+											"GET_ARTIST_TRACKS",
+											{
+												artistId: data.id
+											},
+											this
+										)
+									).tracks;
+								else if (data.kind !== "playlist" && data.kind !== "system-playlist")
+									return next(`Invalid URL provided. Kind got: ${data.kind}.`);
+								else tracks = data.tracks;
+
+								const soundcloudTrackIds = tracks.map(track => track.id);
+
+								return next(null, soundcloudTrackIds);
+							})
+							.catch(next);
+					}
+				],
+				(err, soundcloudTrackIds) => {
+					if (err && err !== true) {
+						SoundCloudModule.log(
+							"ERROR",
+							"GET_PLAYLIST",
+							"Some error has occurred.",
+							typeof err === "string" ? err : err.message
+						);
+						reject(new Error(typeof err === "string" ? err : err.message));
+					} else {
+						resolve({ songs: soundcloudTrackIds });
+					}
+				}
+			);
+
+			// kind;
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a SoundCloud artist
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artistId - the id of the SoundCloud artist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ARTIST_TRACKS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						let first = true;
+						let nextHref = null;
+
+						let tracks = [];
+
+						async.whilst(
+							next => {
+								if (nextHref || first) next(null, true);
+								else next(null, false);
+							},
+							next => {
+								let job;
+
+								if (first) {
+									job = SoundCloudModule.runJob(
+										"API_GET_ARTIST_TRACKS",
+										{ artistId: payload.artistId },
+										this
+									);
+									first = false;
+								} else job = SoundCloudModule.runJob("API_GET_ARTIST_TRACKS", { nextHref }, this);
+
+								job.then(({ response }) => {
+									const { data } = response;
+									const { collection, next_href: _nextHref } = data;
+
+									nextHref = _nextHref;
+									tracks = tracks.concat(collection);
+
+									setTimeout(() => {
+										next();
+									}, 500);
+								}).catch(err => {
+									next(err);
+								});
+							},
+							err => {
+								if (err) return next(err);
+
+								return next(null, tracks);
+							}
+						);
+					}
+				],
+				(err, tracks) => {
+					if (err && err !== true) {
+						SoundCloudModule.log(
+							"ERROR",
+							"GET_ARTIST_TRACKS",
+							"Some error has occurred.",
+							typeof err === "string" ? err : err.message
+						);
+						reject(new Error(typeof err === "string" ? err : err.message));
+					} else {
+						resolve({ tracks });
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Get Soundcloud artists
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.userPermalinks - an array of Soundcloud user permalinks
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_ARTISTS_FROM_PERMALINKS(payload) {
+		const getArtists = async userPermalinks => {
+			const jobsToRun = [];
+
+			userPermalinks.forEach(userPermalink => {
+				const url = `https://soundcloud.com/${userPermalink}`;
+
+				jobsToRun.push(SoundCloudModule.runJob("API_RESOLVE", { url }, this));
+			});
+
+			const jobResponses = await Promise.all(jobsToRun);
+
+			return jobResponses
+				.map(jobResponse => jobResponse.response.data)
+				.map(artist => ({
+					artistId: artist.id,
+					username: artist.username,
+					avatarUrl: artist.avatar_url,
+					permalink: artist.permalink,
+					rawData: artist
+				}));
+		};
+
+		const { userPermalinks } = payload;
+		const existingArtists = [];
+
+		const existingUserPermalinks = existingArtists.map(existingArtists => existingArtists.userPermalink);
+		// const existingArtistsObjectIds = existingArtists.map(existingArtists => existingArtists._id.toString());
+
+		if (userPermalinks.length === existingArtists.length) return { artists: existingArtists };
+
+		const missingUserPermalinks = userPermalinks.filter(
+			userPermalink => existingUserPermalinks.indexOf(userPermalink) === -1
+		);
+
+		if (missingUserPermalinks.length === 0) return { videos: existingArtists };
+
+		const newArtists = await getArtists(missingUserPermalinks);
+
+		// await SoundcloudModule.soundcloudArtistsModel.insertMany(newArtists);
+
+		return { artists: existingArtists.concat(newArtists) };
+	}
+
+	/**
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - the url of the SoundCloud resource
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	API_RESOLVE(payload) {
+		return new Promise((resolve, reject) => {
+			const { url } = payload;
+
+			SoundCloudModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Calls the API_CALL with the proper URL to get artist/user tracks
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artistId - the id of the SoundCloud artist
+	 * @param {string} payload.nextHref - the next url to call
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	API_GET_ARTIST_TRACKS(payload) {
+		return new Promise((resolve, reject) => {
+			const { artistId, nextHref } = payload;
+
+			SoundCloudModule.runJob(
+				"API_CALL",
+				{
+					url: artistId
+						? `https://api-v2.soundcloud.com/users/${artistId}/tracks?access=playable&limit=50`
+						: nextHref
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+}
+
+export default new _SoundCloudModule();

+ 1479 - 0
backend/logic/spotify.js

@@ -0,0 +1,1479 @@
+import mongoose from "mongoose";
+import async from "async";
+import config from "config";
+
+import * as rax from "retry-axios";
+import axios from "axios";
+import url from "url";
+
+import CoreClass from "../core";
+
+let SpotifyModule;
+let SoundcloudModule;
+let DBModule;
+let CacheModule;
+let MediaModule;
+let MusicBrainzModule;
+let WikiDataModule;
+
+const youtubeVideoUrlRegex =
+	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
+
+const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => ({
+	trackId: spotifyTrackObject.id,
+	name: spotifyTrackObject.name,
+	albumId: spotifyTrackObject.album.id,
+	albumTitle: spotifyTrackObject.album.title,
+	albumImageUrl: spotifyTrackObject.album.images[0].url,
+	artists: spotifyTrackObject.artists.map(artist => artist.name),
+	artistIds: spotifyTrackObject.artists.map(artist => artist.id),
+	duration: spotifyTrackObject.duration_ms / 1000,
+	explicit: spotifyTrackObject.explicit,
+	externalIds: spotifyTrackObject.external_ids,
+	popularity: spotifyTrackObject.popularity,
+	isLocal: spotifyTrackObject.is_local
+});
+
+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();
+	}
+}
+
+class _SpotifyModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("spotify");
+
+		SpotifyModule = this;
+	}
+
+	/**
+	 * Initialises the spotify module
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		DBModule = this.moduleManager.modules.db;
+		CacheModule = this.moduleManager.modules.cache;
+		MediaModule = this.moduleManager.modules.media;
+		MusicBrainzModule = this.moduleManager.modules.musicbrainz;
+		SoundcloudModule = this.moduleManager.modules.soundcloud;
+		WikiDataModule = this.moduleManager.modules.wikidata;
+
+		this.spotifyTrackModel = this.SpotifyTrackModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "spotifyTrack"
+		});
+		this.spotifyAlbumModel = this.SpotifyAlbumModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "spotifyAlbum"
+		});
+		this.spotifyArtistModel = this.SpotifyArtistModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "spotifyArtist"
+		});
+
+		return new Promise((resolve, reject) => {
+			if (!config.get("apis.spotify.enabled")) {
+				reject(new Error("Spotify is not enabled."));
+				return;
+			}
+
+			this.rateLimiter = new RateLimitter(config.get("apis.spotify.rateLimit"));
+			this.requestTimeout = config.get("apis.spotify.requestTimeout");
+
+			this.axios = axios.create();
+			this.axios.defaults.raxConfig = {
+				instance: this.axios,
+				retry: config.get("apis.spotify.retryAmount"),
+				noResponseRetries: config.get("apis.spotify.retryAmount")
+			};
+			rax.attach(this.axios);
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Fetches a Spotify API token from either the cache, or Spotify using the client id and secret from the config
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_API_TOKEN() {
+		return new Promise((resolve, reject) => {
+			CacheModule.runJob("GET", { key: "spotifyApiKey" }, this).then(spotifyApiKey => {
+				if (spotifyApiKey) {
+					resolve(spotifyApiKey);
+					return;
+				}
+
+				this.log("INFO", `No Spotify API token stored in cache, requesting new token.`);
+
+				const clientId = config.get("apis.spotify.clientId");
+				const clientSecret = config.get("apis.spotify.clientSecret");
+				const unencoded = `${clientId}:${clientSecret}`;
+				const encoded = Buffer.from(unencoded).toString("base64");
+
+				const params = new url.URLSearchParams({ grant_type: "client_credentials" });
+
+				SpotifyModule.axios
+					.post("https://accounts.spotify.com/api/token", params.toString(), {
+						headers: {
+							Authorization: `Basic ${encoded}`,
+							"Content-Type": "application/x-www-form-urlencoded"
+						}
+					})
+					.then(res => {
+						const { access_token: accessToken, expires_in: expiresIn } = res.data;
+
+						// TODO TTL can be later if stuck in queue
+						CacheModule.runJob(
+							"SET",
+							{ key: "spotifyApiKey", value: accessToken, ttl: expiresIn - 30 },
+							this
+						)
+							.then(spotifyApiKey => {
+								this.log(
+									"SUCCESS",
+									`Stored new Spotify API token in cache. Expires in ${expiresIn - 30}`
+								);
+								resolve(spotifyApiKey);
+							})
+							.catch(err => {
+								this.log(
+									"ERROR",
+									`Failed to store new Spotify API token in cache.`,
+									typeof err === "string" ? err : err.message
+								);
+								reject(err);
+							});
+					})
+					.catch(err => {
+						this.log(
+							"ERROR",
+							`Failed to get new Spotify API token.`,
+							typeof err === "string" ? err : err.message
+						);
+						reject(err);
+					});
+			});
+		});
+	}
+
+	/**
+	 * Perform Spotify API get albums request
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.albumIds - the album ids to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_ALBUMS(payload) {
+		return new Promise((resolve, reject) => {
+			const { albumIds } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api.spotify.com/v1/albums`,
+					params: {
+						ids: albumIds.join(",")
+					}
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform Spotify API get artists request
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.artistIds - the artist ids to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_ARTISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const { artistIds } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api.spotify.com/v1/artists`,
+					params: {
+						ids: artistIds.join(",")
+					}
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform Spotify API get track request
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.trackId - the Spotify track id to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			const { trackId } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api.spotify.com/v1/tracks/${trackId}`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform Spotify API get playlist request
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the Spotify playlist id to get songs from
+	 * @param {string} payload.nextUrl - the next URL to use
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId, nextUrl } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: nextUrl || `https://api.spotify.com/v1/playlists/${playlistId}/tracks`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform Spotify API call
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - request url
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_CALL(payload) {
+		return new Promise((resolve, reject) => {
+			const { url, params } = payload;
+
+			SpotifyModule.runJob("GET_API_TOKEN", {}, this)
+				.then(spotifyApiToken => {
+					SpotifyModule.axios
+						.get(url, {
+							headers: {
+								Authorization: `Bearer ${spotifyApiToken}`
+							},
+							timeout: SpotifyModule.requestTimeout,
+							params
+						})
+						.then(response => {
+							if (response.data.error) {
+								reject(new Error(response.data.error));
+							} else {
+								resolve({ response });
+							}
+						})
+						.catch(err => {
+							reject(err);
+						});
+				})
+				.catch(err => {
+					this.log(
+						"ERROR",
+						`Spotify API call failed as an error occured whilst getting the API token`,
+						typeof err === "string" ? err : err.message
+					);
+					resolve(err);
+				});
+		});
+	}
+
+	/**
+	 * Create Spotify track
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.spotifyTracks - the spotifyTracks
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CREATE_TRACKS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const { spotifyTracks } = payload;
+						if (!Array.isArray(spotifyTracks)) next("Invalid spotifyTracks type");
+						else {
+							const trackIds = spotifyTracks.map(spotifyTrack => spotifyTrack.trackId);
+
+							SpotifyModule.spotifyTrackModel.find({ trackId: trackIds }, (err, existingTracks) => {
+								if (err) {
+									next(err);
+									return;
+								}
+
+								const existingTrackIds = existingTracks.map(existingTrack => existingTrack.trackId);
+
+								const newSpotifyTracks = spotifyTracks.filter(
+									spotifyTrack => existingTrackIds.indexOf(spotifyTrack.trackId) === -1
+								);
+
+								SpotifyModule.spotifyTrackModel.insertMany(newSpotifyTracks, next);
+							});
+						}
+					},
+
+					(spotifyTracks, next) => {
+						const mediaSources = spotifyTracks.map(spotifyTrack => `spotify:${spotifyTrack.trackId}`);
+						async.eachLimit(
+							mediaSources,
+							2,
+							(mediaSource, next) => {
+								MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
+									.then(() => next())
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else next(null, spotifyTracks);
+							}
+						);
+					}
+				],
+				(err, spotifyTracks) => {
+					if (err) reject(new Error(err));
+					else resolve({ spotifyTracks });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Create Spotify albums
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.spotifyAlbums - the Spotify albums
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async CREATE_ALBUMS(payload) {
+		const { spotifyAlbums } = payload;
+
+		if (!Array.isArray(spotifyAlbums)) throw new Error("Invalid spotifyAlbums type");
+
+		const albumIds = spotifyAlbums.map(spotifyAlbum => spotifyAlbum.albumId);
+
+		const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(
+			album => album._doc
+		);
+		const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);
+
+		const newSpotifyAlbums = spotifyAlbums.filter(
+			spotifyAlbum => existingAlbumIds.indexOf(spotifyAlbum.albumId) === -1
+		);
+
+		if (newSpotifyAlbums.length === 0) return existingAlbums;
+
+		await SpotifyModule.spotifyAlbumModel.insertMany(newSpotifyAlbums);
+
+		return existingAlbums.concat(newSpotifyAlbums);
+	}
+
+	/**
+	 * Create Spotify artists
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.spotifyArtists - the Spotify artists
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async CREATE_ARTISTS(payload) {
+		const { spotifyArtists } = payload;
+
+		if (!Array.isArray(spotifyArtists)) throw new Error("Invalid spotifyArtists type");
+
+		const artistIds = spotifyArtists.map(spotifyArtist => spotifyArtist.artistId);
+
+		const existingArtists = (await SpotifyModule.spotifyArtistModel.find({ artistId: artistIds })).map(
+			artist => artist._doc
+		);
+		const existingArtistIds = existingArtists.map(existingArtist => existingArtist.artistId);
+
+		const newSpotifyArtists = spotifyArtists.filter(
+			spotifyArtist => existingArtistIds.indexOf(spotifyArtist.artistId) === -1
+		);
+
+		if (newSpotifyArtists.length === 0) return existingArtists;
+
+		await SpotifyModule.spotifyArtistModel.insertMany(newSpotifyArtists);
+
+		return existingArtists.concat(newSpotifyArtists);
+	}
+
+	/**
+	 * Gets tracks from media sources
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.mediaSources - the media sources to get tracks from
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_TRACKS_FROM_MEDIA_SOURCES(payload) {
+		return new Promise((resolve, reject) => {
+			const { mediaSources } = payload;
+
+			const responses = {};
+
+			const promises = [];
+
+			mediaSources.forEach(mediaSource => {
+				promises.push(
+					new Promise(resolve => {
+						const trackId = mediaSource.split(":")[1];
+						SpotifyModule.runJob("GET_TRACK", { identifier: trackId, createMissing: true }, this)
+							.then(({ track }) => {
+								responses[mediaSource] = track;
+							})
+							.catch(err => {
+								SpotifyModule.log(
+									"ERROR",
+									`Getting tracked with media source ${mediaSource} failed.`,
+									typeof err === "string" ? err : err.message
+								);
+								responses[mediaSource] = typeof err === "string" ? err : err.message;
+							})
+							.finally(() => {
+								resolve();
+							});
+					})
+				);
+			});
+
+			Promise.all(promises)
+				.then(() => {
+					SpotifyModule.log("SUCCESS", `Got all tracks.`);
+					resolve({ tracks: responses });
+				})
+				.catch(reject);
+		});
+	}
+
+	/**
+	 * Gets albums from Spotify album ids
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.albumIds - the Spotify album ids
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALBUMS_FROM_IDS(payload) {
+		const { albumIds } = payload;
+
+		const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(
+			album => album._doc
+		);
+		const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);
+
+		const missingAlbumIds = albumIds.filter(albumId => existingAlbumIds.indexOf(albumId) === -1);
+
+		if (missingAlbumIds.length === 0) return existingAlbums;
+
+		const jobsToRun = [];
+
+		const chunkSize = 2;
+		while (missingAlbumIds.length > 0) {
+			const chunkedMissingAlbumIds = missingAlbumIds.splice(0, chunkSize);
+
+			jobsToRun.push(SpotifyModule.runJob("API_GET_ALBUMS", { albumIds: chunkedMissingAlbumIds }, this));
+		}
+
+		const jobResponses = await Promise.all(jobsToRun);
+
+		const newAlbums = jobResponses
+			.map(jobResponse => jobResponse.response.data.albums)
+			.flat()
+			.map(album => ({
+				albumId: album.id,
+				rawData: album
+			}));
+
+		await SpotifyModule.runJob("CREATE_ALBUMS", { spotifyAlbums: newAlbums }, this);
+
+		return existingAlbums.concat(newAlbums);
+	}
+
+	/**
+	 * Gets Spotify artists from Spotify artist ids
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.artistIds - the Spotify artist ids
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ARTISTS_FROM_IDS(payload) {
+		const { artistIds } = payload;
+
+		const existingArtists = (await SpotifyModule.spotifyArtistModel.find({ artistId: artistIds })).map(
+			artist => artist._doc
+		);
+		const existingArtistIds = existingArtists.map(existingArtist => existingArtist.artistId);
+
+		const missingArtistIds = artistIds.filter(artistId => existingArtistIds.indexOf(artistId) === -1);
+
+		if (missingArtistIds.length === 0) return existingArtists;
+
+		const jobsToRun = [];
+
+		const chunkSize = 50;
+		while (missingArtistIds.length > 0) {
+			const chunkedMissingArtistIds = missingArtistIds.splice(0, chunkSize);
+
+			jobsToRun.push(SpotifyModule.runJob("API_GET_ARTISTS", { artistIds: chunkedMissingArtistIds }, this));
+		}
+
+		const jobResponses = await Promise.all(jobsToRun);
+
+		const newArtists = jobResponses
+			.map(jobResponse => jobResponse.response.data.artists)
+			.flat()
+			.map(artist => ({
+				artistId: artist.id,
+				rawData: artist
+			}));
+
+		await SpotifyModule.runJob("CREATE_ARTISTS", { spotifyArtists: newArtists }, this);
+
+		return existingArtists.concat(newArtists);
+	}
+
+	/**
+	 * Get Spotify track
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.identifier - the spotify track ObjectId or track id
+	 * @param {boolean} payload.createMissing - attempt to fetch and create track if not in db
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const query = mongoose.isObjectIdOrHexString(payload.identifier)
+							? { _id: payload.identifier }
+							: { trackId: payload.identifier };
+
+						return SpotifyModule.spotifyTrackModel.findOne(query, next);
+					},
+
+					(track, next) => {
+						if (track) return next(null, track, false);
+						if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
+							return next("Spotify track not found.");
+						return SpotifyModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
+							.then(({ response }) => {
+								const { data } = response;
+
+								if (!data || !data.id)
+									return next("The specified track does not exist or cannot be publicly accessed.");
+
+								const spotifyTrack = spotifyTrackObjectToMusareTrackObject(data);
+
+								return next(null, false, spotifyTrack);
+							})
+							.catch(next);
+					},
+					(track, spotifyTrack, next) => {
+						if (track) return next(null, track, true);
+						return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks: [spotifyTrack] }, this)
+							.then(res => {
+								if (res.spotifyTracks.length === 1) next(null, res.spotifyTracks[0], false);
+								else next("Spotify track not found.");
+							})
+							.catch(next);
+					}
+				],
+				(err, track, existing) => {
+					if (err) reject(new Error(err));
+					else if (track.isLocal) reject(new Error("Track is local."));
+					else resolve({ track, existing });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Get Spotify album
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.identifier - the spotify album ObjectId or track id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_ALBUM(payload) {
+		const query = mongoose.isObjectIdOrHexString(payload.identifier)
+			? { _id: payload.identifier }
+			: { albumId: payload.identifier };
+
+		const album = await SpotifyModule.spotifyAlbumModel.findOne(query);
+
+		if (album) return album._doc;
+
+		return null;
+	}
+
+	/**
+	 * Returns an array of songs taken from a Spotify playlist
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - the id of the Spotify playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const spotifyPlaylistUrlRegex = /.+open\.spotify\.com\/playlist\/(?<playlistId>[A-Za-z0-9]+)/;
+			const match = spotifyPlaylistUrlRegex.exec(payload.url);
+
+			if (!match || !match.groups) {
+				SpotifyModule.log("ERROR", "GET_PLAYLIST", "Invalid Spotify playlist URL query.");
+				reject(new Error("Invalid playlist URL."));
+				return;
+			}
+
+			const { playlistId } = match.groups;
+
+			async.waterfall(
+				[
+					next => {
+						let spotifyTracks = [];
+						let total = -1;
+						let nextUrl = "";
+
+						async.whilst(
+							next => {
+								SpotifyModule.log(
+									"INFO",
+									`Getting playlist progress for job (${this.toString()}): ${
+										spotifyTracks.length
+									} tracks gotten so far. Total tracks: ${total}.`
+								);
+								next(null, nextUrl !== null);
+							},
+							next => {
+								// Add 250ms delay between each job request
+								setTimeout(() => {
+									SpotifyModule.runJob("API_GET_PLAYLIST", { playlistId, nextUrl }, this)
+										.then(({ response }) => {
+											const { data } = response;
+
+											if (!data) {
+												next("The provided URL does not exist or cannot be accessed.");
+												return;
+											}
+
+											total = data.total;
+											nextUrl = data.next;
+
+											const { items } = data;
+											const trackObjects = items.map(item => item.track);
+											const newSpotifyTracks = trackObjects.map(trackObject =>
+												spotifyTrackObjectToMusareTrackObject(trackObject)
+											);
+
+											spotifyTracks = spotifyTracks.concat(newSpotifyTracks);
+											next();
+										})
+										.catch(err => next(err));
+								}, 1000);
+							},
+							err => {
+								if (err) next(err);
+								else
+									SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks }, this)
+										.then(() => {
+											next(
+												null,
+												spotifyTracks.map(spotifyTrack => spotifyTrack.trackId)
+											);
+										})
+										.catch(next);
+							}
+						);
+					}
+				],
+				(err, soundcloudTrackIds) => {
+					if (err && err !== true) {
+						SpotifyModule.log(
+							"ERROR",
+							"GET_PLAYLIST",
+							"Some error has occurred.",
+							typeof err === "string" ? err : err.message
+						);
+						reject(new Error(typeof err === "string" ? err : err.message));
+					} else {
+						resolve({ songs: soundcloudTrackIds });
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Tries to get alternative artists sources for a list of Spotify artist ids
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artistIds - the Spotify artist ids to try and get alternative artist sources for
+	 * @param {boolean} payload.collectAlternativeArtistSourcesOrigins - whether to collect the origin of any alternative artist sources found
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTISTS(payload) {
+		const { artistIds, collectAlternativeArtistSourcesOrigins } = payload;
+
+		await async.eachLimit(artistIds, 1, async artistId => {
+			try {
+				const result = await SpotifyModule.runJob(
+					"GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTIST",
+					{ artistId, collectAlternativeArtistSourcesOrigins },
+					this
+				);
+				this.publishProgress({
+					status: "working",
+					message: `Got alternative artist source for ${artistId}`,
+					data: {
+						artistId,
+						status: "success",
+						result
+					}
+				});
+			} catch (err) {
+				this.publishProgress({
+					status: "working",
+					message: `Failed to get alternative artist source for ${artistId}`,
+					data: {
+						artistId,
+						status: "error"
+					}
+				});
+			}
+		});
+
+		this.publishProgress({
+			status: "finished",
+			message: `Finished getting alternative artist sources`
+		});
+	}
+
+	/**
+	 * Tries to get alternative artist sources for a Spotify artist id
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artistId - the Spotify artist id to try and get alternative artist sources for
+	 * @param {boolean} payload.collectAlternativeArtistSourcesOrigins - whether to collect the origin of any alternative artist sources found
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTIST(payload) {
+		const { artistId /* , collectAlternativeArtistSourcesOrigins */ } = payload;
+
+		if (!artistId) throw new Error("Artist id provided is not valid.");
+
+		const wikiDataResponse = await WikiDataModule.runJob(
+			"API_GET_DATA_FROM_SPOTIFY_ARTIST",
+			{ spotifyArtistId: artistId },
+			this
+		);
+
+		const youtubeChannelIds = Array.from(
+			new Set(
+				wikiDataResponse.results.bindings
+					.filter(binding => !!binding.YouTube_channel_ID)
+					.map(binding => binding.YouTube_channel_ID.value)
+			)
+		);
+
+		// const soundcloudIds = Array.from(
+		// 	new Set(
+		// 		wikiDataResponse.results.bindings
+		// 			.filter(binding => !!binding.SoundCloud_ID)
+		// 			.map(binding => binding.SoundCloud_ID.value)
+		// 	)
+		// );
+
+		// const musicbrainzArtistIds = Array.from(
+		// 	new Set(
+		// 		wikiDataResponse.results.bindings
+		// 			.filter(binding => !!binding.MusicBrainz_artist_ID)
+		// 			.map(binding => binding.MusicBrainz_artist_ID.value)
+		// 	)
+		// );
+
+		return youtubeChannelIds;
+	}
+
+	/**
+	 * Tries to get alternative album sources for a list of Spotify album ids
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.albumIds - the Spotify album ids to try and get alternative album sources for
+	 * @param {boolean} payload.collectAlternativeAlbumSourcesOrigins - whether to collect the origin of any alternative album sources found
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUMS(payload) {
+		const { albumIds, collectAlternativeAlbumSourcesOrigins } = payload;
+
+		await async.eachLimit(albumIds, 1, async albumId => {
+			try {
+				const result = await SpotifyModule.runJob(
+					"GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUM",
+					{ albumId, collectAlternativeAlbumSourcesOrigins },
+					this
+				);
+				this.publishProgress({
+					status: "working",
+					message: `Got alternative album source for ${albumId}`,
+					data: {
+						albumId,
+						status: "success",
+						result
+					}
+				});
+			} catch (err) {
+				this.publishProgress({
+					status: "working",
+					message: `Failed to get alternative album source for ${albumId}`,
+					data: {
+						albumId,
+						status: "error"
+					}
+				});
+			}
+		});
+
+		this.publishProgress({
+			status: "finished",
+			message: `Finished getting alternative album sources`
+		});
+	}
+
+	/**
+	 * Tries to get alternative album sources for a Spotify album id
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.albumId - the Spotify album id to try and get alternative album sources for
+	 * @param {boolean} payload.collectAlternativeAlbumSourcesOrigins - whether to collect the origin of any alternative album sources found
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUM(payload) {
+		const { albumId /* , collectAlternativeAlbumSourcesOrigins */ } = payload;
+
+		if (!albumId) throw new Error("Album id provided is not valid.");
+
+		const wikiDataResponse = await WikiDataModule.runJob(
+			"API_GET_DATA_FROM_SPOTIFY_ALBUM",
+			{ spotifyAlbumId: albumId },
+			this
+		);
+
+		const youtubePlaylistIds = Array.from(
+			new Set(
+				wikiDataResponse.results.bindings
+					.filter(binding => !!binding.YouTube_playlist_ID)
+					.map(binding => binding.YouTube_playlist_ID.value)
+			)
+		);
+
+		return youtubePlaylistIds;
+	}
+
+	/**
+	 * Tries to get alternative track sources for a list of Spotify track media sources
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.mediaSources - the Spotify media sources to try and get alternative track sources for
+	 * @param {boolean} payload.collectAlternativeMediaSourcesOrigins - whether to collect the origin of any alternative track sources found
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACKS(payload) {
+		const { mediaSources, collectAlternativeMediaSourcesOrigins } = payload;
+
+		await async.eachLimit(mediaSources, 1, async mediaSource => {
+			try {
+				const result = await SpotifyModule.runJob(
+					"GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",
+					{ mediaSource, collectAlternativeMediaSourcesOrigins },
+					this
+				);
+				this.publishProgress({
+					status: "working",
+					message: `Got alternative media for ${mediaSource}`,
+					data: {
+						mediaSource,
+						status: "success",
+						result
+					}
+				});
+			} catch (err) {
+				this.publishProgress({
+					status: "working",
+					message: `Failed to get alternative media for ${mediaSource}`,
+					data: {
+						mediaSource,
+						status: "error"
+					}
+				});
+			}
+		});
+
+		this.publishProgress({
+			status: "finished",
+			message: `Finished getting alternative media`
+		});
+	}
+
+	/**
+	 * Tries to get alternative track sources for a Spotify track media source
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.mediaSource - the Spotify media source to try and get alternative track sources for
+	 * @param {boolean} payload.collectAlternativeMediaSourcesOrigins - whether to collect the origin of any alternative track sources found
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK(payload) {
+		const { mediaSource, collectAlternativeMediaSourcesOrigins } = payload;
+
+		if (!mediaSource || !mediaSource.startsWith("spotify:"))
+			throw new Error("Media source provided is not a valid Spotify media source.");
+
+		const spotifyTrackId = mediaSource.split(":")[1];
+
+		const { track: spotifyTrack } = await SpotifyModule.runJob(
+			"GET_TRACK",
+			{
+				identifier: spotifyTrackId,
+				createMissing: true
+			},
+			this
+		);
+
+		const ISRC = spotifyTrack.externalIds.isrc;
+		if (!ISRC) throw new Error(`ISRC not found for Spotify track ${mediaSource}.`);
+
+		const mediaSources = new Set();
+		const mediaSourcesOrigins = {};
+
+		const jobsToRun = [];
+
+		try {
+			const ISRCApiResponse = await MusicBrainzModule.runJob(
+				"API_CALL",
+				{
+					url: `https://musicbrainz.org/ws/2/isrc/${ISRC}`,
+					params: {
+						fmt: "json",
+						inc: "url-rels+work-rels"
+					}
+				},
+				this
+			);
+
+			ISRCApiResponse.recordings.forEach(recording => {
+				recording.relations.forEach(relation => {
+					if (relation["target-type"] === "url" && relation.url) {
+						// relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c"
+						const { resource } = relation.url;
+
+						if (config.get("experimental.soundcloud") && resource.indexOf("soundcloud.com") !== -1) {
+							const promise = new Promise(resolve => {
+								SoundcloudModule.runJob(
+									"GET_TRACK_FROM_URL",
+									{ identifier: resource, createMissing: true },
+									this
+								)
+									.then(response => {
+										const { trackId } = response.track;
+										const mediaSource = `soundcloud:${trackId}`;
+
+										mediaSources.add(mediaSource);
+
+										if (collectAlternativeMediaSourcesOrigins) {
+											const mediaSourceOrigins = [
+												`Spotify track ${spotifyTrackId}`,
+												`ISRC ${ISRC}`,
+												`MusicBrainz recordings`,
+												`MusicBrainz recording ${recording.id}`,
+												`MusicBrainz relations`,
+												`MusicBrainz relation target-type url`,
+												`MusicBrainz relation resource ${resource}`,
+												`SoundCloud ID ${trackId}`
+											];
+
+											if (!mediaSourcesOrigins[mediaSource])
+												mediaSourcesOrigins[mediaSource] = [];
+
+											mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+										}
+
+										resolve();
+									})
+									.catch(() => {
+										resolve();
+									});
+							});
+
+							jobsToRun.push(promise);
+
+							return;
+						}
+
+						if (resource.indexOf("youtube.com") !== -1 || resource.indexOf("youtu.be") !== -1) {
+							const match = youtubeVideoUrlRegex.exec(resource);
+							if (!match) throw new Error(`Unable to parse YouTube resource ${resource}.`);
+
+							const { youtubeId } = match.groups;
+							if (!youtubeId) throw new Error(`Unable to parse YouTube resource ${resource}.`);
+
+							const mediaSource = `youtube:${youtubeId}`;
+
+							mediaSources.add(mediaSource);
+
+							if (collectAlternativeMediaSourcesOrigins) {
+								const mediaSourceOrigins = [
+									`Spotify track ${spotifyTrackId}`,
+									`ISRC ${ISRC}`,
+									`MusicBrainz recordings`,
+									`MusicBrainz recording ${recording.id}`,
+									`MusicBrainz relations`,
+									`MusicBrainz relation target-type url`,
+									`MusicBrainz relation resource ${resource}`,
+									`YouTube ID ${youtubeId}`
+								];
+
+								if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+								mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+							}
+
+							return;
+						}
+
+						return;
+					}
+
+					if (relation["target-type"] === "work") {
+						const promise = new Promise(resolve => {
+							WikiDataModule.runJob(
+								"API_GET_DATA_FROM_MUSICBRAINZ_WORK",
+								{ workId: relation.work.id },
+								this
+							)
+								.then(resultBody => {
+									const youtubeIds = Array.from(
+										new Set(
+											resultBody.results.bindings
+												.filter(binding => !!binding.YouTube_video_ID)
+												.map(binding => binding.YouTube_video_ID.value)
+										)
+									);
+									// const soundcloudIds = Array.from(
+									// 	new Set(
+									// 		resultBody.results.bindings
+									// 			.filter(binding => !!binding["SoundCloud_track_ID"])
+									// 			.map(binding => binding["SoundCloud_track_ID"].value)
+									// 	)
+									// );
+									const musicVideoEntityUrls = Array.from(
+										new Set(
+											resultBody.results.bindings
+												.filter(binding => !!binding.Music_video_entity_URL)
+												.map(binding => binding.Music_video_entity_URL.value)
+										)
+									);
+
+									youtubeIds.forEach(youtubeId => {
+										const mediaSource = `youtube:${youtubeId}`;
+
+										mediaSources.add(mediaSource);
+
+										if (collectAlternativeMediaSourcesOrigins) {
+											const mediaSourceOrigins = [
+												`Spotify track ${spotifyTrackId}`,
+												`ISRC ${ISRC}`,
+												`MusicBrainz recordings`,
+												`MusicBrainz recording ${recording.id}`,
+												`MusicBrainz relations`,
+												`MusicBrainz relation target-type work`,
+												`MusicBrainz relation work id ${relation.work.id}`,
+												`WikiData select from MusicBrainz work id ${relation.work.id}`,
+												`YouTube ID ${youtubeId}`
+											];
+
+											if (!mediaSourcesOrigins[mediaSource])
+												mediaSourcesOrigins[mediaSource] = [];
+
+											mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+										}
+									});
+
+									// soundcloudIds.forEach(soundcloudId => {
+									// 	const mediaSource = `soundcloud:${soundcloudId}`;
+
+									// 	mediaSources.add(mediaSource);
+
+									// 	if (collectAlternativeMediaSourcesOrigins) {
+									// 		const mediaSourceOrigins = [
+									// 			`Spotify track ${spotifyTrackId}`,
+									// 			`ISRC ${ISRC}`,
+									// 			`MusicBrainz recordings`,
+									// 			`MusicBrainz recording ${recording.id}`,
+									// 			`MusicBrainz relations`,
+									// 			`MusicBrainz relation target-type work`,
+									// 			`MusicBrainz relation work id ${relation.work.id}`,
+									// 			`WikiData select from MusicBrainz work id ${relation.work.id}`,
+									// 			`SoundCloud ID ${soundcloudId}`
+									// 		];
+
+									// 		if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+									// 		mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+									// 	}
+									// });
+
+									const promisesToRun2 = [];
+
+									musicVideoEntityUrls.forEach(musicVideoEntityUrl => {
+										promisesToRun2.push(
+											new Promise(resolve => {
+												WikiDataModule.runJob(
+													"API_GET_DATA_FROM_ENTITY_URL",
+													{ entityUrl: musicVideoEntityUrl },
+													this
+												).then(resultBody => {
+													const youtubeIds = Array.from(
+														new Set(
+															resultBody.results.bindings
+																.filter(binding => !!binding.YouTube_video_ID)
+																.map(binding => binding.YouTube_video_ID.value)
+														)
+													);
+													// const soundcloudIds = Array.from(
+													// 	new Set(
+													// 		resultBody.results.bindings
+													// 			.filter(binding => !!binding["SoundCloud_track_ID"])
+													// 			.map(binding => binding["SoundCloud_track_ID"].value)
+													// 	)
+													// );
+
+													youtubeIds.forEach(youtubeId => {
+														const mediaSource = `youtube:${youtubeId}`;
+
+														mediaSources.add(mediaSource);
+
+														// if (collectAlternativeMediaSourcesOrigins) {
+														// 	const mediaSourceOrigins = [
+														// 		`Spotify track ${spotifyTrackId}`,
+														// 		`ISRC ${ISRC}`,
+														// 		`MusicBrainz recordings`,
+														// 		`MusicBrainz recording ${recording.id}`,
+														// 		`MusicBrainz relations`,
+														// 		`MusicBrainz relation target-type work`,
+														// 		`MusicBrainz relation work id ${relation.work.id}`,
+														// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,
+														// 		`YouTube ID ${youtubeId}`
+														// 	];
+
+														// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+														// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+														// }
+													});
+
+													// soundcloudIds.forEach(soundcloudId => {
+													// 	const mediaSource = `soundcloud:${soundcloudId}`;
+
+													// 	mediaSources.add(mediaSource);
+
+													// 	// if (collectAlternativeMediaSourcesOrigins) {
+													// 	// 	const mediaSourceOrigins = [
+													// 	// 		`Spotify track ${spotifyTrackId}`,
+													// 	// 		`ISRC ${ISRC}`,
+													// 	// 		`MusicBrainz recordings`,
+													// 	// 		`MusicBrainz recording ${recording.id}`,
+													// 	// 		`MusicBrainz relations`,
+													// 	// 		`MusicBrainz relation target-type work`,
+													// 	// 		`MusicBrainz relation work id ${relation.work.id}`,
+													// 	// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,
+													// 	// 		`SoundCloud ID ${soundcloudId}`
+													// 	// 	];
+
+													// 	// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+													// 	// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+													// 	// }
+													// });
+
+													resolve();
+												});
+											})
+										);
+									});
+
+									Promise.allSettled(promisesToRun2).then(resolve);
+								})
+								.catch(err => {
+									console.log(err);
+									resolve();
+								});
+						});
+
+						jobsToRun.push(promise);
+
+						// WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_WORK", { workId: relation.work.id }, this));
+					}
+				});
+			});
+		} catch (err) {
+			console.log("Error during initial ISRC getting/parsing", err);
+		}
+
+		try {
+			const RecordingApiResponse = await MusicBrainzModule.runJob(
+				"API_CALL",
+				{
+					url: `https://musicbrainz.org/ws/2/recording/`,
+					params: {
+						fmt: "json",
+						query: `isrc:${ISRC}`
+					}
+				},
+				this
+			);
+
+			const releaseIds = new Set();
+			const releaseGroupIds = new Set();
+
+			RecordingApiResponse.recordings.forEach(recording => {
+				// const recordingId = recording.id;
+				// console.log("Recording:", recording.id);
+
+				recording.releases.forEach(release => {
+					const releaseId = release.id;
+					// console.log("Release:", releaseId);
+
+					const releaseGroupId = release["release-group"].id;
+					// console.log("Release group:", release["release-group"]);
+					// console.log("Release group id:", release["release-group"].id);
+					// console.log("Release group type id:", release["release-group"]["type-id"]);
+					// console.log("Release group primary type id:", release["release-group"]["primary-type-id"]);
+					// console.log("Release group primary type:", release["release-group"]["primary-type"]);
+
+					// d6038452-8ee0-3f68-affc-2de9a1ede0b9 = single
+					// 6d0c5bf6-7a33-3420-a519-44fc63eedebf = EP
+					if (
+						release["release-group"]["type-id"] === "d6038452-8ee0-3f68-affc-2de9a1ede0b9" ||
+						release["release-group"]["type-id"] === "6d0c5bf6-7a33-3420-a519-44fc63eedebf"
+					) {
+						releaseIds.add(releaseId);
+						releaseGroupIds.add(releaseGroupId);
+					}
+				});
+			});
+
+			Array.from(releaseGroupIds).forEach(releaseGroupId => {
+				const promise = new Promise(resolve => {
+					WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_RELEASE_GROUP", { releaseGroupId }, this)
+						.then(resultBody => {
+							const youtubeIds = Array.from(
+								new Set(
+									resultBody.results.bindings
+										.filter(binding => !!binding.YouTube_video_ID)
+										.map(binding => binding.YouTube_video_ID.value)
+								)
+							);
+							// const soundcloudIds = Array.from(
+							// 	new Set(
+							// 		resultBody.results.bindings
+							// 			.filter(binding => !!binding["SoundCloud_track_ID"])
+							// 			.map(binding => binding["SoundCloud_track_ID"].value)
+							// 	)
+							// );
+							const musicVideoEntityUrls = Array.from(
+								new Set(
+									resultBody.results.bindings
+										.filter(binding => !!binding.Music_video_entity_URL)
+										.map(binding => binding.Music_video_entity_URL.value)
+								)
+							);
+
+							youtubeIds.forEach(youtubeId => {
+								const mediaSource = `youtube:${youtubeId}`;
+
+								mediaSources.add(mediaSource);
+
+								// if (collectAlternativeMediaSourcesOrigins) {
+								// 	const mediaSourceOrigins = [
+								// 		`Spotify track ${spotifyTrackId}`,
+								// 		`ISRC ${ISRC}`,
+								// 		`MusicBrainz recordings`,
+								// 		`MusicBrainz recording ${recording.id}`,
+								// 		`MusicBrainz relations`,
+								// 		`MusicBrainz relation target-type work`,
+								// 		`MusicBrainz relation work id ${relation.work.id}`,
+								// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,
+								// 		`YouTube ID ${youtubeId}`
+								// 	];
+
+								// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+								// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+								// }
+							});
+
+							// soundcloudIds.forEach(soundcloudId => {
+							// 	const mediaSource = `soundcloud:${soundcloudId}`;
+
+							// 	mediaSources.add(mediaSource);
+
+							// 	// if (collectAlternativeMediaSourcesOrigins) {
+							// 	// 	const mediaSourceOrigins = [
+							// 	// 		`Spotify track ${spotifyTrackId}`,
+							// 	// 		`ISRC ${ISRC}`,
+							// 	// 		`MusicBrainz recordings`,
+							// 	// 		`MusicBrainz recording ${recording.id}`,
+							// 	// 		`MusicBrainz relations`,
+							// 	// 		`MusicBrainz relation target-type work`,
+							// 	// 		`MusicBrainz relation work id ${relation.work.id}`,
+							// 	// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,
+							// 	// 		`SoundCloud ID ${soundcloudId}`
+							// 	// 	];
+
+							// 	// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+							// 	// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+							// 	// }
+							// });
+
+							const promisesToRun2 = [];
+
+							musicVideoEntityUrls.forEach(musicVideoEntityUrl => {
+								promisesToRun2.push(
+									new Promise(resolve => {
+										WikiDataModule.runJob(
+											"API_GET_DATA_FROM_ENTITY_URL",
+											{ entityUrl: musicVideoEntityUrl },
+											this
+										).then(resultBody => {
+											const youtubeIds = Array.from(
+												new Set(
+													resultBody.results.bindings
+														.filter(binding => !!binding.YouTube_video_ID)
+														.map(binding => binding.YouTube_video_ID.value)
+												)
+											);
+											// const soundcloudIds = Array.from(
+											// 	new Set(
+											// 		resultBody.results.bindings
+											// 			.filter(binding => !!binding["SoundCloud_track_ID"])
+											// 			.map(binding => binding["SoundCloud_track_ID"].value)
+											// 	)
+											// );
+
+											youtubeIds.forEach(youtubeId => {
+												const mediaSource = `youtube:${youtubeId}`;
+
+												mediaSources.add(mediaSource);
+
+												// if (collectAlternativeMediaSourcesOrigins) {
+												// 	const mediaSourceOrigins = [
+												// 		`Spotify track ${spotifyTrackId}`,
+												// 		`ISRC ${ISRC}`,
+												// 		`MusicBrainz recordings`,
+												// 		`MusicBrainz recording ${recording.id}`,
+												// 		`MusicBrainz relations`,
+												// 		`MusicBrainz relation target-type work`,
+												// 		`MusicBrainz relation work id ${relation.work.id}`,
+												// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,
+												// 		`YouTube ID ${youtubeId}`
+												// 	];
+
+												// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+												// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+												// }
+											});
+
+											// soundcloudIds.forEach(soundcloudId => {
+											// 	const mediaSource = `soundcloud:${soundcloudId}`;
+
+											// 	mediaSources.add(mediaSource);
+
+											// 	// if (collectAlternativeMediaSourcesOrigins) {
+											// 	// 	const mediaSourceOrigins = [
+											// 	// 		`Spotify track ${spotifyTrackId}`,
+											// 	// 		`ISRC ${ISRC}`,
+											// 	// 		`MusicBrainz recordings`,
+											// 	// 		`MusicBrainz recording ${recording.id}`,
+											// 	// 		`MusicBrainz relations`,
+											// 	// 		`MusicBrainz relation target-type work`,
+											// 	// 		`MusicBrainz relation work id ${relation.work.id}`,
+											// 	// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,
+											// 	// 		`SoundCloud ID ${soundcloudId}`
+											// 	// 	];
+
+											// 	// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+											// 	// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+											// 	// }
+											// });
+
+											resolve();
+										});
+									})
+								);
+							});
+
+							Promise.allSettled(promisesToRun2).then(resolve);
+						})
+						.catch(err => {
+							console.log(err);
+							resolve();
+						});
+				});
+
+				jobsToRun.push(promise);
+			});
+		} catch (err) {
+			console.log("Error during getting releases from ISRC", err);
+		}
+
+		await Promise.allSettled(jobsToRun);
+
+		return {
+			mediaSources: Array.from(mediaSources),
+			mediaSourcesOrigins
+		};
+	}
+}
+
+export default new _SpotifyModule();

+ 213 - 109
backend/logic/stations.js

@@ -25,7 +25,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Initialises the stations module
-	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async initialize() {
@@ -127,6 +126,10 @@ class _StationsModule extends CoreClass {
 		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
 		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
 
+		this.stationHistoryModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "stationHistory"
+		});
+
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -169,11 +172,32 @@ class _StationsModule extends CoreClass {
 
 					next => {
 						this.setStage(4);
+						const mediaSources = [];
+						if (!config.get("experimental.soundcloud")) {
+							mediaSources.push(/^soundcloud:/);
+						}
+						if (!config.get("experimental.spotify")) {
+							mediaSources.push(/^spotify:/);
+						}
+						if (mediaSources.length > 0)
+							stationModel.updateMany(
+								{},
+								{ $pull: { queue: { mediaSource: { $in: mediaSources } } } },
+								err => {
+									if (err) next(err);
+									else next();
+								}
+							);
+						else next();
+					},
+
+					next => {
+						this.setStage(5);
 						stationModel.find({}, next);
 					},
 
 					(stations, next) => {
-						this.setStage(5);
+						this.setStage(6);
 						async.each(
 							stations,
 							(station, next2) => {
@@ -229,7 +253,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -269,7 +292,8 @@ class _StationsModule extends CoreClass {
 									cb: () =>
 										StationsModule.runJob("SKIP_STATION", {
 											stationId: station._id,
-											natural: true
+											natural: true,
+											skipReason: "natural"
 										}),
 									unique: true,
 									station
@@ -277,28 +301,42 @@ class _StationsModule extends CoreClass {
 									.then()
 									.catch();
 
-								if (station.paused) return next(true, station);
-
 								return next(null, station);
 							});
 					},
 					(station, next) => {
-						if (!station.currentSong) {
+						// A current song is invalid if it isn't allowed to be played. Spotify songs can never be played, and SoundCloud songs can't be played if SoundCloud isn't enabled
+						let currentSongIsInvalid = false;
+						if (station.currentSong && station.currentSong.mediaSource) {
+							if (station.currentSong.mediaSource.startsWith("spotify:")) currentSongIsInvalid = true;
+							if (
+								station.currentSong.mediaSource.startsWith("soundcloud:") &&
+								!config.get("experimental.soundcloud")
+							)
+								currentSongIsInvalid = true;
+						}
+						if (
+							(!station.paused && !station.currentSong) ||
+							(station.currentSong && currentSongIsInvalid)
+						) {
 							return StationsModule.runJob(
 								"SKIP_STATION",
 								{
 									stationId: station._id,
-									natural: false
+									natural: false,
+									skipReason: "other"
 								},
 								this
 							)
 								.then(station => {
-									next(true, station);
+									next(null, station);
 								})
 								.catch(next)
 								.finally(() => {});
 						}
 
+						if (station.paused) return next(null, station);
+
 						let timeLeft =
 							station.currentSong.duration * 1000 - (Date.now() - station.startedAt - station.timePaused);
 
@@ -309,7 +347,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: station._id,
-									natural: false
+									natural: false,
+									skipReason: "other"
 								},
 								this
 							)
@@ -346,7 +385,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -398,7 +436,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -433,7 +470,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -490,7 +526,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Autofill station queue from 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
@@ -547,19 +582,19 @@ class _StationsModule extends CoreClass {
 						const currentRequests = station.queue.filter(song => !song.requestedBy).length;
 						const songsStillNeeded = station.autofill.limit - currentRequests;
 						const currentSongs = station.queue;
-						let currentYoutubeIds = station.queue.map(song => song.youtubeId);
+						let currentMediaSources = station.queue.map(song => song.mediaSource);
 						const songsToAdd = [];
 						let lastSongAdded = null;
 
-						if (station.currentSong && station.currentSong.youtubeId)
-							currentYoutubeIds.push(station.currentSong.youtubeId);
+						if (station.currentSong && station.currentSong.mediaSource)
+							currentMediaSources.push(station.currentSong.mediaSource);
 
 						// Block for experiment: queue_autofill_skip_last_x_played
 						if (config.has(`experimental.queue_autofill_skip_last_x_played.${stationId}`)) {
 							const redisList = `experimental:queue_autofill_skip_last_x_played:${stationId}`;
 							// Get list of last x youtube video's played, to make sure they can't be autofilled
 							const listOfYoutubeIds = await CacheModule.runJob("LRANGE", { key: redisList }, this);
-							currentYoutubeIds = [...currentYoutubeIds, ...listOfYoutubeIds];
+							currentMediaSources = [...currentMediaSources, ...listOfYoutubeIds];
 						}
 
 						// Block for experiment: weight_stations
@@ -573,10 +608,10 @@ class _StationsModule extends CoreClass {
 									: config.get(`experimental.weight_stations.${stationId}`);
 							const weightMap = {};
 							const getYoutubeIds = playlistSongs
-								.map(playlistSong => playlistSong.youtubeId)
-								.filter(youtubeId => currentYoutubeIds.indexOf(youtubeId) === -1);
+								.map(playlistSong => playlistSong.mediaSource)
+								.filter(mediaSource => currentMediaSources.indexOf(mediaSource) === -1);
 
-							const { songs } = await SongsModule.runJob("GET_SONGS", { youtubeIds: getYoutubeIds });
+							const { songs } = await SongsModule.runJob("GET_SONGS", { mediaSources: getYoutubeIds });
 
 							const weightRegex = new RegExp(`${weightTagName}\\[(\\d+)\\]`);
 
@@ -593,13 +628,13 @@ class _StationsModule extends CoreClass {
 								weight = Math.max(1, weight);
 								weight = Math.min(10000, weight);
 
-								weightMap[song.youtubeId] = weight;
+								weightMap[song.mediaSource] = weight;
 							});
 
 							const adjustedPlaylistSongs = [];
 
 							playlistSongs.forEach(playlistSong => {
-								Array.from({ length: weightMap[playlistSong.youtubeId] }).forEach(() => {
+								Array.from({ length: weightMap[playlistSong.mediaSource] }).forEach(() => {
 									adjustedPlaylistSongs.push(playlistSong);
 								});
 							});
@@ -614,16 +649,20 @@ class _StationsModule extends CoreClass {
 						}
 
 						playlistSongs.every(song => {
-							if (
-								songsToAdd.length < songsStillNeeded &&
-								currentYoutubeIds.indexOf(song.youtubeId) === -1 &&
-								!songsToAdd.find(songToAdd => songToAdd.youtubeId === song.youtubeId)
-							) {
-								lastSongAdded = song;
-								songsToAdd.push(song);
-								return true;
-							}
 							if (songsToAdd.length >= songsStillNeeded) return false;
+
+							// Skip Spotify songs
+							if (song.mediaSource.startsWith("spotify:")) return true;
+							// Skip SoundCloud songs if Soundcloud isn't enabled
+							if (song.mediaSource.startsWith("soundcloud:") && !config.get("experimental.soundcloud"))
+								return true;
+							// Skip songs already in songsToAdd
+							if (songsToAdd.find(songToAdd => songToAdd.mediaSource === song.mediaSource)) return true;
+							// Skip songs already in the queue
+							if (currentMediaSources.indexOf(song.mediaSource) !== -1) return true;
+
+							lastSongAdded = song;
+							songsToAdd.push(song);
 							return true;
 						});
 
@@ -631,8 +670,8 @@ class _StationsModule extends CoreClass {
 
 						if (station.autofill.mode === "sequential" && lastSongAdded) {
 							const indexOfLastSong = _playlistSongs
-								.map(song => song.youtubeId)
-								.indexOf(lastSongAdded.youtubeId);
+								.map(song => song.mediaSource)
+								.indexOf(lastSongAdded.mediaSource);
 
 							if (indexOfLastSong !== -1) currentSongIndex = indexOfLastSong;
 						}
@@ -643,17 +682,17 @@ class _StationsModule extends CoreClass {
 					({ currentSongs, songsToAdd, currentSongIndex }, next) => {
 						const songs = [];
 						async.eachLimit(
-							songsToAdd.map(song => song.youtubeId),
+							songsToAdd.map(song => song.mediaSource),
 							2,
-							(youtubeId, next) => {
-								MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+							(mediaSource, next) => {
+								MediaModule.runJob("GET_MEDIA", { mediaSource }, this)
 									.then(response => {
 										const { song } = response;
 										const { _id, title, artists, thumbnail, duration, skipDuration, verified } =
 											song;
 										songs.push({
 											_id,
-											youtubeId,
+											mediaSource,
 											title,
 											artists,
 											thumbnail,
@@ -669,7 +708,7 @@ class _StationsModule extends CoreClass {
 								if (err) next(err);
 								else {
 									const newSongsToAdd = songsToAdd.map(song =>
-										songs.find(newSong => newSong.youtubeId === song.youtubeId)
+										songs.find(newSong => newSong.mediaSource === song.mediaSource)
 									);
 									next(null, currentSongs, newSongsToAdd, currentSongIndex);
 								}
@@ -681,6 +720,7 @@ class _StationsModule extends CoreClass {
 						const newPlaylist = [...currentSongs, ...songsToAdd].map(song => {
 							if (!song._id) song._id = null;
 							if (!song.requestedAt) song.requestedAt = Date.now();
+							if (!song.requestedType) song.requestedType = "autofill";
 							return song;
 						});
 						next(null, newPlaylist, currentSongIndex);
@@ -719,7 +759,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -749,17 +788,25 @@ class _StationsModule extends CoreClass {
 						MediaModule.runJob(
 							"GET_MEDIA",
 							{
-								youtubeId: queueSong.youtubeId
+								mediaSource: queueSong.mediaSource
 							},
 							this
 						)
 							.then(response => {
 								const { song } = response;
-								const { _id, youtubeId, title, skipDuration, artists, thumbnail, duration, verified } =
-									song;
+								const {
+									_id,
+									mediaSource,
+									title,
+									skipDuration,
+									artists,
+									thumbnail,
+									duration,
+									verified
+								} = song;
 								next(null, {
 									_id,
-									youtubeId,
+									mediaSource,
 									title,
 									skipDuration,
 									artists,
@@ -768,6 +815,7 @@ class _StationsModule extends CoreClass {
 									verified,
 									requestedAt: queueSong.requestedAt,
 									requestedBy: queueSong.requestedBy,
+									requestedType: queueSong.requestedType,
 									likes: song.likes || 0,
 									dislikes: song.dislikes || 0
 								});
@@ -785,7 +833,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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)
@@ -829,7 +876,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Process vote to skips for a station
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the id of the station to process
 	 * @returns {Promise} - returns a promise (resolve, reject)
@@ -883,7 +929,11 @@ class _StationsModule extends CoreClass {
 								WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
 									.then(socket => {
 										if (socket && socket.session && socket.session.userId) {
-											if (!users.includes(socket.session.userId))
+											if (
+												!users.includes(socket.session.userId) &&
+												(socket.session.stationState !== "participate" ||
+													station.currentSong.skipVotes.includes(socket.session.userId))
+											)
 												users.push(socket.session.userId);
 										}
 										return next();
@@ -909,7 +959,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: payload.stationId,
-									natural: false
+									natural: false,
+									skipReason: "vote_skip"
 								},
 								this
 							)
@@ -926,12 +977,54 @@ class _StationsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Creates a station history item
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.stationId - the station id to create the history item for
+	 * @param {object} payload.currentSong - the song to create the history item for
+	 * @param {string} payload.skipReason - the reason the song was skipped
+	 * @param {string} payload.skippedAt - the date/time the song was skipped
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async ADD_STATION_HISTORY_ITEM(payload) {
+		if (!config.get("experimental.station_history")) throw new Error("Station history is not enabled");
+
+		const { stationId, currentSong, skipReason, skippedAt } = payload;
+
+		let document = await StationsModule.stationHistoryModel.create({
+			stationId,
+			type: "song_played",
+			payload: {
+				song: currentSong,
+				skippedAt,
+				skipReason
+			}
+		});
+
+		if (!document) return;
+
+		document = document._doc;
+
+		delete document.__v;
+		delete document.documentVersion;
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.history.new", { data: { historyItem: document } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:manageStation.history.new", { data: { stationId, historyItem: document } }]
+		});
+	}
+
 	/**
 	 * 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
+	 * @param {string} payload.skipReason - if it was skipped via force skip or via vote skipping or if it was natural
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	SKIP_STATION(payload) {
@@ -969,6 +1062,30 @@ class _StationsModule extends CoreClass {
 					(station, next) => {
 						if (!station) return next("Station not found.");
 
+						if (!config.get("experimental.station_history")) return next(null, station);
+
+						const { currentSong } = station;
+						if (!currentSong || !currentSong.mediaSource) return next(null, station);
+
+						const stationId = station._id;
+						const skippedAt = new Date();
+						const { skipReason } = payload;
+
+						return StationsModule.runJob(
+							"ADD_STATION_HISTORY_ITEM",
+							{
+								stationId,
+								currentSong,
+								skippedAt,
+								skipReason
+							},
+							this
+						).finally(() => {
+							next(null, station);
+						});
+					},
+
+					(station, next) => {
 						if (station.autofill.enabled)
 							return StationsModule.runJob("AUTOFILL_STATION", { stationId: station._id }, this)
 								.then(() => next(null, station))
@@ -1010,19 +1127,19 @@ class _StationsModule extends CoreClass {
 									config.get(`experimental.queue_autofill_skip_last_x_played.${payload.stationId}`)
 								);
 
-								// Add youtubeId to list for this station in Redis list
+								// Add mediaSource to list for this station in Redis list
 								await CacheModule.runJob(
 									"LPUSH",
 									{
 										key: `experimental:queue_autofill_skip_last_x_played:${payload.stationId}`,
-										value: song.youtubeId
+										value: song.mediaSource
 									},
 									this
 								);
 
 								const currentListLength = await CacheModule.runJob("LLEN", { key: redisList }, this);
 
-								// Removes oldest youtubeId from list for this station in Redis list
+								// Removes oldest mediaSource from list for this station in Redis list
 								if (currentListLength > maxListLength) {
 									const amount = currentListLength - maxListLength;
 									const promises = Array.from({ length: amount }).map(() =>
@@ -1040,7 +1157,7 @@ class _StationsModule extends CoreClass {
 
 							$set.currentSong = {
 								_id: song._id,
-								youtubeId: song.youtubeId,
+								mediaSource: song.mediaSource,
 								title: song.title,
 								artists: song.artists,
 								duration: song.duration,
@@ -1048,6 +1165,7 @@ class _StationsModule extends CoreClass {
 								thumbnail: song.thumbnail,
 								requestedAt: song.requestedAt,
 								requestedBy: song.requestedBy,
+								requestedType: song.requestedType,
 								verified: song.verified
 							};
 						}
@@ -1071,7 +1189,7 @@ class _StationsModule extends CoreClass {
 					},
 
 					(station, next) => {
-						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+						if (station.currentSong !== null && station.currentSong.mediaSource !== undefined) {
 							station.currentSong.skipVotes = 0;
 						}
 						next(null, station);
@@ -1177,10 +1295,10 @@ class _StationsModule extends CoreClass {
 					}
 
 					WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${station._id}` }).then(sockets => {
-						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+						if (station.currentSong !== null && station.currentSong.mediaSource !== undefined) {
 							WSModule.runJob("SOCKETS_JOIN_SONG_ROOM", {
 								sockets,
-								room: `song.${station.currentSong.youtubeId}`
+								room: `song.${station.currentSong.mediaSource}`
 							});
 							if (!station.paused) {
 								NotificationsModule.runJob("SCHEDULE", {
@@ -1200,7 +1318,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -1244,7 +1361,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -1280,7 +1396,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * 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
@@ -1348,7 +1463,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Adds a playlist to autofill 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
@@ -1422,7 +1536,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Removes a playlist from autofill
-	 *
 	 * @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
@@ -1488,7 +1601,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Add a playlist to station blacklist
-	 *
 	 * @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
@@ -1564,7 +1676,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Remove a playlist from station blacklist
-	 *
 	 * @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
@@ -1630,7 +1741,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Removes autofilled or blacklisted 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)
@@ -1676,7 +1786,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Gets stations that autofill or blacklist a specific playlist
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
 	 * @returns {Promise} - returns promise (reject, resolve)
@@ -1705,7 +1814,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Clears every queue
-	 *
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	CLEAR_EVERY_STATION_QUEUE() {
@@ -1757,7 +1865,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Resets 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)
@@ -1816,16 +1923,16 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Add to a station queue
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the station id
-	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @param {string} payload.requestUser - the requesting user id
+	 * @param {string} payload.requestType - the request type
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	ADD_TO_QUEUE(payload) {
 		return new Promise((resolve, reject) => {
-			const { stationId, youtubeId, requestUser } = payload;
+			const { stationId, mediaSource, requestUser, requestType } = payload;
 			async.waterfall(
 				[
 					next => {
@@ -1839,16 +1946,17 @@ class _StationsModule extends CoreClass {
 					(station, next) => {
 						if (!station) return next("Station not found.");
 						if (!station.requests.enabled) return next("Requests are disabled in this station.");
-						if (station.currentSong && station.currentSong.youtubeId === youtubeId)
+						if (mediaSource.startsWith("spotify:")) return next("Spotify playback is not supported.");
+						if (station.currentSong && station.currentSong.mediaSource === mediaSource)
 							return next("That song is currently playing.");
-						if (station.queue.find(song => song.youtubeId === youtubeId))
+						if (station.queue.find(song => song.mediaSource === mediaSource))
 							return next("That song is already in the queue.");
 
 						return next(null, station);
 					},
 
 					(station, next) => {
-						MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+						MediaModule.runJob("GET_MEDIA", { mediaSource }, this)
 							.then(response => {
 								const { song } = response;
 								const { _id, title, skipDuration, artists, thumbnail, duration, verified } = song;
@@ -1856,7 +1964,7 @@ class _StationsModule extends CoreClass {
 									null,
 									{
 										_id,
-										youtubeId,
+										mediaSource,
 										title,
 										skipDuration,
 										artists,
@@ -1894,11 +2002,11 @@ class _StationsModule extends CoreClass {
 							.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
 							.reduce(
 								(items, item) =>
-									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+									items.find(x => x.mediaSource === item.mediaSource) ? [...items] : [...items, item],
 								[]
 							);
 
-						if (blacklistedSongs.find(blacklistedSong => blacklistedSong.youtubeId === song.youtubeId))
+						if (blacklistedSongs.find(blacklistedSong => blacklistedSong.mediaSource === song.mediaSource))
 							next("That song is in an blacklisted playlist and cannot be played.");
 						else next(null, song, station);
 					},
@@ -1906,6 +2014,7 @@ class _StationsModule extends CoreClass {
 					(song, station, next) => {
 						song.requestedBy = requestUser;
 						song.requestedAt = Date.now();
+						song.requestedType = requestType;
 						if (station.queue.length === 0) return next(null, song, station);
 						if (
 							requestUser &&
@@ -1964,36 +2073,34 @@ class _StationsModule extends CoreClass {
 					// },
 
 					(song, station, next) => {
-						if (config.has(`experimental.queue_add_before_autofilled`)) {
-							const queueAddBeforeAutofilled = config.get(`experimental.queue_add_before_autofilled`);
+						const queueAddBeforeAutofilled = config.get(`experimental.queue_add_before_autofilled`);
 
-							if (
-								queueAddBeforeAutofilled === true ||
-								(Array.isArray(queueAddBeforeAutofilled) &&
-									queueAddBeforeAutofilled.indexOf(stationId) !== -1)
-							) {
-								let position = station.queue.length;
-
-								if (station.autofill.enabled && station.queue.length >= station.autofill.limit) {
-									position = -station.autofill.limit;
-								}
+						if (
+							queueAddBeforeAutofilled === true ||
+							(Array.isArray(queueAddBeforeAutofilled) &&
+								queueAddBeforeAutofilled.indexOf(stationId) !== -1)
+						) {
+							let position = station.queue.length;
 
-								StationsModule.stationModel.updateOne(
-									{ _id: stationId },
-									{
-										$push: {
-											queue: {
-												$each: [song],
-												$position: position
-											}
+							if (station.autofill.enabled && station.queue.length >= station.autofill.limit) {
+								position = -station.autofill.limit;
+							}
+
+							StationsModule.stationModel.updateOne(
+								{ _id: stationId },
+								{
+									$push: {
+										queue: {
+											$each: [song],
+											$position: position
 										}
-									},
-									{ runValidators: true },
-									next
-								);
+									}
+								},
+								{ runValidators: true },
+								next
+							);
 
-								return;
-							}
+							return;
 						}
 
 						StationsModule.stationModel.updateOne(
@@ -2033,15 +2140,14 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Remove from a station queue
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the station id
-	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.mediaSource - the media source
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	REMOVE_FROM_QUEUE(payload) {
 		return new Promise((resolve, reject) => {
-			const { stationId, youtubeId } = payload;
+			const { stationId, mediaSource } = payload;
 			async.waterfall(
 				[
 					next => {
@@ -2054,12 +2160,12 @@ class _StationsModule extends CoreClass {
 
 					(station, next) => {
 						if (!station) return next("Station not found.");
-						if (!station.queue.find(song => song.youtubeId === youtubeId))
+						if (!station.queue.find(song => song.mediaSource === mediaSource))
 							return next("That song is not currently in the queue.");
 
 						return StationsModule.stationModel.updateOne(
 							{ _id: stationId },
-							{ $pull: { queue: { youtubeId } } },
+							{ $pull: { queue: { mediaSource } } },
 							next
 						);
 					},
@@ -2105,7 +2211,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Add DJ to station
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the station id
 	 * @param {string} payload.userId - the dj user id
@@ -2163,7 +2268,6 @@ class _StationsModule extends CoreClass {
 
 	/**
 	 * Remove DJ from station
-	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the station id
 	 * @param {string} payload.userId - the dj user id

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä