Bladeren bron

Merge branch 'v3.10.0'

Owen Diffey 2 jaren geleden
bovenliggende
commit
5c629dac13
100 gewijzigde bestanden met toevoegingen van 7558 en 2603 verwijderingen
  1. 15 0
      .dockerignore
  2. 14 2
      .env.example
  3. 2 4
      .github/workflows/automated-tests.yml
  4. 2 4
      .github/workflows/build-lint.yml
  5. 1 2
      .gitignore
  6. 138 117
      .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. 3 2
      backend/.eslintignore
  13. 2 0
      backend/.prettierignore
  14. 8 8
      backend/.prettierrc
  15. 7 6
      backend/Dockerfile
  16. 20 0
      backend/config/custom-environment-variables.json
  17. 154 0
      backend/config/default.json
  18. 20 121
      backend/config/template.json
  19. 15 7
      backend/core.js
  20. 62 37
      backend/index.js
  21. 200 1
      backend/logic/actions/apis.js
  22. 4 0
      backend/logic/actions/index.js
  23. 92 69
      backend/logic/actions/media.js
  24. 830 214
      backend/logic/actions/playlists.js
  25. 8 8
      backend/logic/actions/reports.js
  26. 53 41
      backend/logic/actions/songs.js
  27. 265 0
      backend/logic/actions/soundcloud.js
  28. 83 0
      backend/logic/actions/spotify.js
  29. 177 37
      backend/logic/actions/stations.js
  30. 26 9
      backend/logic/actions/users.js
  31. 19 4
      backend/logic/actions/utils.js
  32. 225 10
      backend/logic/actions/youtube.js
  33. 6 6
      backend/logic/activities.js
  34. 1 1
      backend/logic/api.js
  35. 19 12
      backend/logic/app.js
  36. 80 9
      backend/logic/cache/index.js
  37. 90 22
      backend/logic/db/index.js
  38. 2 2
      backend/logic/db/schemas/activity.js
  39. 6 0
      backend/logic/db/schemas/genericApiRequest.js
  40. 10 3
      backend/logic/db/schemas/playlist.js
  41. 2 0
      backend/logic/db/schemas/queueSong.js
  42. 2 2
      backend/logic/db/schemas/ratings.js
  43. 2 2
      backend/logic/db/schemas/report.js
  44. 2 2
      backend/logic/db/schemas/song.js
  45. 27 0
      backend/logic/db/schemas/soundcloudTrack.js
  46. 8 0
      backend/logic/db/schemas/spotifyAlbum.js
  47. 8 0
      backend/logic/db/schemas/spotifyArtist.js
  48. 18 0
      backend/logic/db/schemas/spotifyTrack.js
  49. 23 14
      backend/logic/db/schemas/station.js
  50. 22 0
      backend/logic/db/schemas/stationHistory.js
  51. 9 0
      backend/logic/db/schemas/youtubeChannel.js
  52. 3 1
      backend/logic/db/schemas/youtubeVideo.js
  53. 41 7
      backend/logic/hooks/hasPermission.js
  54. 8 11
      backend/logic/mail/index.js
  55. 4 4
      backend/logic/mail/schemas/dataRequest.js
  56. 0 2
      backend/logic/mail/schemas/passwordRequest.js
  57. 0 2
      backend/logic/mail/schemas/resetPasswordRequest.js
  58. 2 4
      backend/logic/mail/schemas/verifyEmail.js
  59. 212 42
      backend/logic/media.js
  60. 4 3
      backend/logic/migration/index.js
  61. 271 0
      backend/logic/migration/migrations/migration25.js
  62. 126 0
      backend/logic/musicbrainz.js
  63. 1 5
      backend/logic/notifications.js
  64. 194 27
      backend/logic/playlists.js
  65. 173 71
      backend/logic/songs.js
  66. 710 0
      backend/logic/soundcloud.js
  67. 1503 0
      backend/logic/spotify.js
  68. 214 84
      backend/logic/stations.js
  69. 53 2
      backend/logic/tasks.js
  70. 297 0
      backend/logic/wikidata.js
  71. 41 17
      backend/logic/ws.js
  72. 276 139
      backend/logic/youtube.js
  73. 390 182
      backend/package-lock.json
  74. 18 17
      backend/package.json
  75. 4 0
      docker-compose.dev.yml
  76. 37 12
      docker-compose.yml
  77. 0 4
      frontend/.dockerignore
  78. 5 2
      frontend/.eslintignore
  79. 0 1
      frontend/.eslintrc
  80. 5 2
      frontend/.prettierignore
  81. 8 8
      frontend/.prettierrc
  82. 28 15
      frontend/Dockerfile
  83. 0 0
      frontend/dist/assets/social/soundcloud.svg
  84. 1 0
      frontend/dist/assets/social/spotify.svg
  85. 0 56
      frontend/dist/config/template.json
  86. BIN
      frontend/dist/fonts/inter-v3-latin-200.eot
  87. 0 350
      frontend/dist/fonts/inter-v3-latin-200.svg
  88. BIN
      frontend/dist/fonts/inter-v3-latin-200.ttf
  89. BIN
      frontend/dist/fonts/inter-v3-latin-200.woff
  90. BIN
      frontend/dist/fonts/inter-v3-latin-200.woff2
  91. BIN
      frontend/dist/fonts/inter-v3-latin-600.eot
  92. 0 351
      frontend/dist/fonts/inter-v3-latin-600.svg
  93. BIN
      frontend/dist/fonts/inter-v3-latin-600.ttf
  94. BIN
      frontend/dist/fonts/inter-v3-latin-600.woff
  95. BIN
      frontend/dist/fonts/inter-v3-latin-600.woff2
  96. BIN
      frontend/dist/fonts/inter-v3-latin-800.eot
  97. 0 350
      frontend/dist/fonts/inter-v3-latin-800.svg
  98. BIN
      frontend/dist/fonts/inter-v3-latin-800.ttf
  99. BIN
      frontend/dist/fonts/inter-v3-latin-800.woff
  100. BIN
      frontend/dist/fonts/inter-v3-latin-800.woff2

+ 15 - 0
.dockerignore

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

+ 14 - 2
.env.example

@@ -1,6 +1,6 @@
 COMPOSE_PROJECT_NAME=musare
 RESTART_POLICY=unless-stopped
-CONTAINER_MODE=prod
+CONTAINER_MODE=production
 DOCKER_COMMAND=docker
 
 BACKEND_HOST=127.0.0.1
@@ -8,7 +8,10 @@ BACKEND_PORT=8080
 
 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
@@ -25,3 +28,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

@@ -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

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

+ 138 - 117
.wiki/Configuration.md

@@ -1,19 +1,95 @@
 # Configuration
 
-## Backend
+## Environment Variables
+
+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`.
 
-Location: `backend/config/default.json`
+| 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. |
+| `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,136 +98,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. |
-| `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.
+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

+ 3 - 2
backend/.eslintignore

@@ -1,2 +1,3 @@
-node_modules
-build
+.git/
+build/
+node_modules/

+ 2 - 0
backend/.prettierignore

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

+ 8 - 8
backend/.prettierrc

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

+ 7 - 6
backend/Dockerfile

@@ -3,23 +3,24 @@ 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 CONTAINER_MODE=prod
+ARG CONTAINER_MODE=production
 ENV CONTAINER_MODE=${CONTAINER_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
 
-ENTRYPOINT bash -c '([[ "${CONTAINER_MODE}" == "dev" ]] && npm install --silent); npm run docker:dev'
+ENTRYPOINT bash -c '([[ "${CONTAINER_MODE}" == "development" ]] && npm install --silent); npm run docker:dev'
 
 EXPOSE 8080/tcp
 EXPOSE 8080/udp

+ 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
 	}
-}
+}

+ 15 - 7
backend/core.js

@@ -267,18 +267,26 @@ 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 +297,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 +311,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);
 		}
@@ -615,7 +623,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 +698,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",

+ 62 - 37
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");
 }
 
@@ -262,6 +269,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");
 }
@@ -298,43 +311,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 +449,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 +457,5 @@ rl.on("line",function(command) {
 });
 
 export default moduleManager;
+
+export { MUSARE_VERSION };

+ 200 - 1
backend/logic/actions/apis.js

@@ -11,6 +11,7 @@ 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 {
 	/**
@@ -117,6 +118,202 @@ 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
 	 *
@@ -131,7 +328,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",
@@ -213,6 +410,8 @@ export default {
 			page === "punishments" ||
 			page === "youtube" ||
 			page === "youtubeVideos" ||
+			page === "youtubeChannels" ||
+			(config.get("experimental.soundcloud") && (page === "soundcloud" || page === "soundcloudTracks")) ||
 			page === "import" ||
 			page === "dataRequests"
 		) {

+ 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
 };

+ 92 - 69
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
 					}
@@ -190,10 +190,10 @@ 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 +202,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -211,7 +211,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -234,7 +234,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
+								args: [mediaSource, user.dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -254,7 +254,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "addSongToPlaylist",
-								args: [false, youtubeId, likedSongsPlaylist]
+								args: [false, mediaSource, likedSongsPlaylist]
 							},
 							this
 						)
@@ -266,7 +266,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 +277,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 +289,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.like",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -300,8 +300,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
 					}
 				});
@@ -318,10 +318,10 @@ 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 +330,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -339,7 +339,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -362,7 +362,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.likedSongsPlaylist]
+								args: [mediaSource, user.likedSongsPlaylist]
 							},
 							this
 						)
@@ -382,7 +382,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "addSongToPlaylist",
-								args: [false, youtubeId, dislikedSongsPlaylist]
+								args: [false, mediaSource, dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -394,7 +394,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 +405,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 +417,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.dislike",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -428,8 +428,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
 					}
 				});
@@ -446,10 +446,10 @@ 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 +458,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -467,7 +467,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -490,7 +490,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
+								args: [mediaSource, user.dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -510,7 +510,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
+								args: [mediaSource, likedSongsPlaylist]
 							},
 							this
 						)
@@ -523,7 +523,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 +534,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 +546,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.undislike",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -557,10 +557,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
 					}
 				});
@@ -577,10 +577,10 @@ 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 +589,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -598,7 +598,7 @@ export default {
 							const { _id, title, artists, thumbnail, duration, verified } = song;
 							next(null, {
 								_id,
-								youtubeId,
+								mediaSource,
 								title,
 								artists,
 								thumbnail,
@@ -621,7 +621,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
+								args: [mediaSource, user.dislikedSongsPlaylist]
 							},
 							this
 						)
@@ -641,7 +641,7 @@ export default {
 								session,
 								namespace: "playlists",
 								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
+								args: [mediaSource, likedSongsPlaylist]
 							},
 							this
 						)
@@ -654,7 +654,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 +665,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 +677,7 @@ export default {
 				CacheModule.runJob("PUB", {
 					channel: "ratings.unlike",
 					value: JSON.stringify({
-						youtubeId,
+						mediaSource,
 						userId: session.userId,
 						likes,
 						dislikes
@@ -688,10 +688,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
 					}
 				});
@@ -708,15 +708,15 @@ 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 +734,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 });
 				}
@@ -756,10 +756,10 @@ 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 +768,7 @@ export default {
 					MediaModule.runJob(
 						"GET_MEDIA",
 						{
-							youtubeId
+							mediaSource
 						},
 						this
 					)
@@ -787,7 +787,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 +805,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 +818,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 +828,7 @@ export default {
 				return cb({
 					status: "success",
 					data: {
-						youtubeId,
+						mediaSource,
 						liked: isLiked,
 						disliked: isDisliked
 					}
@@ -913,5 +913,28 @@ 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 });
+			});
 	})
 };

File diff suppressed because it is too large
+ 830 - 214
backend/logic/actions/playlists.js


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

@@ -512,7 +512,7 @@ export default {
 	 *
 	 * @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 +520,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 +537,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 +571,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 +583,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",

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

@@ -296,45 +296,48 @@ export default {
 	 * 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
@@ -468,8 +471,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 +532,7 @@ export default {
 													session,
 													namespace: "playlists",
 													action: "removeSongFromPlaylist",
-													args: [song.youtubeId, playlistId]
+													args: [song.mediaSource, playlistId]
 												},
 												this
 											)
@@ -627,7 +635,7 @@ export default {
 							if (!youtubeVideo)
 								StationsModule.runJob(
 									"REMOVE_FROM_QUEUE",
-									{ stationId, youtubeId: song.youtubeId },
+									{ stationId, mediaSource: song.mediaSource },
 									this
 								)
 									.then(() => next())
@@ -656,7 +664,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();
 									})

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

@@ -0,0 +1,265 @@
+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 });
+			});
+	})
+};

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

@@ -0,0 +1,83 @@
+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 });
+				});
+		}
+	)
+};

+ 177 - 37
backend/logic/actions/stations.js

@@ -467,7 +467,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",
@@ -864,7 +864,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;
@@ -996,6 +996,75 @@ 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(
 			[
@@ -1141,9 +1210,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);
@@ -1257,7 +1326,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",
@@ -1331,6 +1400,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);
 				},
@@ -1652,9 +1727,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 +1776,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 +1860,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) => {
@@ -1822,12 +1911,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 +1964,9 @@ export default {
 						"ADD_TO_QUEUE",
 						{
 							stationId,
-							youtubeId,
-							requestUser: session.userId
+							mediaSource,
+							requestUser: session.userId,
+							requestType
 						},
 						this
 					)
@@ -1888,7 +1979,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 +1987,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({
@@ -1912,10 +2003,10 @@ export default {
 	 *
 	 * @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 +2016,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 +2028,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 +2036,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({
@@ -2016,7 +2107,7 @@ export default {
 	 * @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 +2124,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 +2132,7 @@ export default {
 				next => {
 					stationModel.updateOne(
 						{ _id: stationId },
-						{ $pull: { queue: { youtubeId: song.youtubeId } } },
+						{ $pull: { queue: { mediaSource: song.mediaSource } } },
 						next
 					);
 				},
@@ -2068,7 +2159,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 +2167,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
 						},
@@ -2123,7 +2214,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)
@@ -2392,6 +2505,13 @@ 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 +2579,13 @@ 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);
 
@@ -2516,7 +2643,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(
@@ -2571,7 +2698,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
 	 */
@@ -2605,10 +2732,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) {
@@ -2657,7 +2784,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
@@ -2692,7 +2819,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 +2849,18 @@ 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 - 9
backend/logic/actions/users.js

@@ -394,7 +394,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 +408,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 +422,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);
 						},
@@ -625,7 +625,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 +639,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 +653,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);
 						},
@@ -870,7 +870,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 +881,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();
 				},
 
@@ -2941,6 +2956,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);

+ 19 - 4
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
 					});
 				}
 			}

+ 225 - 10
backend/logic/actions/youtube.js

@@ -244,7 +244,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 +265,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 +378,81 @@ 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 +461,42 @@ 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,6 +537,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
 	 *
@@ -451,7 +646,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
 				});
 			})
@@ -572,16 +767,36 @@ 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 - 6
backend/logic/activities.js

@@ -43,7 +43,7 @@ class _ActivitiesModule extends CoreClass {
 	 * @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
@@ -287,11 +287,11 @@ 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 +302,7 @@ class _ActivitiesModule extends CoreClass {
 				[
 					next => {
 						if (
-							(payload.type !== "youtubeId" &&
+							(payload.type !== "mediaSource" &&
 								payload.type !== "stationId" &&
 								payload.type !== "reportId" &&
 								payload.type !== "playlistId") ||
@@ -333,9 +333,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"
 									);
 								}

+ 1 - 1
backend/logic/api.js

@@ -36,7 +36,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 - 12
backend/logic/app.js

@@ -42,8 +42,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 +57,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 +71,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 +84,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 +101,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 +119,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 +229,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 +435,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 +446,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 +509,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`);
 					}
 				);
 			});

+ 80 - 9
backend/logic/cache/index.js

@@ -43,14 +43,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");
@@ -113,6 +109,48 @@ 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
 	 *
@@ -139,6 +177,42 @@ 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
 	 *
@@ -310,10 +384,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(() => {

+ 90 - 22
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 = {
@@ -54,10 +61,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 +82,14 @@ class _DBModule extends CoreClass {
 						punishment: {},
 						youtubeApiRequest: {},
 						youtubeVideo: {},
-						ratings: {}
+						youtubeChannel: {},
+						ratings: {},
+						stationHistory: {},
+						soundcloudTrack: {},
+						spotifyTrack: {},
+						spotifyAlbum: {},
+						spotifyArtist: {},
+						genericApiRequest: {}
 					};
 
 					const importSchema = schemaName =>
@@ -98,8 +112,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 +135,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 +222,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 +266,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 +275,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 +304,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 {
@@ -289,17 +340,34 @@ class _DBModule extends CoreClass {
 		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));

+ 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 - 11
backend/logic/mail/index.js

@@ -32,18 +32,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();
@@ -60,6 +51,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 => {

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

@@ -12,7 +12,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 +21,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 - 2
backend/logic/mail/schemas/passwordRequest.js

@@ -1,4 +1,3 @@
-import config from "config";
 import mail from "../index";
 
 /**
@@ -11,7 +10,6 @@ import mail from "../index";
  */
 export default (to, username, code, cb) => {
 	const data = {
-		from: config.get("mail.from"),
 		to,
 		subject: "Password request",
 		html: `

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

@@ -1,4 +1,3 @@
-import config from "config";
 import mail from "../index";
 
 /**
@@ -11,7 +10,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 - 4
backend/logic/mail/schemas/verifyEmail.js

@@ -10,17 +10,15 @@ import mail from "../index";
  * @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>.
 			`
 	};
 

+ 212 - 42
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;
 
@@ -29,6 +32,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 +80,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 +113,7 @@ class _MediaModule extends CoreClass {
 							(rating, next) => {
 								CacheModule.runJob("HSET", {
 									table: "ratings",
-									key: rating.youtubeId,
+									key: rating.mediaSource,
 									value: MediaModule.RatingsSchemaCache(rating)
 								})
 									.then(() => next())
@@ -132,7 +137,7 @@ 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 +148,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 +158,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 +168,7 @@ class _MediaModule extends CoreClass {
 
 					({ likes, dislikes }, next) => {
 						MediaModule.RatingsModel.findOneAndUpdate(
-							{ youtubeId: payload.youtubeId },
+							{ mediaSource: payload.mediaSource },
 							{
 								$set: {
 									likes,
@@ -180,7 +185,7 @@ class _MediaModule extends CoreClass {
 							"HSET",
 							{
 								table: "ratings",
-								key: payload.youtubeId,
+								key: payload.mediaSource,
 								value: ratings
 							},
 							this
@@ -207,30 +212,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();
 									})
@@ -256,7 +262,7 @@ 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 +271,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 +286,7 @@ class _MediaModule extends CoreClass {
 								"HSET",
 								{
 									table: "ratings",
-									key: payload.youtubeId,
+									key: payload.mediaSource,
 									value: ratings
 								},
 								this
@@ -288,13 +294,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)
 				],
@@ -310,29 +316,29 @@ 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 +357,10 @@ 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 +369,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,6 +487,97 @@ 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
 	 *

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

@@ -33,10 +33,10 @@ class _MigrationModule extends CoreClass {
 		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 +68,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"));

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

@@ -0,0 +1,271 @@
+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();
+					}
+				);
+			}
+		});
+	});
+}

+ 126 - 0
backend/logic/musicbrainz.js

@@ -0,0 +1,126 @@
+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();

+ 1 - 5
backend/logic/notifications.js

@@ -24,12 +24,8 @@ class _NotificationsModule extends CoreClass {
 	 */
 	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");

+ 194 - 27
backend/logic/playlists.js

@@ -159,6 +159,32 @@ 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
 	 *
@@ -382,12 +408,12 @@ class _PlaylistsModule extends CoreClass {
 	 *
 	 * @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 +427,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 +487,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 +504,136 @@ 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 +646,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 +684,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 +711,18 @@ 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 {
@@ -640,10 +785,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,
@@ -929,17 +1074,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 +1094,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 => {
@@ -1323,6 +1481,15 @@ 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();

+ 173 - 71
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;
@@ -32,6 +34,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 +71,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 +104,7 @@ class _SongsModule extends CoreClass {
 							(song, next) => {
 								CacheModule.runJob("HSET", {
 									table: "songs",
-									key: song.youtubeId,
+									key: song.mediaSource,
 									value: SongsModule.SongSchemaCache(song)
 								})
 									.then(() => next())
@@ -171,55 +174,125 @@ 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) => {
@@ -276,7 +349,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);
 					},
@@ -336,11 +409,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 +442,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 +491,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);
@@ -563,10 +644,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 +734,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,
@@ -948,11 +1029,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 +1067,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,
@@ -1077,7 +1168,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 +1180,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();
@@ -1118,29 +1209,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 +1242,7 @@ class _SongsModule extends CoreClass {
 											const { _id, title, artists, thumbnail, duration, verified } = song;
 											const trimmedSong = {
 												_id,
-												youtubeId,
+												mediaSource,
 												title,
 												artists,
 												thumbnail,
@@ -1157,7 +1250,7 @@ class _SongsModule extends CoreClass {
 												verified
 											};
 											playlistModel.updateMany(
-												{ "songs.youtubeId": song.youtubeId },
+												{ "songs.mediaSource": song.mediaSource },
 												{ $set: { "songs.$": trimmedSong } },
 												err => {
 													next(err, song);
@@ -1275,6 +1368,15 @@ 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();

+ 710 - 0
backend/logic/soundcloud.js

@@ -0,0 +1,710 @@
+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();

+ 1503 - 0
backend/logic/spotify.js

@@ -0,0 +1,1503 @@
+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();

+ 214 - 84
backend/logic/stations.js

@@ -127,6 +127,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 +173,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) => {
@@ -269,7 +294,8 @@ class _StationsModule extends CoreClass {
 									cb: () =>
 										StationsModule.runJob("SKIP_STATION", {
 											stationId: station._id,
-											natural: true
+											natural: true,
+											skipReason: "natural"
 										}),
 									unique: true,
 									station
@@ -277,28 +303,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 +349,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: station._id,
-									natural: false
+									natural: false,
+									skipReason: "other"
 								},
 								this
 							)
@@ -547,19 +588,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 +614,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 +634,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 +655,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 +676,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 +688,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 +714,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 +726,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);
@@ -749,17 +795,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 +822,7 @@ class _StationsModule extends CoreClass {
 									verified,
 									requestedAt: queueSong.requestedAt,
 									requestedBy: queueSong.requestedBy,
+									requestedType: queueSong.requestedType,
 									likes: song.likes || 0,
 									dislikes: song.dislikes || 0
 								});
@@ -883,7 +938,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 +968,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: payload.stationId,
-									natural: false
+									natural: false,
+									skipReason: "vote_skip"
 								},
 								this
 							)
@@ -926,12 +986,56 @@ 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 +1073,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 +1138,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 +1168,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 +1176,7 @@ class _StationsModule extends CoreClass {
 								thumbnail: song.thumbnail,
 								requestedAt: song.requestedAt,
 								requestedBy: song.requestedBy,
+								requestedType: song.requestedType,
 								verified: song.verified
 							};
 						}
@@ -1071,7 +1200,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 +1306,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", {
@@ -1819,13 +1948,14 @@ class _StationsModule extends CoreClass {
 	 *
 	 * @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 +1969,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 +1987,7 @@ class _StationsModule extends CoreClass {
 									null,
 									{
 										_id,
-										youtubeId,
+										mediaSource,
 										title,
 										skipDuration,
 										artists,
@@ -1894,11 +2025,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 +2037,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 +2096,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(
@@ -2036,12 +2166,12 @@ class _StationsModule extends CoreClass {
 	 *
 	 * @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 +2184,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
 						);
 					},

+ 53 - 2
backend/logic/tasks.js

@@ -15,6 +15,18 @@ let UtilsModule;
 let WSModule;
 let DBModule;
 
+const stationStateWorth = {
+	unknown: 0,
+	no_song: 1,
+	station_paused: 2,
+	participate: 3,
+	local_paused: 4,
+	muted: 5,
+	unavailable: 6,
+	buffering: 7,
+	playing: 8
+};
+
 class _TasksModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
@@ -62,6 +74,12 @@ class _TasksModule extends CoreClass {
 				timeout: 1000 * 3
 			});
 
+			// TasksModule.runJob("CREATE_TASK", {
+			//	name: "historyClearTask",
+			//	fn: TasksModule.historyClearTask,
+			//	timeout: 1000 * 60 * 60 * 6
+			// });
+
 			resolve();
 		});
 	}
@@ -397,8 +415,20 @@ class _TasksModule extends CoreClass {
 								(user, next) => {
 									if (!user) return next("User not found.");
 
-									if (usersPerStation[stationId].loggedIn.some(u => user.username === u.username))
+									const state = socket.session.stationState ?? "unknown";
+
+									const existingUserObject = usersPerStation[stationId].loggedIn.findLast(
+										u => user.username === u.username
+									);
+
+									if (existingUserObject) {
+										if (stationStateWorth[state] > stationStateWorth[existingUserObject.state]) {
+											usersPerStation[stationId].loggedIn[
+												usersPerStation[stationId].loggedIn.indexOf(existingUserObject)
+											].state = state;
+										}
 										return next("User already in the list.");
+									}
 
 									usersPerStationCount[stationId] += 1; // increment user count for station
 
@@ -406,7 +436,8 @@ class _TasksModule extends CoreClass {
 										_id: user._id,
 										username: user.username,
 										name: user.name,
-										avatar: user.avatar
+										avatar: user.avatar,
+										state
 									});
 								}
 							],
@@ -466,6 +497,26 @@ class _TasksModule extends CoreClass {
 			resolve();
 		});
 	}
+
+	/**
+	 * Periodically removes any old history documents
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async historyClearTask() {
+		TasksModule.log("INFO", "TASK_HISTORY_CLEAR", `Removing old history.`);
+
+		const stationHistoryModel = await DBModule.runJob("GET_MODEL", { modelName: "stationHistory" });
+
+		// Remove documents created more than 2 days ago
+		const mongoQuery = { "payload.skippedAt": { $lt: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2) } };
+
+		const count = await stationHistoryModel.count(mongoQuery);
+
+		await stationHistoryModel.remove(mongoQuery);
+
+		TasksModule.log("SUCCESS", "TASK_HISTORY_CLEAR", `Removed ${count} history items`);
+	}
 }
 
 export default new _TasksModule();

+ 297 - 0
backend/logic/wikidata.js

@@ -0,0 +1,297 @@
+import axios from "axios";
+
+import CoreClass from "../core";
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed WikiData request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a WikiData 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 WikiDataModule;
+let DBModule;
+
+class _WikiDataModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("wikidata", {
+			concurrency: 10
+		});
+
+		WikiDataModule = this;
+	}
+
+	/**
+	 * Initialises the activities 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();
+	}
+
+	/**
+	 * Get WikiData data from entity url
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.entityUrl - entity url
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_ENTITY_URL(payload) {
+		const { entityUrl } = payload;
+
+		const sparqlQuery = `PREFIX entity_url: <${entityUrl}>
+							SELECT DISTINCT ?YouTube_video_ID ?SoundCloud_track_ID WHERE {
+								OPTIONAL { entity_url: wdt:P1651 ?YouTube_video_ID. }
+								OPTIONAL { entity_url: wdt:P3040 ?SoundCloud_track_ID. }
+							}`
+			.replaceAll("\n", "")
+			.replaceAll("\t", "");
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
+	/**
+	 * Get WikiData data from work id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.workId - work id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_MUSICBRAINZ_WORK(payload) {
+		const { workId } = payload;
+
+		const sparqlQuery =
+			`SELECT DISTINCT ?item ?itemLabel ?YouTube_video_ID ?SoundCloud_ID ?Music_video_entity_URL WHERE {
+				SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }
+				{
+					SELECT DISTINCT ?item WHERE {
+						?item p:P435 ?statement0.
+						?statement0 ps:P435 "${workId}".
+					}
+					LIMIT 100
+				}
+				OPTIONAL { ?item wdt:P1651 ?YouTube_video_ID. }
+				OPTIONAL { ?item wdt:P3040 ?SoundCloud_ID. }
+				OPTIONAL { ?item wdt:P6718 ?Music_video_entity_URL. }
+			}`
+				.replaceAll("\n", "")
+				.replaceAll("\t", "");
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
+	/**
+	 * Get WikiData data from release group id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.releaseGroupId - release group id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_MUSICBRAINZ_RELEASE_GROUP(payload) {
+		const { releaseGroupId } = payload;
+
+		const sparqlQuery =
+			`SELECT DISTINCT ?item ?itemLabel ?YouTube_video_ID ?SoundCloud_ID ?Music_video_entity_URL WHERE {
+				SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }
+				{
+					SELECT DISTINCT ?item WHERE {
+						?item p:P436 ?statement0.
+						?statement0 ps:P436 "${releaseGroupId}".
+					}
+					LIMIT 100
+				}
+				OPTIONAL { ?item wdt:P1651 ?YouTube_video_ID. }
+				OPTIONAL { ?item wdt:P3040 ?SoundCloud_ID. }
+				OPTIONAL { ?item wdt:P6718 ?Music_video_entity_URL. }
+			}`
+				.replaceAll("\n", "")
+				.replaceAll("\t", "");
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
+	/**
+	 * Get WikiData data from Spotify album id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.spotifyAlbumId - Spotify album id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_SPOTIFY_ALBUM(payload) {
+		const { spotifyAlbumId } = payload;
+
+		if (!spotifyAlbumId) throw new Error("Invalid Spotify album ID provided.");
+
+		const sparqlQuery = `SELECT DISTINCT ?item ?itemLabel ?YouTube_playlist_ID ?SoundCloud_ID WHERE {
+				SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }
+				{
+					SELECT DISTINCT ?item WHERE {
+						?item p:P2205 ?statement0.
+						?statement0 ps:P2205 "${spotifyAlbumId}".
+					}
+					LIMIT 100
+				}
+				OPTIONAL { ?item wdt:P4300 ?YouTube_playlist_ID. }
+				OPTIONAL { ?item wdt:P3040 ?SoundCloud_ID. }
+			}`
+			.replaceAll("\n", "")
+			.replaceAll("\t", "");
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
+	/**
+	 * Get WikiData data from Spotify artist id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.spotifyArtistId - Spotify artist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_SPOTIFY_ARTIST(payload) {
+		const { spotifyArtistId } = payload;
+
+		if (!spotifyArtistId) throw new Error("Invalid Spotify artist ID provided.");
+
+		const sparqlQuery =
+			`SELECT DISTINCT ?item ?itemLabel ?YouTube_channel_ID ?SoundCloud_ID ?MusicBrainz_artist_ID WHERE {
+				SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }
+				{
+					SELECT DISTINCT ?item WHERE {
+						?item p:P1902 ?statement0.
+						?statement0 ps:P1902 "${spotifyArtistId}".
+					}
+					LIMIT 100
+				}
+				OPTIONAL { ?item wdt:P2397 ?YouTube_channel_ID. }
+				OPTIONAL { ?item wdt:P3040 ?SoundCloud_ID. }
+				OPTIONAL { ?item wdt:P434 ?MusicBrainz_artist_ID. }
+			}`
+				.replaceAll("\n", "")
+				.replaceAll("\t", "");
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
+	/**
+	 * Perform WikiData 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 WikiDataModule.GenericApiRequestModel.findOne({
+			url,
+			params
+		});
+		if (genericApiRequest) return genericApiRequest._doc.responseData;
+
+		await WikiDataModule.rateLimiter.continue();
+		WikiDataModule.rateLimiter.restart();
+
+		const { data: responseData } = await WikiDataModule.axios.get(url, {
+			params,
+			headers: {
+				Accept: "application/sparql-results+json"
+			},
+			timeout: WikiDataModule.requestTimeout
+		});
+
+		if (responseData.error) throw new Error(responseData.error);
+
+		genericApiRequest = new WikiDataModule.GenericApiRequestModel({
+			url,
+			params,
+			responseData,
+			date: Date.now()
+		});
+		genericApiRequest.save();
+
+		return responseData;
+	}
+}
+
+export default new _WikiDataModule();

+ 41 - 17
backend/logic/ws.js

@@ -44,7 +44,7 @@ class _WSModule extends CoreClass {
 
 		this.setStage(2);
 
-		this.SIDname = config.get("cookie.SIDname");
+		this.SIDname = config.get("cookie");
 
 		// TODO: Check every 30s/, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
 		const server = await AppModule.runJob("SERVER");
@@ -586,6 +586,34 @@ class _WSModule extends CoreClass {
 				console.error("SOCKET ERROR: ", error);
 			};
 
+			const readyData = {
+				config: {
+					cookie: config.get("cookie"),
+					sitename: config.get("sitename"),
+					recaptcha: {
+						enabled: config.get("apis.recaptcha.enabled"),
+						key: config.get("apis.recaptcha.key")
+					},
+					githubAuthentication: config.get("apis.github.enabled"),
+					messages: config.get("messages"),
+					christmas: config.get("christmas"),
+					footerLinks: config.get("footerLinks"),
+					shortcutOverrides: config.get("shortcutOverrides"),
+					registrationDisabled: config.get("registrationDisabled"),
+					mailEnabled: config.get("mail.enabled"),
+					discogsEnabled: config.get("apis.discogs.enabled"),
+					experimental: {
+						changable_listen_mode: config.get("experimental.changable_listen_mode"),
+						media_session: config.get("experimental.media_session"),
+						disable_youtube_search: config.get("experimental.disable_youtube_search"),
+						station_history: config.get("experimental.station_history"),
+						soundcloud: config.get("experimental.soundcloud"),
+						spotify: config.get("experimental.spotify")
+					}
+				},
+				user: { loggedIn: false }
+			};
+
 			if (socket.session.sessionId) {
 				CacheModule.runJob("HGET", {
 					table: "sessions",
@@ -594,28 +622,24 @@ class _WSModule extends CoreClass {
 					.then(session => {
 						if (session && session.userId) {
 							WSModule.userModel.findOne({ _id: session.userId }, (err, user) => {
-								if (err || !user) return socket.dispatch("ready", { data: { loggedIn: false } });
-
-								let role = "";
-								let username = "";
-								let userId = "";
-								let email = "";
+								if (err || !user) return socket.dispatch("ready", readyData);
 
 								if (user) {
-									role = user.role;
-									username = user.username;
-									email = user.email.address;
-									userId = session.userId;
+									readyData.user = {
+										loggedIn: true,
+										role: user.role,
+										username: user.username,
+										email: user.email.address,
+										userId: session.userId
+									};
 								}
 
-								return socket.dispatch("ready", {
-									data: { loggedIn: true, role, username, userId, email }
-								});
+								return socket.dispatch("ready", readyData);
 							});
-						} else socket.dispatch("ready", { data: { loggedIn: false } });
+						} else socket.dispatch("ready", readyData);
 					})
-					.catch(() => socket.dispatch("ready", { data: { loggedIn: false } }));
-			} else socket.dispatch("ready", { data: { loggedIn: false } });
+					.catch(() => socket.dispatch("ready", readyData));
+			} else socket.dispatch("ready", readyData);
 
 			socket.onmessage = message => {
 				const data = JSON.parse(message.data);

+ 276 - 139
backend/logic/youtube.js

@@ -117,6 +117,10 @@ class _YouTubeModule extends CoreClass {
 			modelName: "youtubeVideo"
 		});
 
+		this.youtubeChannelModel = this.YoutubeChannelModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "youtubeChannel"
+		});
+
 		return new Promise(resolve => {
 			CacheModule.runJob("SUB", {
 				channel: "youtube.removeYoutubeApiRequest",
@@ -139,7 +143,7 @@ class _YouTubeModule extends CoreClass {
 					const videos = Array.isArray(videoIds) ? videoIds : [videoIds];
 					videos.forEach(videoId => {
 						WSModule.runJob("EMIT_TO_ROOM", {
-							room: `view-youtube-video.${videoId}`,
+							room: `view-media.youtube:${videoId}`,
 							args: ["event:youtubeVideo.removed"]
 						});
 
@@ -620,6 +624,9 @@ class _YouTubeModule extends CoreClass {
 				return;
 			}
 			const playlistId = splitQuery[1];
+			const maxPages = Number.parseInt(config.get("apis.youtube.maxPlaylistPages"));
+
+			let currentPage = 0;
 
 			async.waterfall(
 				[
@@ -635,9 +642,10 @@ class _YouTubeModule extends CoreClass {
 										songs.length
 									} songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
 								);
-								next(null, nextPageToken !== undefined);
+								next(null, nextPageToken !== undefined && currentPage < maxPages);
 							},
 							next => {
+								currentPage += 1;
 								// Add 250ms delay between each job request
 								setTimeout(() => {
 									YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
@@ -679,7 +687,7 @@ class _YouTubeModule extends CoreClass {
 	}
 
 	/**
-	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL.
+	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL_VIDEOS.
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {boolean} payload.playlistId - the playlist id to get videos from
@@ -787,7 +795,7 @@ class _YouTubeModule extends CoreClass {
 	 * @param {string} payload.url - the url of the YouTube channel
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	GET_CHANNEL(payload) {
+	GET_CHANNEL_VIDEOS(payload) {
 		return new Promise((resolve, reject) => {
 			const regex =
 				/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
@@ -803,11 +811,6 @@ class _YouTubeModule extends CoreClass {
 			const channelCustomUrl = splitQuery[3];
 			const channelUsernameOrCustomUrl = splitQuery[4];
 
-			console.log(`Channel id: ${channelId}`);
-			console.log(`Channel username: ${channelUsername}`);
-			console.log(`Channel custom URL: ${channelCustomUrl}`);
-			console.log(`Channel username or custom URL: ${channelUsernameOrCustomUrl}`);
-
 			const disableSearch = payload.disableSearch || false;
 
 			async.waterfall(
@@ -1014,10 +1017,7 @@ class _YouTubeModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			const { params } = payload;
 
-			if (
-				config.has("experimental.disable_youtube_search") &&
-				config.get("experimental.disable_youtube_search")
-			) {
+			if (config.get("experimental.disable_youtube_search")) {
 				reject(new Error("Searching with YouTube is disabled."));
 				return;
 			}
@@ -1327,7 +1327,7 @@ class _YouTubeModule extends CoreClass {
 	 * Create YouTube videos
 	 *
 	 * @param {object} payload - an object containing the payload
-	 * @param {string} payload.youtubeVideos - the youtubeVideo object or array of
+	 * @param {Array | object} payload.youtubeVideos - the youtubeVideo object or array of
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	CREATE_VIDEOS(payload) {
@@ -1344,12 +1344,13 @@ class _YouTubeModule extends CoreClass {
 					},
 
 					(youtubeVideos, next) => {
-						const youtubeIds = youtubeVideos.map(video => video.youtubeId);
+						// TODO support spotify here
+						const mediaSources = youtubeVideos.map(video => `youtube:${video.youtubeId}`);
 						async.eachLimit(
-							youtubeIds,
+							mediaSources,
 							2,
-							(youtubeId, next) => {
-								MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId }, this)
+							(mediaSource, next) => {
+								MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
 									.then(() => next())
 									.catch(next);
 							},
@@ -1369,96 +1370,187 @@ class _YouTubeModule extends CoreClass {
 	}
 
 	/**
-	 * Get YouTube video
+	 * Get YouTube videos
 	 *
 	 * @param {object} payload - an object containing the payload
-	 * @param {string} payload.identifier - the youtube video ObjectId or YouTube ID
-	 * @param {string} payload.createMissing - attempt to fetch and create video if not in db
+	 * @param {Array} payload.identifiers - an array of YouTube video ObjectId's or YouTube ID's
+	 * @param {boolean} payload.createMissing - attempt to fetch and create video's if not in db
+	 * @param {boolean} payload.replaceExisting - replace existing
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	GET_VIDEO(payload) {
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						const query = mongoose.Types.ObjectId.isValid(payload.identifier)
-							? { _id: payload.identifier }
-							: { youtubeId: payload.identifier };
+	async GET_VIDEOS(payload) {
+		const getVideosFromYoutubeIds = async youtubeIds => {
+			const jobsToRun = [];
 
-						return YouTubeModule.youtubeVideoModel.findOne(query, next);
-					},
+			const chunkSize = 50;
+			while (youtubeIds.length > 0) {
+				const chunkedYoutubeIds = youtubeIds.splice(0, chunkSize);
 
-					(video, next) => {
-						if (video) return next(null, video, false);
-						if (mongoose.Types.ObjectId.isValid(payload.identifier) || !payload.createMissing)
-							return next("YouTube video not found.");
+				const params = {
+					part: "snippet,contentDetails,statistics,status",
+					id: chunkedYoutubeIds.join(",")
+				};
 
-						const params = {
-							part: "snippet,contentDetails,statistics,status",
-							id: payload.identifier
-						};
+				jobsToRun.push(YouTubeModule.runJob("API_GET_VIDEOS", { params }, this));
+			}
 
-						return YouTubeModule.runJob("API_GET_VIDEOS", { params }, this)
-							.then(({ response }) => {
-								const { data } = response;
-								if (data.items[0] === undefined)
-									return next("The specified video does not exist or cannot be publicly accessed.");
+			const jobResponses = await Promise.all(jobsToRun);
 
-								// TODO Clean up duration converter
-								let dur = data.items[0].contentDetails.duration;
+			return jobResponses
+				.map(jobResponse => jobResponse.response.data.items)
+				.flat()
+				.map(item => {
+					// TODO Clean up duration converter
+					let dur = item.contentDetails.duration;
 
-								dur = dur.replace("PT", "");
+					dur = dur.replace("PT", "");
 
-								let duration = 0;
+					let duration = 0;
 
-								dur = dur.replace(/([\d]*)H/, (v, v2) => {
-									v2 = Number(v2);
-									duration = v2 * 60 * 60;
-									return "";
-								});
+					dur = dur.replace(/([\d]*)H/, (v, v2) => {
+						v2 = Number(v2);
+						duration = v2 * 60 * 60;
+						return "";
+					});
 
-								dur = dur.replace(/([\d]*)M/, (v, v2) => {
-									v2 = Number(v2);
-									duration += v2 * 60;
-									return "";
-								});
+					dur = dur.replace(/([\d]*)M/, (v, v2) => {
+						v2 = Number(v2);
+						duration += v2 * 60;
+						return "";
+					});
 
-								dur.replace(/([\d]*)S/, (v, v2) => {
-									v2 = Number(v2);
-									duration += v2;
-									return "";
-								});
+					dur.replace(/([\d]*)S/, (v, v2) => {
+						v2 = Number(v2);
+						duration += v2;
+						return "";
+					});
 
-								const youtubeVideo = {
-									youtubeId: data.items[0].id,
-									title: data.items[0].snippet.title,
-									author: data.items[0].snippet.channelTitle,
-									thumbnail: data.items[0].snippet.thumbnails.default.url,
-									duration,
-									uploadedAt: new Date(data.items[0].snippet.publishedAt)
-								};
+					const youtubeVideo = {
+						youtubeId: item.id,
+						title: item.snippet.title,
+						author: item.snippet.channelTitle,
+						thumbnail: item.snippet.thumbnails.default.url,
+						duration,
+						uploadedAt: new Date(item.snippet.publishedAt),
+						rawData: item
+					};
+
+					return youtubeVideo;
+				});
+		};
 
-								return next(null, false, youtubeVideo);
-							})
-							.catch(next);
-					},
+		const { identifiers, createMissing, replaceExisting } = payload;
 
-					(video, youtubeVideo, next) => {
-						if (video) return next(null, video, true);
-						return YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: youtubeVideo }, this)
-							.then(res => {
-								if (res.youtubeVideos.length === 1) next(null, res.youtubeVideos[0], false);
-								else next("YouTube video not found.");
-							})
-							.catch(next);
-					}
-				],
-				(err, video, existing) => {
-					if (err) reject(new Error(err));
-					else resolve({ video, existing });
-				}
-			);
-		});
+		const youtubeIds = identifiers.filter(identifier => !mongoose.Types.ObjectId.isValid(identifier));
+		const objectIds = identifiers.filter(identifier => mongoose.Types.ObjectId.isValid(identifier));
+
+		const existingVideos = (await YouTubeModule.youtubeVideoModel.find({ youtubeId: youtubeIds }))
+			.concat(await YouTubeModule.youtubeVideoModel.find({ _id: objectIds }))
+			.map(video => video._doc);
+
+		const existingYoutubeIds = existingVideos.map(existingVideo => existingVideo.youtubeId);
+		// const existingYoutubeObjectIds = existingVideos.map(existingVideo => existingVideo._id.toString());
+
+		if (!replaceExisting) {
+			if (!createMissing) return { videos: existingVideos };
+			if (identifiers.length === existingVideos.length || youtubeIds.length === 0)
+				return { videos: existingVideos };
+
+			const missingYoutubeIds = youtubeIds.filter(youtubeId => existingYoutubeIds.indexOf(youtubeId) === -1);
+
+			if (missingYoutubeIds.length === 0) return { videos: existingVideos };
+
+			const newVideos = await getVideosFromYoutubeIds(missingYoutubeIds);
+
+			await YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: newVideos }, this);
+
+			return { videos: existingVideos.concat(newVideos) };
+		}
+
+		const newVideos = await getVideosFromYoutubeIds(existingYoutubeIds);
+
+		const promises = newVideos.map(newVideo =>
+			YouTubeModule.youtubeVideoModel.updateOne(
+				{ youtubeId: newVideo.youtubeId },
+				{ $set: { ...newVideo, updatedAt: Date.now(), documentVersion: 2 } }
+			)
+		);
+
+		await Promise.allSettled(promises);
+
+		return { videos: newVideos };
+	}
+
+	/**
+	 * Get YouTube channels
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.channelIds - an array of YouTube channel id's
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_CHANNELS_FROM_IDS(payload) {
+		const getChannels = async channelIds => {
+			const jobsToRun = [];
+
+			const chunkSize = 50;
+			while (channelIds.length > 0) {
+				const chunkedChannelIds = channelIds.splice(0, chunkSize);
+
+				const params = {
+					part: [
+						"brandingSettings",
+						"contentDetails",
+						"contentOwnerDetails",
+						"id",
+						"localizations",
+						"snippet",
+						"statistics",
+						"status",
+						"topicDetails"
+					].join(","),
+					id: chunkedChannelIds.join(",")
+				};
+
+				jobsToRun.push(YouTubeModule.runJob("API_GET_CHANNELS", { params }, this));
+			}
+
+			const jobResponses = await Promise.all(jobsToRun);
+
+			return jobResponses
+				.map(jobResponse => jobResponse.response.data.items)
+				.flat()
+				.map(item => {
+					const youtubeChannel = {
+						channelId: item.id,
+						title: item.snippet.title,
+						customUrl: item.snippet.customUrl,
+						rawData: item
+					};
+
+					return youtubeChannel;
+				});
+		};
+
+		const { channelIds } = payload;
+
+		const existingChannels = (await YouTubeModule.youtubeChannelModel.find({ channelId: channelIds })).map(
+			channel => channel._doc
+		);
+
+		const existingChannelIds = existingChannels.map(existingChannel => existingChannel.channelId);
+		// const existingChannelObjectIds = existingChannels.map(existingChannel => existingChannel._id.toString());
+
+		if (channelIds.length === existingChannels.length) return { channels: existingChannels };
+
+		const missingChannelIds = channelIds.filter(channelId => existingChannelIds.indexOf(channelId) === -1);
+
+		if (missingChannelIds.length === 0) return { videos: existingChannels };
+
+		const newChannels = await getChannels(missingChannelIds);
+
+		await YouTubeModule.youtubeChannelModel.insertMany(newChannels);
+
+		return { channels: existingChannels.concat(newChannels) };
 	}
 
 	/**
@@ -1591,7 +1683,11 @@ class _YouTubeModule extends CoreClass {
 															(station, next) => {
 																StationsModule.runJob(
 																	"SKIP_STATION",
-																	{ stationId: station._id, natural: false },
+																	{
+																		stationId: station._id,
+																		natural: false,
+																		skipReason: "other"
+																	},
 																	this
 																)
 																	.then(() => {
@@ -1659,7 +1755,7 @@ class _YouTubeModule extends CoreClass {
 							/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
 						if (playlistRegex.exec(payload.url) || channelRegex.exec(payload.url))
 							YouTubeModule.runJob(
-								playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL",
+								playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL_VIDEOS",
 								{
 									url: payload.url,
 									musicOnly: payload.musicOnly
@@ -1673,54 +1769,16 @@ class _YouTubeModule extends CoreClass {
 						else next("Invalid YouTube URL.");
 					},
 
-					(youtubeIds, next) => {
-						let successful = 0;
-						let failed = 0;
-						let alreadyInDatabase = 0;
-
-						let videos = {};
+					async youtubeIds => {
+						if (youtubeIds.length === 0) return { videos: [] };
 
-						const successfulVideoIds = [];
-						const failedVideoIds = [];
-
-						if (youtubeIds.length === 0) next();
-
-						async.eachOfLimit(
-							youtubeIds,
-							1,
-							(youtubeId, index, next2) => {
-								YouTubeModule.runJob("GET_VIDEO", { identifier: youtubeId, createMissing: true }, this)
-									.then(res => {
-										successful += 1;
-										successfulVideoIds.push(youtubeId);
-
-										if (res.existing) alreadyInDatabase += 1;
-										if (res.video) videos[index] = res.video;
-									})
-									.catch(() => {
-										failed += 1;
-										failedVideoIds.push(youtubeId);
-									})
-									.finally(() => {
-										next2();
-									});
-							},
-							() => {
-								if (payload.returnVideos)
-									videos = Object.keys(videos)
-										.sort()
-										.map(key => videos[key]);
-
-								next(null, {
-									successful,
-									failed,
-									alreadyInDatabase,
-									videos,
-									successfulVideoIds,
-									failedVideoIds
-								});
-							}
+						const { videos } = await YouTubeModule.runJob(
+							"GET_VIDEOS",
+							{ identifiers: youtubeIds, createMissing: true },
+							this
 						);
+
+						return { videos };
 					}
 				],
 				(err, response) => {
@@ -1730,6 +1788,85 @@ class _YouTubeModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets missing YouTube video's from all playlists, stations and songs
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_MISSING_VIDEOS() {
+		const youtubeIds = Array.from(
+			new Set(
+				[
+					...(await SongsModule.runJob("GET_ALL_MEDIA_SOURCES", {}, this)),
+					...(await PlaylistsModule.runJob("GET_ALL_MEDIA_SOURCES", {}, this))
+				]
+					.filter(mediaSource => mediaSource.startsWith("youtube:"))
+					.map(mediaSource => mediaSource.split(":")[1])
+			)
+		);
+
+		const existingYoutubeIds = await YouTubeModule.youtubeVideoModel.distinct("youtubeId");
+
+		const missingYoutubeIds = youtubeIds.filter(youtubeId => existingYoutubeIds.indexOf(youtubeId) === -1);
+
+		const res = await YouTubeModule.runJob(
+			"GET_VIDEOS",
+			{ identifiers: missingYoutubeIds, createMissing: true },
+			this
+		);
+
+		const gotVideos = res.videos;
+
+		return {
+			all: youtubeIds.length,
+			existing: existingYoutubeIds.length,
+			missing: missingYoutubeIds.length,
+			got: gotVideos.length
+		};
+	}
+
+	/**
+	 * Updates videos from version 1 to version 2
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_VIDEOS_V1_TO_V2() {
+		const videoIds = await YouTubeModule.youtubeVideoModel.distinct("_id", { documentVersion: 1 });
+
+		const res = await YouTubeModule.runJob("GET_VIDEOS", { identifiers: videoIds, replaceExisting: true }, this);
+
+		const v1 = videoIds.length;
+		const v2 = res.videos.length;
+
+		return {
+			v1,
+			v2
+		};
+	}
+
+	/**
+	 * Gets missing YouTube channels based on cached YouTube video's
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_MISSING_CHANNELS() {
+		const currentChannelIds = await YouTubeModule.youtubeChannelModel.distinct("channelId");
+		const videoChannelIds = await YouTubeModule.youtubeVideoModel.distinct("rawData.snippet.channelId");
+
+		const missingChannelIds = videoChannelIds.filter(channelId => currentChannelIds.indexOf(channelId) === -1);
+
+		const res = await YouTubeModule.runJob("GET_CHANNELS_FROM_IDS", { channelIds: missingChannelIds }, this);
+
+		const gotChannels = res.channels;
+
+		return {
+			current: currentChannelIds.length,
+			all: videoChannelIds.length,
+			missing: missingChannelIds.length,
+			got: gotChannels.length
+		};
+	}
 }
 
 export default new _YouTubeModule();

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


+ 18 - 17
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.9.0",
+  "version": "3.10.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -12,43 +12,44 @@
     "dev": "nodemon --es-module-specifier-resolution=node",
     "docker:dev": "nodemon --es-module-specifier-resolution=node --legacy-watch --no-stdin /opt/app",
     "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
-    "lint": "eslint --cache logic",
+    "lint": "eslint . --ext .js",
     "typescript": "tsc --noEmit --skipLibCheck"
   },
   "dependencies": {
     "async": "^3.2.4",
-    "axios": "^1.1.3",
+    "axios": "^1.3.4",
     "bcrypt": "^5.1.0",
     "bluebird": "^3.7.2",
-    "body-parser": "^1.20.1",
-    "config": "^3.3.8",
+    "body-parser": "^1.20.2",
+    "config": "^3.3.9",
     "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
     "express": "^4.18.2",
     "moment": "^2.29.4",
     "mongoose": "^6.6.5",
-    "nodemailer": "^6.8.0",
+    "nodemailer": "^6.9.1",
     "oauth": "^0.10.0",
-    "redis": "^4.5.1",
+    "redis": "^4.6.5",
     "retry-axios": "^3.0.0",
     "sha256": "^0.2.0",
     "socks": "^2.7.1",
+    "soundcloud-key-fetch": "^1.0.13",
     "underscore": "^1.13.6",
-    "ws": "^8.11.0"
+    "ws": "^8.13.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.45.0",
-    "@typescript-eslint/parser": "^5.45.0",
-    "eslint": "^8.28.0",
+    "@typescript-eslint/eslint-plugin": "^5.54.1",
+    "@typescript-eslint/parser": "^5.54.1",
+    "eslint": "^8.36.0",
     "eslint-config-airbnb-base": "^15.0.0",
-    "eslint-config-prettier": "^8.5.0",
-    "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jsdoc": "^39.6.4",
+    "eslint-config-prettier": "^8.7.0",
+    "eslint-plugin-import": "^2.27.5",
+    "eslint-plugin-jsdoc": "^40.0.1",
     "eslint-plugin-prettier": "^4.2.1",
-    "nodemon": "^2.0.20",
-    "prettier": "2.8.0",
+    "nodemon": "^2.0.21",
+    "prettier": "2.8.4",
     "trace-unhandled": "^2.0.1",
     "ts-node": "^10.9.1",
-    "typescript": "^4.9.3"
+    "typescript": "^4.9.5"
   }
 }

+ 4 - 0
docker-compose.dev.yml

@@ -3,10 +3,14 @@ services:
     ports:
       - "${BACKEND_HOST:-0.0.0.0}:${BACKEND_PORT:-8080}:8080"
     volumes:
+      - ./.git:/opt/.git:ro
+      - ./types:/opt/types
       - ./backend:/opt/app
 
   frontend:
     volumes:
+      - ./.git:/opt/.git:ro
+      - ./types:/opt/types
       - ./frontend:/opt/app
 
   mongo:

+ 37 - 12
docker-compose.yml

@@ -3,15 +3,24 @@ version: "3.8"
 services:
   backend:
     build:
-      context: ./backend
+      context: .
+      dockerfile: ./backend/Dockerfile
       target: musare_backend
     restart: ${RESTART_POLICY:-unless-stopped}
     volumes:
-      - ./.git:/opt/app/.parent_git:ro
       - ./backend/config:/opt/app/config
-      - ./types:/opt/types
     environment:
-      - CONTAINER_MODE=${CONTAINER_MODE:-prod}
+      - CONTAINER_MODE=${CONTAINER_MODE:-production}
+      - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
+      - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
+      - MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE:-false}
+      - MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL:-false}
+      - MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH:-true}
+      - MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}
+      - MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}
+      - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
+      - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
+      - REDIS_PASSWORD=${REDIS_PASSWORD}
     links:
       - mongo
       - redis
@@ -20,21 +29,37 @@ services:
 
   frontend:
     build:
-      context: ./frontend
+      context: .
+      dockerfile: ./frontend/Dockerfile
       target: musare_frontend
       args:
-        FRONTEND_MODE: "${FRONTEND_MODE:-prod}"
+        FRONTEND_MODE: "${FRONTEND_MODE:-production}"
+        FRONTEND_PROD_DEVTOOLS: "${FRONTEND_PROD_DEVTOOLS:-false}"
+        MUSARE_SITENAME: "${MUSARE_SITENAME:-Musare}"
+        MUSARE_DEBUG_VERSION: "${MUSARE_DEBUG_VERSION:-true}"
+        MUSARE_DEBUG_GIT_REMOTE: "${MUSARE_DEBUG_GIT_REMOTE:-false}"
+        MUSARE_DEBUG_GIT_REMOTE_URL: "${MUSARE_DEBUG_GIT_REMOTE_URL:-false}"
+        MUSARE_DEBUG_GIT_BRANCH: ${MUSARE_DEBUG_GIT_BRANCH:-true}
+        MUSARE_DEBUG_GIT_LATEST_COMMIT: "${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}"
+        MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT: "${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}"
     restart: ${RESTART_POLICY:-unless-stopped}
     user: root
     ports:
       - "${FRONTEND_HOST:-0.0.0.0}:${FRONTEND_PORT:-80}:80"
-    volumes:
-      - ./.git:/opt/app/.parent_git:ro
-      - ./frontend/dist/config:/opt/app/dist/config
-      - ./types:/opt/types
     environment:
-      - FRONTEND_MODE=${FRONTEND_MODE:-prod}
-      - CONTAINER_MODE=${CONTAINER_MODE:-prod}
+      - CONTAINER_MODE=${CONTAINER_MODE:-production}
+      - FRONTEND_MODE=${FRONTEND_MODE:-production}
+      - FRONTEND_PORT=${FRONTEND_PORT:-80}
+      - FRONTEND_CLIENT_PORT=${FRONTEND_CLIENT_PORT:-80}
+      - FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-81}
+      - FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS:-false}
+      - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
+      - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
+      - MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE:-false}
+      - MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL:-false}
+      - MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH:-true}
+      - MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}
+      - MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}
     links:
       - backend
 

+ 0 - 4
frontend/.dockerignore

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

+ 5 - 2
frontend/.eslintignore

@@ -1,2 +1,5 @@
-node_modules
-dist
+.git/
+build/
+dist/fonts/
+dist/assets/
+node_modules/

+ 0 - 1
frontend/.eslintrc

@@ -26,7 +26,6 @@
 		"@typescript-eslint"
 	],
 	"globals": {
-		"lofig": "writable",
 		"grecaptcha": "readonly",
 		"history": "readonly"
 	},

+ 5 - 2
frontend/.prettierignore

@@ -1,2 +1,5 @@
-node_modules/
-build/
+.git/
+build/
+dist/fonts/
+dist/assets/
+node_modules/

+ 8 - 8
frontend/.prettierrc

@@ -1,8 +1,8 @@
-{
-    "singleQuote": false,
-    "tabWidth": 4,
-    "useTabs": true,
-    "trailingComma": "none",
-    "arrowParens": "avoid",
-    "endOfLine":"auto"
-}
+{
+	"singleQuote": false,
+	"tabWidth": 4,
+	"useTabs": true,
+	"trailingComma": "none",
+	"arrowParens": "avoid",
+	"endOfLine": "lf"
+}

+ 28 - 15
frontend/Dockerfile

@@ -3,30 +3,43 @@ FROM node:18 AS frontend_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 frontend/package.json frontend/package-lock.json /opt/app/
 
 RUN npm install --silent
 
 FROM node:18 AS musare_frontend
 
-ARG FRONTEND_MODE=prod
-ENV FRONTEND_MODE=${FRONTEND_MODE}
-ENV SUPPRESS_NO_CONFIG_WARNING=1
-ENV NODE_CONFIG_DIR=./dist/config
-
-RUN apt update
-RUN apt install nginx -y
-
-RUN mkdir -p /opt/app /opt/types
+ARG FRONTEND_MODE=production
+ARG FRONTEND_PROD_DEVTOOLS=false
+ARG MUSARE_SITENAME=Musare
+ARG MUSARE_DEBUG_VERSION=true
+ARG MUSARE_DEBUG_GIT_REMOTE=false
+ARG MUSARE_DEBUG_GIT_REMOTE_URL=false
+ARG MUSARE_DEBUG_GIT_BRANCH=true
+ARG MUSARE_DEBUG_GIT_LATEST_COMMIT=true
+ARG MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=true
+
+ENV FRONTEND_MODE=${FRONTEND_MODE} \
+    FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS} \
+    MUSARE_SITENAME=${MUSARE_SITENAME} \
+    MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION} \
+    MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE} \
+    MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL} \
+    MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH} \
+    MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT} \
+    MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT}
+
+RUN apt-get update && apt-get install nginx -y
+
+RUN mkdir -p /opt/.git /opt/types /opt/app /run/nginx
 WORKDIR /opt/app
 
-COPY . /opt/app
+COPY .git /opt/.git
+COPY types /opt/types
+COPY frontend /opt/app
 COPY --from=frontend_node_modules /opt/app/node_modules node_modules
 
-RUN mkdir -p /run/nginx
-
-RUN bash -c '([[ "${FRONTEND_MODE}" == "dev" ]] && exit 0) || npm run prod'
+RUN bash -c '([[ "${FRONTEND_MODE}" == "development" ]] && exit 0) || npm run prod'
 
 RUN chmod u+x entrypoint.sh
 

File diff suppressed because it is too large
+ 0 - 0
frontend/dist/assets/social/soundcloud.svg


+ 1 - 0
frontend/dist/assets/social/spotify.svg

@@ -0,0 +1 @@
+<svg aria-hidden="true" focusable="false" data-icon="spotify" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 168"><path fill="currentColor" d="m83.996 0.277c-46.249 0-83.743 37.493-83.743 83.742 0 46.251 37.494 83.741 83.743 83.741 46.254 0 83.744-37.49 83.744-83.741 0-46.246-37.49-83.738-83.745-83.738l0.001-0.004zm38.404 120.78c-1.5 2.46-4.72 3.24-7.18 1.73-19.662-12.01-44.414-14.73-73.564-8.07-2.809 0.64-5.609-1.12-6.249-3.93-0.643-2.81 1.11-5.61 3.926-6.25 31.9-7.291 59.263-4.15 81.337 9.34 2.46 1.51 3.24 4.72 1.73 7.18zm10.25-22.805c-1.89 3.075-5.91 4.045-8.98 2.155-22.51-13.839-56.823-17.846-83.448-9.764-3.453 1.043-7.1-0.903-8.148-4.35-1.04-3.453 0.907-7.093 4.354-8.143 30.413-9.228 68.222-4.758 94.072 11.127 3.07 1.89 4.04 5.91 2.15 8.976v-0.001zm0.88-23.744c-26.99-16.031-71.52-17.505-97.289-9.684-4.138 1.255-8.514-1.081-9.768-5.219-1.254-4.14 1.08-8.513 5.221-9.771 29.581-8.98 78.756-7.245 109.83 11.202 3.73 2.209 4.95 7.016 2.74 10.733-2.2 3.722-7.02 4.949-10.73 2.739z"/></svg>

+ 0 - 56
frontend/dist/config/template.json

@@ -1,56 +0,0 @@
-{
-	"recaptcha": {
-		"key": "",
-		"enabled": false
-	},
-	"backend": {
-		"apiDomain": "http://localhost/backend",
-		"websocketsDomain": "ws://localhost/backend/ws"
-	},
-	"devServer": {
-		"port": 81,
-		"hmrClientPort": 80
-	},
-	"frontendDomain": "http://localhost",
-	"mode": "development",
-	"cookie": {
-		"domain": "localhost",
-		"secure": false,
-		"SIDname": "SID"
-	},
-	"siteSettings": {
-		"logo_white": "/assets/white_wordmark.png",
-		"logo_blue": "/assets/blue_wordmark.png",
-		"logo_small": "/assets/favicon/mstile-144x144.png",
-		"sitename": "Musare",
-		"footerLinks": {
-			"GitHub": "https://github.com/Musare/Musare"
-		},
-		"christmas": false,
-		"registrationDisabled": false,
-		"githubAuthentication": false
-	},
-	"messages": {
-		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
-	},
-	"shortcutOverrides": {},
-	"debug": {
-		"git": {
-			"remote": false,
-			"remoteUrl": false,
-			"branch": true,
-			"latestCommit": true,
-			"latestCommitShort": true
-		},
-		"version": true
-	},
-	"skipConfigVersionCheck": false,
-	"configVersion": 13,
-	"experimental": {
-		"changable_listen_mode": [
-			"STATION_ID"
-		],
-		"disable_youtube_search": true,
-		"media_session": false
-	}
-}

BIN
frontend/dist/fonts/inter-v3-latin-200.eot


+ 0 - 350
frontend/dist/fonts/inter-v3-latin-200.svg

@@ -1,350 +0,0 @@
-<?xml version="1.0" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg xmlns="http://www.w3.org/2000/svg">
-<defs >
-<font id="Inter" horiz-adv-x="1838" ><font-face
-    font-family="Inter ExtraLight"
-    units-per-em="2816"
-    panose-1="2 11 5 2 3 0 0 0 0 4"
-    ascent="2728"
-    descent="-680"
-    alphabetic="0" />
-<glyph unicode=" " horiz-adv-x="792" />
-<glyph unicode="!" horiz-adv-x="752" d="M443 2048L436 597H316L309 2048H443ZM376 -13Q331 -13 300 18T268 95Q268 139 300 171T376 203Q420 203 452 171T484 95Q484 65 470 41T431 2T376 -13Z" />
-<glyph unicode="&quot;" horiz-adv-x="1019" d="M353 2048V1387H237V2048H353ZM780 2048V1387H664V2048H780Z" />
-<glyph unicode="#" horiz-adv-x="1781" d="M1028 0L1364 2048H1473L1137 0H1028ZM59 613L77 723H1605L1587 613H59ZM308 0L644 2048H753L417 0H308ZM176 1325L195 1435H1723L1704 1325H176Z" />
-<glyph unicode="$" horiz-adv-x="1748" d="M827 -256V2304H924V-256H827ZM1400 1536Q1384 1727 1245 1845T884 1963Q735 1963 619 1908T437 1758T371 1540Q371 1461 401 1398T488 1285T621 1198T791 1133L1015 1067Q1130 1032 1227 986T1397 877T1510 728T1551
-532Q1551 370 1468 242T1231 41T869 -33Q671 -33 525 34T293 220T197 496H325Q335 369 406 277T597 134T869 84Q1034 84 1160 140T1357 298T1428 533Q1428 641 1377 718T1227 851T980 951L741 1023Q501 1096 375 1222T248 1536Q248 1694 332 1816T560 2009T887
-2079Q1070 2079 1210 2009T1432 1816T1523 1536H1400Z" />
-<glyph unicode="%" horiz-adv-x="2213" d="M1272 376V455Q1272 572 1317 663T1442 806T1629 859Q1736 859 1816 807T1940 663T1985 455V376Q1985 259 1941 168T1816 24T1629 -28Q1523 -28 1443 24T1317 167T1272 376ZM1383 455V376Q1383 243 1450 158T1629 72Q1741
-72 1808 157T1875 376V455Q1875 588 1809 673T1629 759Q1518 759 1451 674T1383 455ZM228 1593V1672Q228 1789 273 1880T398 2024T585 2076Q692 2076 772 2024T896 1881T941 1672V1593Q941 1477 897 1386T772 1242T585 1189Q479 1189 399 1241T273 1385T228 1593ZM339
-1672V1593Q339 1460 406 1375T585 1289Q697 1289 764 1374T831 1593V1672Q831 1805 765 1890T585 1976Q474 1976 407 1891T339 1672ZM336 0L1741 2048H1868L463 0H336Z" />
-<glyph unicode="&amp;" horiz-adv-x="1696" d="M769 -28Q594 -28 459 45T246 242T168 523Q168 627 209 713T333 884T545 1064L844 1287Q970 1381 1028 1463T1087 1653Q1087 1781 1000 1872T777 1963Q685 1963 609 1916T487 1792T441 1629Q441 1561 473 1495T566
-1349T712 1164L1644 0H1496L640 1067Q541 1189 469 1281T358 1456T319 1631Q319 1754 379 1854T543 2014T775 2073Q903 2073 1001 2018T1154 1867T1209 1653Q1209 1522 1140 1416T924 1197L588 947Q420 819 356 719T291 523Q291 403 353 304T522 145T767 84Q905
-86 1022 138T1228 283T1374 506T1448 795L1567 789Q1557 664 1523 565T1445 389T1359 260Q1347 244 1336 229T1312 200Q1211 91 1065 32T769 -28Z" />
-<glyph unicode="&apos;" horiz-adv-x="592" d="M353 2048V1387H237V2048H353Z" />
-<glyph unicode="(" horiz-adv-x="932" d="M319 864Q319 1206 402 1528T643 2144H757Q683 2008 625 1854T527 1534T465 1200T443 864Q443 641 481 415T589 -23T757 -416H643Q486 -121 403 201T319 864Z" />
-<glyph unicode=")" horiz-adv-x="932" d="M613 864Q613 522 530 200T289 -416H175Q249 -280 307 -126T405 194T467 528T489 864Q489 1087 451 1313T343 1751T175 2144H289Q446 1849 529 1527T613 864Z" />
-<glyph unicode="*" horiz-adv-x="1408" d="M651 896L656 1389L231 1137L177 1231L609 1472L177 1713L231 1807L656 1555L651 2048H757L752 1555L1177 1807L1231 1713L799 1472L1231 1231L1177 1137L752 1389L757 896H651Z" />
-<glyph unicode="+" horiz-adv-x="1813" d="M848 193V1407H965V193H848ZM300 741V859H1513V741H300Z" />
-<glyph unicode="," horiz-adv-x="745" d="M493 280L475 175Q457 72 430 -57T373 -307T320 -500H225Q239 -428 259 -324T302 -93T345 168L363 280H493Z" />
-<glyph unicode="-" horiz-adv-x="1256" d="M1032 936V820H224V936H1032Z" />
-<glyph unicode="." horiz-adv-x="733" d="M367 -13Q322 -13 291 18T259 95Q259 139 290 171T367 203Q411 203 443 171T475 95Q475 65 461 41T422 2T367 -13Z" />
-<glyph unicode="/" horiz-adv-x="941" d="M860 2144L200 -308H83L743 2144H860Z" />
-<glyph unicode="0" horiz-adv-x="1696" d="M848 -28Q643 -28 496 98T271 459T192 1024Q192 1352 271 1588T496 1950T848 2076Q1053 2076 1199 1950T1425 1588T1504 1024Q1504 695 1426 460T1200 98T848 -28ZM848 88Q1097 88 1239 335T1381 1024Q1381 1318 1317
-1528T1133 1849T848 1961Q599 1961 457 1713T315 1024Q315 730 379 521T562 200T848 88Z" />
-<glyph unicode="1" horiz-adv-x="1201" d="M788 2048V0H663V1916H651L191 1611V1745L647 2048H788Z" />
-<glyph unicode="2" horiz-adv-x="1733" d="M244 0V95L907 859Q1049 1023 1132 1137T1251 1343T1287 1535Q1287 1660 1229 1756T1070 1907T841 1961Q707 1961 606 1902T449 1742T392 1515H271Q271 1674 345 1801T548 2002T844 2076Q1007 2076 1134 2005T1334 1812T1407
-1535Q1407 1422 1369 1318T1237 1085T980 768L424 127V116H1499V0H244Z" />
-<glyph unicode="3" horiz-adv-x="1749" d="M883 -28Q701 -28 558 41T332 232T245 512H372Q375 387 443 292T625 142T881 88Q1034 88 1149 148T1330 310T1395 540Q1395 676 1324 778T1126 938T831 996H755V1112H831Q974 1112 1087 1164T1265 1313T1331 1540Q1331
-1663 1274 1758T1117 1907T881 1961Q751 1961 646 1908T478 1759T415 1536H292Q294 1695 372 1817T583 2007T883 2076Q1053 2076 1181 2005T1381 1813T1453 1544Q1453 1366 1355 1240T1088 1064V1053Q1287 1009 1403 872T1519 540Q1519 380 1437 251T1212 47T883
--28Z" />
-<glyph unicode="4" horiz-adv-x="1720" d="M193 460V560L1120 2048H1196V1855H1131L344 587V576H1527V460H193ZM1141 0V494V539V2048H1263V0H1141Z" />
-<glyph unicode="5" horiz-adv-x="1643" d="M801 -28Q637 -28 509 41T306 233T229 512H352Q356 326 481 207T801 88Q951 88 1065 160T1245 358T1309 640Q1309 806 1239 930T1052 1122T793 1191Q681 1191 583 1151T416 1047L275 1067L396 2048H1356V1932H503L409
-1165H419Q491 1229 590 1268T807 1308Q979 1308 1121 1224T1348 990T1432 641Q1431 449 1350 298T1127 60T801 -28Z" />
-<glyph unicode="6" horiz-adv-x="1679" d="M855 -28Q726 -27 610 27T405 197T264 495T212 933Q212 1162 248 1368T364 1734T570 1985T879 2077Q1037 2077 1157 2009T1350 1818T1437 1536H1313Q1292 1724 1180 1842T879 1961Q613 1961 473 1701T332 956H343Q383
-1052 461 1125T643 1240T860 1281Q1026 1281 1163 1196T1384 963T1467 628Q1467 451 1390 302T1176 62T855 -28ZM855 88Q995 88 1105 161T1280 356T1344 628Q1344 778 1278 900T1100 1093T847 1165Q737 1165 643 1121T482 998T383 820T365 608Q388 447 455 331T625
-151T855 88Z" />
-<glyph unicode="7" horiz-adv-x="1563" d="M297 0L1248 1921V1932H155V2048H1384V1928L432 0H297Z" />
-<glyph unicode="8" horiz-adv-x="1709" d="M855 -28Q669 -28 520 46T285 248T199 533Q198 658 252 768T402 955T619 1059V1068Q464 1117 367 1249T269 1548Q269 1697 346 1816T557 2006T855 2076Q1017 2076 1150 2007T1361 1818T1440 1548Q1440 1382 1344 1250T1091
-1068V1059Q1212 1034 1306 957T1455 769T1511 533Q1510 375 1424 248T1189 47T855 -28ZM855 88Q1008 88 1128 148T1317 310T1387 540Q1386 669 1316 774T1125 941T855 1003Q704 1003 584 941T393 774T323 540Q322 413 391 311T581 148T855 88ZM855 1111Q984 1111
-1088 1169T1255 1325T1317 1543Q1317 1663 1256 1757T1090 1906T855 1961Q724 1961 619 1907T453 1758T392 1543Q392 1423 453 1325T620 1169T855 1111Z" />
-<glyph unicode="9" horiz-adv-x="1679" d="M824 2080Q953 2080 1069 2025T1274 1855T1415 1557T1467 1119Q1467 890 1431 684T1315 317T1109 64T800 -28Q641 -28 521 41T329 232T241 516H365Q386 329 499 209T800 88Q1065 88 1206 349T1347 1096H1336Q1296 1000
-1218 927T1036 812T819 771Q652 771 514 856T294 1089T212 1424Q212 1601 288 1750T502 1990T824 2080ZM824 1964Q683 1964 573 1891T399 1696T335 1424Q335 1273 400 1152T578 959T832 887Q942 887 1036 931T1197 1054T1297 1233T1313 1444Q1291 1605 1224 1721T1054
-1901T824 1964Z" />
-<glyph unicode=":" horiz-adv-x="733" d="M367 94Q322 94 291 125T259 202Q259 246 290 278T367 310Q411 310 443 278T475 202Q475 172 461 148T422 109T367 94ZM367 1170Q322 1170 291 1201T259 1278Q259 1322 290 1354T367 1386Q411 1386 443 1354T475 1278Q475
-1248 461 1224T422 1185T367 1170Z" />
-<glyph unicode=";" horiz-adv-x="748" d="M493 280L475 175Q457 72 430 -57T373 -307T320 -500H225Q239 -428 259 -324T302 -93T345 168L363 280H493ZM403 1170Q358 1170 327 1201T295 1278Q295 1322 326 1354T403 1386Q447 1386 479 1354T511 1278Q511 1248 497
-1224T458 1185T403 1170Z" />
-<glyph unicode="&lt;" horiz-adv-x="1813" d="M327 752V848L1487 1541V1405L467 799L477 817V783L467 801L1487 195V59L327 752Z" />
-<glyph unicode="=" horiz-adv-x="1813" d="M384 1020V1135H1429V1020H384ZM384 465V580H1429V465H384Z" />
-<glyph unicode="&gt;" horiz-adv-x="1813" d="M1487 752L327 59V195L1347 801L1336 783V817L1347 799L327 1405V1541L1487 848V752Z" />
-<glyph unicode="?" horiz-adv-x="1375" d="M564 597V695Q565 820 594 905T686 1056T851 1191Q938 1251 993 1308T1073 1429T1099 1567Q1099 1684 1044 1773T894 1913T681 1964Q563 1964 467 1911T313 1762T252 1536H125Q128 1695 201 1817T400 2007T681 2076Q838
-2076 960 2010T1151 1827T1221 1563Q1221 1470 1191 1393T1097 1246T931 1106Q838 1042 785 984T709 858T687 695V597H564ZM628 -13Q583 -13 552 18T520 95Q520 139 551 171T628 203Q673 203 704 171T736 95Q736 65 722 41T683 2T628 -13Z" />
-<glyph unicode="@" horiz-adv-x="2636" d="M1345 -543Q1081 -543 860 -450T477 -187T227 212T139 716Q139 988 227 1217T477 1614T858 1876T1336 1969Q1592 1969 1804 1874T2172 1612T2412 1229T2497 769Q2497 630 2469 503T2382 277T2233 121T2017 64Q1941 64
-1881 91T1786 180T1747 347H1737Q1697 261 1631 193T1475 87T1277 49Q1155 51 1053 103T878 246T764 459T723 720Q723 857 765 974T883 1181T1060 1321T1277 1376Q1381 1379 1471 1345T1629 1247T1733 1101H1743V1352H1856V403Q1856 277 1892 223T2019 169Q2134
-169 2216 242T2341 450T2385 771Q2385 947 2336 1110T2194 1410T1971 1649T1680 1807T1332 1864Q1100 1864 904 1778T562 1537T336 1172T255 713Q255 464 335 254T562 -111T909 -351T1348 -436Q1458 -436 1564 -414T1763 -355T1928 -271L1973 -367Q1889 -421 1786
--460T1570 -521T1345 -543ZM1288 160Q1425 160 1528 231T1689 430T1747 724Q1747 891 1688 1013T1526 1201T1291 1268Q1161 1268 1058 1194T895 997T835 724Q835 571 892 443T1051 237T1288 160Z" />
-<glyph unicode="A" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771Z" />
-<glyph unicode="B" horiz-adv-x="1813" d="M296 0V2048H961Q1147 2048 1275 1982T1469 1797T1536 1520Q1536 1396 1496 1300T1385 1142T1217 1055V1043Q1323 1035 1414 970T1560 792T1616 531Q1616 374 1549 255T1347 68T1015 0H296ZM421 116H1015Q1246 116 1370
-232T1495 531Q1495 660 1435 762T1267 923T1019 983H421V116ZM421 1097H1004Q1135 1097 1226 1154T1366 1307T1415 1520Q1415 1710 1297 1821T961 1932H421V1097Z" />
-<glyph unicode="C" horiz-adv-x="2040" d="M1856 1408H1728Q1708 1512 1653 1609T1512 1785T1311 1910T1059 1956Q857 1956 688 1849T417 1533T315 1024Q315 721 416 513T687 199T1059 92Q1197 92 1311 137T1511 261T1653 438T1728 640H1856Q1834 516 1772 397T1608
-182T1369 29T1059 -28Q803 -28 608 102T303 470T192 1024Q192 1341 302 1578T608 1945T1059 2076Q1231 2076 1368 2020T1607 1868T1771 1653T1856 1408Z" />
-<glyph unicode="D" horiz-adv-x="2016" d="M864 0H296V2048H908Q1196 2048 1401 1926T1715 1577T1824 1031Q1823 706 1710 476T1383 123T864 0ZM421 116H859Q1133 116 1321 225T1605 540T1701 1031Q1701 1313 1608 1514T1338 1824T903 1932H421V116Z" />
-<glyph unicode="E" horiz-adv-x="1657" d="M296 0V2048H1436V1932H421V1083H1375V967H421V116H1460V0H296Z" />
-<glyph unicode="F" horiz-adv-x="1649" d="M296 0V2048H1439V1932H421V1083H1344V967H421V0H296Z" />
-<glyph unicode="G" horiz-adv-x="2084" d="M1731 1408Q1708 1512 1653 1609T1513 1785T1313 1910T1056 1956Q839 1956 672 1843T410 1520T315 1024Q315 741 409 531T672 207T1067 92Q1268 92 1423 185T1666 451T1751 865L1795 844H1099V960H1873V844Q1873 573
-1770 378T1484 77T1067 -28Q803 -28 606 103T301 470T192 1024Q192 1262 254 1456T431 1788T705 2001T1056 2076Q1230 2076 1369 2019T1609 1865T1774 1650T1859 1408H1731Z" />
-<glyph unicode="H" horiz-adv-x="2076" d="M296 0V2048H421V1083H1655V2048H1780V0H1655V967H421V0H296Z" />
-<glyph unicode="I" horiz-adv-x="717" d="M421 2048V0H296V2048H421Z" />
-<glyph unicode="J" horiz-adv-x="1485" d="M1064 2048H1189V557Q1189 353 1119 224T930 34T664 -28Q514 -28 396 28T208 185T140 416H264Q263 320 315 247T459 131T664 88Q774 88 865 135T1009 285T1064 557V2048Z" />
-<glyph unicode="K" horiz-adv-x="1769" d="M296 0V2048H421V859H435L1515 2048H1681L792 1079L1673 0H1517L712 989L421 667V0H296Z" />
-<glyph unicode="L" horiz-adv-x="1584" d="M296 0V2048H421V116H1421V0H296Z" />
-<glyph unicode="M" horiz-adv-x="2456" d="M296 2048H432L1221 180H1235L2024 2048H2160V0H2040V1751H2028L1287 0H1169L428 1751H416V0H296V2048Z" />
-<glyph unicode="N" horiz-adv-x="2157" d="M1861 2048V0H1739L433 1821H421V0H296V2048H419L1725 225H1737V2048H1861Z" />
-<glyph unicode="O" horiz-adv-x="2117" d="M1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059
-1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024Z" />
-<glyph unicode="P" horiz-adv-x="1735" d="M296 0V2048H927Q1122 2048 1261 1968T1476 1749T1551 1432Q1551 1256 1476 1117T1262 898T928 817H384V933H925Q1083 933 1195 997T1367 1174T1427 1432Q1427 1578 1367 1691T1195 1868T924 1932H421V0H296Z" />
-<glyph unicode="Q" horiz-adv-x="2117" d="M1085 640H1229L1527 247L1555 209L1857 -192H1713L1465 137L1439 173L1085 640ZM1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076
-1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059 1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024Z" />
-<glyph unicode="R" horiz-adv-x="1755" d="M296 0V2048H927Q1121 2048 1261 1972T1476 1762T1551 1449Q1551 1273 1476 1140T1262 931T928 856H363V973H925Q1083 973 1195 1032T1367 1197T1427 1449Q1427 1595 1367 1703T1195 1872T924 1932H421V0H296ZM1137 928L1641
-0H1497L996 928H1137Z" />
-<glyph unicode="S" horiz-adv-x="1748" d="M1400 1536Q1384 1727 1245 1845T884 1963Q735 1963 619 1908T437 1758T371 1540Q371 1461 401 1398T488 1285T621 1198T791 1133L1015 1067Q1130 1032 1227 986T1397 877T1510 728T1551 532Q1551 370 1468 242T1231
-41T869 -33Q671 -33 525 34T293 220T197 496H325Q335 369 406 277T597 134T869 84Q1034 84 1160 140T1357 298T1428 533Q1428 641 1377 718T1227 851T980 951L741 1023Q501 1096 375 1222T248 1536Q248 1694 332 1816T560 2009T887 2079Q1070 2079 1210 2009T1432
-1816T1523 1536H1400Z" />
-<glyph unicode="T" horiz-adv-x="1755" d="M160 1932V2048H1595V1932H940V0H815V1932H160Z" />
-<glyph unicode="U" horiz-adv-x="2104" d="M1683 2048H1808V692Q1808 483 1711 320T1444 62T1052 -33Q832 -33 661 61T393 320T296 692V2048H421V699Q421 522 501 384T724 167T1052 87Q1238 87 1380 166T1602 384T1683 699V2048Z" />
-<glyph unicode="V" horiz-adv-x="1792" d="M217 2048L889 161H903L1575 2048H1707L968 0H824L85 2048H217Z" />
-<glyph unicode="W" horiz-adv-x="2565" d="M636 0L73 2048H203L703 193H715L1207 2048H1359L1851 193H1863L2363 2048H2492L1929 0H1785L1288 1843H1277L780 0H636Z" />
-<glyph unicode="X" horiz-adv-x="1701" d="M240 2048L845 1121H856L1461 2048H1612L939 1024L1612 0H1461L856 921H845L240 0H89L768 1024L89 2048H240Z" />
-<glyph unicode="Y" horiz-adv-x="1776" d="M88 2048H236L881 1060H895L1540 2048H1688L951 929V0H825V929L88 2048Z" />
-<glyph unicode="Z" horiz-adv-x="1723" d="M201 0V103L1372 1932H203V2048H1521V1945L351 116H1531V0H201Z" />
-<glyph unicode="[" horiz-adv-x="932" d="M345 -416V2144H777V2036H463V-308H777V-416H345Z" />
-<glyph unicode="\" horiz-adv-x="941" d="M81 2144H199L859 -308H741L81 2144Z" />
-<glyph unicode="]" horiz-adv-x="932" d="M587 2144V-416H155V-308H469V2036H155V2144H587Z" />
-<glyph unicode="^" horiz-adv-x="1192" d="M571 1920H621V1844H571V1920ZM125 1120L535 1984H657L1067 1120H940L581 1884H611L253 1120H125Z" />
-<glyph unicode="_" horiz-adv-x="1225" d="M1231 0V-116H-7V0H1231Z" />
-<glyph unicode="`" horiz-adv-x="1400" d="M785 1755L484 2197H629L897 1755H785Z" />
-<glyph unicode="a" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344 1414T536
-1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295 262 403
-171T672 80Z" />
-<glyph unicode="b" horiz-adv-x="1705" d="M296 0V2048H417V1193H429Q465 1292 531 1375T695 1508T924 1559Q1109 1559 1247 1457T1461 1175T1537 764Q1537 531 1461 352T1248 70T925 -32Q794 -32 696 18T532 152T429 335H415V0H296ZM416 765Q416 569 475 415T647
-171T915 81Q1073 81 1185 171T1356 417T1416 765Q1416 958 1357 1112T1185 1355T915 1445Q759 1445 647 1357T476 1115T416 765Z" />
-<glyph unicode="c" horiz-adv-x="1561" d="M829 -32Q633 -32 484 71T252 355T168 763Q168 991 252 1171T485 1455T828 1559Q974 1559 1094 1499T1294 1332T1396 1084H1272Q1242 1240 1123 1342T829 1445Q673 1445 551 1358T359 1117T289 765Q289 570 357 416T547
-171T829 81Q942 81 1035 126T1192 254T1275 448H1399Q1377 306 1299 198T1100 29T829 -32Z" />
-<glyph unicode="d" horiz-adv-x="1705" d="M780 -32Q595 -32 457 70T244 351T168 764Q168 995 244 1174T459 1456T781 1559Q913 1559 1010 1509T1174 1375T1276 1193H1288V2048H1409V0H1291V335H1276Q1239 236 1173 153T1009 19T780 -32ZM791 81Q947 81 1058 170T1229
-414T1289 765Q1289 961 1230 1114T1059 1356T791 1445Q633 1445 521 1356T349 1112T289 765Q289 572 348 417T520 172T791 81Z" />
-<glyph unicode="e" horiz-adv-x="1629" d="M852 -32Q644 -32 490 71T252 353T168 761Q168 991 252 1171T485 1455T823 1559Q952 1559 1067 1508T1271 1359T1410 1125T1461 817V741H245V852H1339Q1339 1019 1271 1153T1086 1366T823 1445Q670 1445 551 1360T362
-1129T289 805V755Q289 563 356 411T550 170T852 81Q978 81 1067 123T1214 227T1300 345L1411 300Q1376 223 1303 147T1117 19T852 -32Z" />
-<glyph unicode="f" horiz-adv-x="960" d="M833 1536V1427H152V1536H833ZM376 0V1756Q376 1872 429 1954T568 2079T755 2123Q808 2123 845 2114T911 2095L875 1987Q852 1994 826 2000T763 2007Q647 2007 572 1936T497 1737L496 12L376 0Z" />
-<glyph unicode="g" horiz-adv-x="1668" d="M807 -608Q664 -608 550 -571T357 -464T235 -300L337 -240Q371 -314 434 -372T590 -463T807 -496Q1029 -496 1158 -382T1288 -31V351H1276Q1241 251 1173 174T1005 52T780 8Q598 8 460 102T245 366T168 768Q168 999 243
-1177T457 1457T781 1559Q914 1559 1011 1509T1175 1375T1277 1193H1291V1536H1409V-36Q1409 -228 1334 -355T1123 -545T807 -608ZM791 121Q945 121 1056 199T1228 423T1289 769Q1289 963 1230 1116T1059 1357T791 1445Q632 1445 520 1356T349 1113T289 769Q289
-577 349 432T521 204T791 121Z" />
-<glyph unicode="h" horiz-adv-x="1621" d="M375 996V0H253V2048H375V1293H387Q441 1413 559 1484T844 1556Q999 1556 1117 1491T1301 1304T1368 1013V0H1247V1008Q1247 1205 1130 1324T821 1443Q692 1443 591 1387T433 1231T375 996Z" />
-<glyph unicode="i" horiz-adv-x="639" d="M259 0V1536H380V0H259ZM320 1808Q279 1808 250 1836T221 1904Q221 1944 250 1972T320 2000Q360 2000 389 1972T418 1904Q418 1864 389 1836T320 1808Z" />
-<glyph unicode="j" horiz-adv-x="639" d="M259 1536H380V-187Q380 -306 331 -393T197 -528T-1 -576Q-9 -576 -18 -576T-39 -573L-33 -460Q-25 -460 -18 -460T-3 -460Q109 -460 184 -388T259 -187V1536ZM319 1808Q278 1808 249 1836T220 1904Q220 1944 249 1972T319
-2000Q359 2000 388 1972T417 1904Q417 1864 388 1836T319 1808Z" />
-<glyph unicode="k" horiz-adv-x="1425" d="M359 600L357 775H389L1157 1536H1316L611 836L599 831L359 600ZM253 0V2048H375V0H253ZM1192 0L544 819L632 900L1348 0H1192Z" />
-<glyph unicode="l" horiz-adv-x="628" d="M375 2048V0H253V2048H375Z" />
-<glyph unicode="m" horiz-adv-x="2384" d="M253 0V1536H372V1304H384Q428 1420 536 1488T796 1556Q963 1556 1065 1476T1213 1259H1224Q1269 1394 1385 1475T1673 1556Q1896 1556 2013 1416T2131 1031V0H2009V1031Q2009 1223 1917 1333T1647 1443Q1457 1443 1355
-1324T1253 1021V0H1131V1039Q1131 1218 1041 1330T771 1443Q649 1443 561 1387T424 1233T375 1008V0H253Z" />
-<glyph unicode="n" horiz-adv-x="1616" d="M375 996V0H253V1536H372V1293H384Q438 1412 557 1484T840 1556Q995 1556 1112 1490T1296 1303T1363 1013V0H1241V1008Q1241 1204 1125 1323T817 1443Q689 1443 589 1387T432 1231T375 996Z" />
-<glyph unicode="o" horiz-adv-x="1648" d="M824 -32Q634 -32 486 70T253 353T168 763Q168 994 253 1173T486 1456T824 1559Q1014 1559 1162 1456T1395 1173T1480 763Q1480 533 1395 353T1162 71T824 -32ZM824 81Q985 81 1105 172T1292 418T1359 763Q1359 953 1292
-1107T1105 1353T824 1445Q663 1445 543 1354T356 1108T289 763Q289 573 355 418T542 172T824 81Z" />
-<glyph unicode="p" horiz-adv-x="1657" d="M248 -576V1536H367V1193H381Q418 1292 483 1375T646 1508T876 1559Q1062 1559 1199 1457T1413 1176T1489 764Q1489 532 1413 353T1199 71T877 -32Q747 -32 649 18T484 151T381 335H369V-576H248ZM368 765Q368 569 428
-415T599 171T867 81Q1024 81 1136 171T1308 417T1368 765Q1368 958 1309 1112T1138 1355T867 1445Q710 1445 599 1357T428 1115T368 765Z" />
-<glyph unicode="q" horiz-adv-x="1657" d="M1409 -576H1288V335H1276Q1241 235 1174 152T1009 18T780 -32Q596 -32 459 70T245 352T168 764Q168 996 244 1175T458 1457T781 1559Q914 1559 1011 1509T1174 1375T1276 1193H1291V1536H1409V-576ZM1289 765Q1289 961
-1230 1114T1059 1356T791 1445Q632 1445 520 1356T349 1112T289 765Q289 572 349 417T521 172T791 81Q946 81 1057 170T1229 414T1289 765Z" />
-<glyph unicode="r" horiz-adv-x="960" d="M253 0V1536H372V1296H383Q433 1414 552 1485T823 1557Q839 1557 855 1557T885 1556V1433Q873 1434 857 1436T816 1439Q688 1439 589 1384T432 1233T375 1012V0H253Z" />
-<glyph unicode="s" horiz-adv-x="1416" d="M1229 1205L1116 1175Q1089 1259 1039 1320T914 1414T733 1448Q564 1448 457 1364T349 1145Q349 1035 422 964T645 855L881 796Q1063 751 1155 651T1247 401Q1247 274 1178 176T988 23T705 -32Q490 -32 352 68T175 357L292
-387Q325 237 429 159T704 80Q892 80 1007 169T1123 397Q1123 503 1055 576T849 683L604 743Q416 789 322 890T228 1141Q228 1265 293 1359T472 1506T733 1559Q928 1559 1051 1467T1229 1205Z" />
-<glyph unicode="t" horiz-adv-x="995" d="M796 1536V1427H125V1536H796ZM352 1904H473V363Q473 269 509 210T605 122T733 93Q766 93 790 98T837 111L867 0Q839 -10 805 -17T725 -25Q629 -25 544 18T405 143T352 341V1904Z" />
-<glyph unicode="u" horiz-adv-x="1612" d="M1237 545V1536H1359V0H1237V249H1227Q1173 130 1053 55T768 -20Q617 -20 501 46T319 233T253 523V1536H375V528Q375 336 491 218T793 99Q913 99 1014 153T1176 307T1237 545Z" />
-<glyph unicode="v" horiz-adv-x="1504" d="M1376 1536L821 0H683L128 1536H259L747 157H757L1245 1536H1376Z" />
-<glyph unicode="w" horiz-adv-x="2245" d="M588 0L128 1536H256L652 179H663L1060 1536H1187L1583 180H1593L1989 1536H2117L1657 0H1521L1129 1337H1116L724 0H588Z" />
-<glyph unicode="x" horiz-adv-x="1445" d="M299 1536L723 853L1147 1536H1288L796 768L1288 0H1147L723 672L299 0H157L647 768L157 1536H299Z" />
-<glyph unicode="y" horiz-adv-x="1505" d="M351 -577Q305 -577 260 -567T179 -541L215 -435Q357 -488 457 -438T621 -213L691 -21L128 1536H259L747 157H757L1245 1536L1377 1535L728 -261Q690 -366 635 -436T508 -542T351 -577Z" />
-<glyph unicode="z" horiz-adv-x="1463" d="M201 0V103L1105 1409V1420H251V1536H1260V1431L364 127V116H1296V0H201Z" />
-<glyph unicode="{" horiz-adv-x="932" d="M157 841V925Q328 926 391 990T455 1224V1680Q455 1799 478 1886T552 2030T681 2116T871 2144V2036Q763 2037 697 2000T600 1883T569 1680V1197Q569 1105 550 1038T484 928T358 863T157 841ZM871 -416Q760 -416 681 -388T552
--303T479 -158T455 48V504Q455 673 392 737T157 803V887Q278 886 358 865T484 800T549 690T569 531V48Q569 -76 599 -155T696 -271T871 -308V-416ZM157 803V925H380V803H157Z" />
-<glyph unicode="|" horiz-adv-x="819" d="M464 2708V-660H355V2708H464Z" />
-<glyph unicode="}" horiz-adv-x="932" d="M775 887V803Q604 802 541 738T477 504V48Q477 -71 454 -158T380 -302T251 -388T61 -416V-308Q170 -308 236 -271T332 -155T363 48V531Q363 623 382 690T448 800T574 865T775 887ZM61 2144Q172 2144 251 2116T380 2031T453
-1886T477 1680V1224Q477 1055 540 991T775 925V841Q654 842 574 863T448 928T383 1038T363 1197V1680Q363 1804 333 1883T236 1999T61 2036V2144ZM775 925V803H552V925H775Z" />
-<glyph unicode="~" horiz-adv-x="1813" d="M253 651Q260 760 312 835T446 950T625 988Q728 987 805 947T960 847Q1024 799 1076 771T1195 741Q1293 741 1361 802T1445 967L1560 956Q1547 849 1495 776T1365 665T1196 627Q1103 627 1031 665T883 763Q818 811 761
-842T627 873Q520 874 449 814T368 645L253 651Z" />
-<glyph unicode="&#xa0;" horiz-adv-x="792" />
-<glyph unicode="&#xa1;" horiz-adv-x="752" d="M376 1541Q421 1541 452 1510T484 1433Q484 1389 452 1357T376 1325Q332 1325 300 1357T268 1433Q268 1463 282 1487T321 1526T376 1541ZM443 -520H309L316 931H436L443 -520Z" />
-<glyph unicode="&#xa2;" horiz-adv-x="1561" d="M763 -256V1780H860V-256H763ZM829 -32Q633 -32 484 71T252 355T168 763Q168 991 252 1171T485 1455T828 1559Q974 1559 1094 1499T1294 1332T1396 1084H1272Q1242 1240 1123 1342T829 1445Q673 1445 551 1358T359
-1117T289 765Q289 570 357 416T547 171T829 81Q942 81 1035 126T1192 254T1275 448H1399Q1377 306 1299 198T1100 29T829 -32Z" />
-<glyph unicode="&#xa3;" horiz-adv-x="1776" d="M1495 0H211V116H1495V0ZM1095 833H208V952H1095V833ZM577 1481L607 453Q612 301 565 194T405 57L307 116Q375 116 415 166T471 294T485 453L457 1481Q452 1665 525 1798T726 2004T1012 2076Q1137 2076 1240 2032T1416
-1912T1518 1740T1525 1537L1405 1569Q1425 1679 1376 1768T1232 1909T1016 1960Q890 1960 790 1902T632 1738T577 1481Z" />
-<glyph unicode="&#xa4;" horiz-adv-x="1957" d="M973 -28Q806 -28 660 37T405 221T234 496T172 836Q172 1019 233 1175T404 1449T660 1631T973 1696Q1143 1696 1289 1631T1545 1449T1717 1176T1779 836Q1779 653 1717 496T1546 221T1290 38T973 -28ZM973 88Q1118
-88 1242 145T1460 305T1606 543T1660 836Q1661 1047 1570 1215T1322 1482T973 1581Q783 1581 628 1483T380 1216T288 836Q288 627 379 458T626 188T973 88ZM1548 1344L1467 1431L1719 1691L1801 1604L1548 1344ZM1719 -7L1467 253L1548 339L1801 80L1719 -7ZM232
--7L151 80L403 339L485 253L232 -7ZM403 1344L151 1604L232 1691L485 1431L403 1344Z" />
-<glyph unicode="&#xa5;" horiz-adv-x="1496" d="M193 2048L792 892L680 865L53 2048H193ZM704 892L1303 2048H1443L816 865L704 892ZM811 993V0H685V993H811ZM1280 951V848H216V951H1280ZM1280 656V553H216V656H1280Z" />
-<glyph unicode="&#xa6;" horiz-adv-x="599" d="M241 539H357V-384H241V539ZM357 2048V1129H241V2048H357Z" />
-<glyph unicode="&#xa7;" horiz-adv-x="1512" d="M1219 1612H1100Q1094 1759 1007 1861T756 1963Q650 1963 574 1919T457 1803T416 1648Q416 1563 456 1502T560 1395T696 1305L937 1163Q1006 1123 1077 1077T1207 976T1304 851T1341 691Q1341 606 1297 535T1178
-419T1013 365V361Q1145 268 1193 186T1241 5Q1241 -129 1176 -223T999 -367T749 -416Q603 -416 489 -358T308 -194T235 55H355Q363 -108 470 -205T749 -303Q904 -303 1013 -223T1123 5Q1123 75 1093 128T998 233T827 348L583 489Q484 547 407 600T278 709T198 827T171
-965Q171 1051 211 1125T323 1246T493 1297V1305Q402 1374 350 1455T297 1648Q297 1768 356 1865T518 2019T756 2076Q894 2076 996 2017T1155 1852T1219 1612ZM1225 691Q1225 775 1174 841T1043 961T883 1063L613 1221Q515 1235 442 1205T328 1113T287 971Q287 857
-375 774T639 589L883 448Q963 435 1042 460T1173 544T1225 691Z" />
-<glyph unicode="&#xa8;" horiz-adv-x="1792" d="M672 1808Q634 1808 605 1837T576 1904Q576 1944 605 1972T672 2000Q712 2000 740 1972T768 1904Q768 1866 740 1837T672 1808ZM1120 1808Q1082 1808 1053 1837T1024 1904Q1024 1944 1053 1972T1120 2000Q1160 2000
-1188 1972T1216 1904Q1216 1866 1188 1837T1120 1808Z" />
-<glyph unicode="&#xa9;" horiz-adv-x="2576" d="M716 1024Q716 1188 796 1323T1011 1538T1310 1618Q1436 1618 1542 1569T1722 1434T1827 1237H1699Q1657 1354 1553 1426T1310 1498Q1179 1498 1072 1434T900 1263T836 1024Q836 893 900 786T1071 614T1310 550Q1449
-550 1549 622T1692 811H1821Q1789 700 1717 614T1540 479T1310 430Q1146 430 1011 510T796 725T716 1024ZM1288 -32Q1070 -32 879 50T542 277T314 614T232 1024Q233 1243 315 1434T543 1770T879 1998T1288 2080Q1507 2080 1698 1998T2034 1771T2261 1434T2344 1024Q2345
-805 2263 614T2036 278T1699 50T1288 -32ZM1288 88Q1482 88 1652 161T1951 362T2152 660T2224 1024Q2223 1218 2150 1388T1949 1686T1652 1887T1288 1960Q1095 1960 926 1888T628 1686T427 1387T352 1024Q351 831 423 662T625 363T924 161T1288 88Z" />
-<glyph unicode="&#xaa;" horiz-adv-x="1099" d="M783 1184V1652Q781 1791 728 1856T563 1921Q441 1921 374 1866T300 1704L179 1715Q183 1857 287 1944T563 2032Q730 2032 817 1939T905 1652V1220Q905 1149 912 1090T939 975H812Q796 1022 789 1076T783 1184ZM811
-1560V1467H587Q434 1467 359 1411T284 1253Q283 1165 337 1114T489 1061Q548 1061 606 1081T715 1137T800 1219L824 1137Q745 1051 662 1002T469 952Q326 952 243 1034T159 1251Q160 1401 268 1480T587 1560H811Z" />
-<glyph unicode="&#xab;" horiz-adv-x="1493" d="M741 220H621L217 820V840H336L741 220ZM741 1448L336 828H217V848L621 1448H741ZM1253 220H1133L729 820V840H848L1253 220ZM1253 1448L848 828H729V848L1133 1448H1253Z" />
-<glyph unicode="&#xac;" horiz-adv-x="1445" d="M1288 1108V987H161V1108H1288ZM1288 1053V516H1153V1053H1288Z" />
-<glyph unicode="&#xad;" horiz-adv-x="1256" d="M1032 936V820H224V936H1032Z" />
-<glyph unicode="&#xae;" horiz-adv-x="1888" d="M681 916V1654H1017Q1082 1654 1135 1627T1218 1549T1249 1429Q1249 1359 1218 1307T1134 1225T1013 1195H735V1278H995Q1064 1278 1105 1318T1147 1429Q1147 1501 1106 1536T996 1571H778V916H681ZM1097 1253L1279
-916H1171L992 1253H1097ZM944 476Q778 476 633 538T378 710T206 965T144 1276Q144 1442 206 1587T378 1842T633 2014T944 2076Q1110 2076 1255 2014T1510 1842T1682 1587T1744 1276Q1744 1110 1682 965T1510 710T1255 538T944 476ZM944 583Q1136 583 1293 676T1544
-926T1637 1276Q1637 1468 1544 1625T1293 1875T944 1969Q752 1969 595 1876T344 1626T251 1276Q251 1085 344 927T594 676T944 583Z" />
-<glyph unicode="&#xaf;" horiz-adv-x="971" d="M855 1957V1848H116V1957H855Z" />
-<glyph unicode="&#xb0;" horiz-adv-x="1280" d="M640 1211Q521 1211 423 1269T267 1424T209 1641Q209 1760 267 1858T423 2014T640 2072Q759 2072 857 2014T1013 1858T1071 1641Q1071 1522 1013 1425T857 1269T640 1211ZM640 1320Q729 1320 802 1363T918 1480T961
-1642Q961 1730 918 1803T802 1919T640 1963Q551 1963 478 1920T362 1803T319 1642Q319 1553 362 1480T478 1364T640 1320Z" />
-<glyph unicode="&#xb1;" horiz-adv-x="1813" d="M305 160V277H1508V160H305ZM305 941V1059H1508V941H305ZM848 464V1536H965V464H848Z" />
-<glyph unicode="&#xb2;" horiz-adv-x="1168" d="M159 1024V1108L608 1553Q694 1639 747 1701T824 1818T849 1932Q849 2038 769 2105T567 2173Q439 2173 360 2100T281 1920H169Q169 2071 281 2169T568 2268Q683 2268 771 2224T910 2104T961 1933Q961 1861 934 1796T842
-1654T669 1471L324 1128V1123H1019V1024H159Z" />
-<glyph unicode="&#xb3;" horiz-adv-x="1175" d="M591 1008Q466 1008 367 1051T211 1170T152 1344H268Q269 1239 362 1174T591 1108Q734 1108 824 1180T915 1359Q915 1469 816 1539T555 1609H497V1703H555Q690 1703 781 1766T873 1940Q873 2040 794 2106T589 2173Q465
-2173 380 2107T292 1933H183Q183 2033 237 2108T383 2225T589 2268Q706 2268 795 2226T936 2110T987 1944Q987 1836 917 1762T736 1664V1656Q873 1631 951 1549T1029 1353Q1029 1255 973 1177T818 1054T591 1008Z" />
-<glyph unicode="&#xb4;" horiz-adv-x="1400" d="M628 1755L896 2197H1041L740 1755H628Z" />
-<glyph unicode="&#xb5;" horiz-adv-x="1643" d="M248 -589V1536H369V545Q369 414 427 314T587 156T823 99Q956 99 1058 156T1218 314T1276 545V1536H1397V0H1276V239H1267Q1210 118 1091 49T823 -20Q674 -20 555 49T379 239H369V-589H248Z" />
-<glyph unicode="&#xb6;" horiz-adv-x="1603" d="M1307 1932H971V760H855Q637 760 486 843T257 1072T179 1404Q179 1588 257 1733T486 1963T855 2048H1307V1932ZM1181 0V2048H1307V0H1181Z" />
-<glyph unicode="&#xb7;" horiz-adv-x="733" d="M367 788Q322 788 291 819T259 896Q259 940 290 972T367 1004Q411 1004 443 972T475 896Q475 866 461 842T422 803T367 788Z" />
-<glyph unicode="&#xb8;" horiz-adv-x="635" d="M268 8H376L361 -72Q455 -87 512 -145T569 -305Q569 -419 488 -480T249 -544L245 -449Q351 -447 403 -414T456 -308Q455 -228 401 -190T236 -151L268 8Z" />
-<glyph unicode="&#xb9;" horiz-adv-x="806" d="M429 1024V2134H417L121 1937V2059L413 2252H541V1024H429Z" />
-<glyph unicode="&#xba;" horiz-adv-x="1155" d="M132 1437V1551Q132 1699 185 1807T339 1973T579 2032Q788 2032 905 1902T1023 1551V1437Q1023 1288 970 1180T818 1014T577 955Q369 954 251 1084T132 1437ZM252 1551V1437Q252 1265 337 1164T577 1061Q734 1061
-818 1162T903 1437V1551Q903 1721 818 1822T579 1924Q423 1924 338 1823T252 1551Z" />
-<glyph unicode="&#xbb;" horiz-adv-x="1507" d="M233 220L639 840H757V820L353 220H233ZM233 1448H353L757 848V828H639L233 1448ZM758 220L1164 840H1282V820L878 220H758ZM758 1448H878L1282 848V828H1164L758 1448Z" />
-<glyph unicode="&#xbc;" horiz-adv-x="2303" d="M339 0L1744 2048H1871L465 0H339ZM378 1216V2186H367L87 2000V2121L362 2304H490V1216H378ZM1360 228V317L1908 1088H1980V945H1926L1485 328V324H2194V228H1360ZM1933 0V259L1934 296V1088H2040V0H1933Z" />
-<glyph unicode="&#xbd;" horiz-adv-x="2483" d="M339 0L1744 2048H1871L465 0H339ZM378 1216V2186H367L87 2000V2121L362 2304H490V1216H378ZM1569 0V83L1976 475Q2096 592 2144 659T2192 800Q2192 892 2120 950T1940 1009Q1828 1009 1757 947T1685 792H1576Q1576
-927 1678 1015T1941 1104Q2097 1104 2198 1017T2299 801Q2299 737 2274 680T2191 555T2035 393L1732 101V96H2352V0H1569Z" />
-<glyph unicode="&#xbe;" horiz-adv-x="2580" d="M616 0L2021 2048H2148L742 0H616ZM1638 228V317L2186 1088H2258V945H2204L1763 328V324H2472V228H1638ZM2211 0V259L2212 296V1088H2318V0H2211ZM535 1200Q421 1200 331 1239T189 1346T136 1503H249Q249 1412 331
-1355T535 1297Q662 1297 741 1360T821 1515Q821 1611 733 1671T504 1731H451V1817H504Q625 1817 706 1874T787 2024Q787 2110 717 2167T535 2225Q423 2225 348 2168T273 2020H163Q163 2107 212 2175T345 2281T535 2320Q640 2320 722 2282T850 2178T897 2029Q897
-1936 835 1870T668 1781V1776Q793 1754 864 1682T935 1509Q935 1421 884 1351T743 1241T535 1200Z" />
-<glyph unicode="&#xbf;" horiz-adv-x="1375" d="M747 1556Q791 1556 823 1525T855 1448Q855 1403 823 1372T747 1340Q702 1340 671 1371T639 1448Q639 1478 653 1502T692 1541T747 1556ZM811 945V848Q810 723 781 638T688 487T524 352Q437 293 382 235T302 114T276
--24Q276 -141 331 -231T481 -371T693 -421Q811 -421 907 -368T1062 -219T1123 7H1249Q1247 -153 1174 -274T975 -464T693 -533Q537 -533 415 -467T223 -284T153 -20Q153 74 183 151T278 297T444 437Q537 501 590 559T665 685T688 848V945H811Z" />
-<glyph unicode="&#xc0;" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771ZM842 2267L541 2709H686L954 2267H842Z" />
-<glyph unicode="&#xc1;" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771ZM837 2267L1105 2709H1250L949 2267H837Z" />
-<glyph unicode="&#xc2;" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771ZM627 2216H501V2227L855 2616H937L1291 2227V2216H1165L896 2515L627 2216Z" />
-<glyph unicode="&#xc3;" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771ZM531 2313L427 2321Q436 2435 514 2506T704 2578Q769 2578 817 2555T906 2503T989 2450T1087 2426Q1151 2426 1203 2469T1265
-2581L1368 2565Q1353 2452 1274 2384T1091 2316Q1025 2316 980 2339T895 2391T813 2444T708 2468Q637 2468 587 2424T531 2313Z" />
-<glyph unicode="&#xc4;" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771ZM672 2320Q634 2320 605 2349T576 2416Q576 2456 605 2484T672 2512Q712 2512 740 2484T768 2416Q768 2378 740 2349T672 2320ZM1120
-2320Q1082 2320 1053 2349T1024 2416Q1024 2456 1053 2484T1120 2512Q1160 2512 1188 2484T1216 2416Q1216 2378 1188 2349T1120 2320Z" />
-<glyph unicode="&#xc5;" horiz-adv-x="1792" d="M217 0H85L824 2048H968L1707 0H1575L901 1887H891L217 0ZM404 771H1388V655H404V771ZM896 2160Q824 2160 764 2194T669 2287T633 2416Q633 2487 668 2545T764 2637T896 2672Q969 2672 1028 2638T1123 2545T1158
-2416Q1158 2345 1123 2287T1029 2195T896 2160ZM896 2253Q964 2253 1012 2300T1060 2416Q1060 2484 1012 2531T896 2579Q827 2579 780 2532T732 2416Q732 2348 780 2301T896 2253Z" />
-<glyph unicode="&#xc6;" horiz-adv-x="2729" d="M85 0L1112 2048H2508V1932H1493V1083H2447V967H1493V116H2532V0H1368V1932H1185L224 0H85ZM497 655V771H1433V655H497Z" />
-<glyph unicode="&#xc7;" horiz-adv-x="2040" d="M1856 1408H1728Q1708 1512 1653 1609T1512 1785T1311 1910T1059 1956Q857 1956 688 1849T417 1533T315 1024Q315 721 416 513T687 199T1059 92Q1197 92 1311 137T1511 261T1653 438T1728 640H1856Q1834 516 1772
-397T1608 182T1369 29T1059 -28Q803 -28 608 102T303 470T192 1024Q192 1341 302 1578T608 1945T1059 2076Q1231 2076 1368 2020T1607 1868T1771 1653T1856 1408ZM1003 3H1111L1096 -77Q1190 -92 1247 -150T1304 -310Q1304 -424 1223 -485T984 -549L980 -454Q1086
--452 1138 -419T1191 -313Q1190 -233 1136 -195T971 -156L1003 3Z" />
-<glyph unicode="&#xc8;" horiz-adv-x="1657" d="M296 0V2048H1436V1932H421V1083H1375V967H421V116H1460V0H296ZM816 2267L515 2709H660L928 2267H816Z" />
-<glyph unicode="&#xc9;" horiz-adv-x="1657" d="M296 0V2048H1436V1932H421V1083H1375V967H421V116H1460V0H296ZM811 2267L1079 2709H1224L923 2267H811Z" />
-<glyph unicode="&#xca;" horiz-adv-x="1657" d="M296 0V2048H1436V1932H421V1083H1375V967H421V116H1460V0H296ZM600 2216H474V2227L828 2616H910L1264 2227V2216H1138L869 2515L600 2216Z" />
-<glyph unicode="&#xcb;" horiz-adv-x="1657" d="M296 0V2048H1436V1932H421V1083H1375V967H421V116H1460V0H296ZM901 2320Q863 2320 834 2349T805 2416Q805 2456 834 2484T901 2512Q941 2512 969 2484T997 2416Q997 2378 969 2349T901 2320ZM1349 2320Q1311 2320
-1282 2349T1253 2416Q1253 2456 1282 2484T1349 2512Q1389 2512 1417 2484T1445 2416Q1445 2378 1417 2349T1349 2320Z" />
-<glyph unicode="&#xcc;" horiz-adv-x="717" d="M421 2048V0H296V2048H421ZM305 2267L4 2709H149L417 2267H305Z" />
-<glyph unicode="&#xcd;" horiz-adv-x="717" d="M421 2048V0H296V2048H421ZM300 2267L568 2709H713L412 2267H300Z" />
-<glyph unicode="&#xce;" horiz-adv-x="717" d="M421 2048V0H296V2048H421ZM90 2216H-36V2227L318 2616H400L754 2227V2216H628L359 2515L90 2216Z" />
-<glyph unicode="&#xcf;" horiz-adv-x="717" d="M421 2048V0H296V2048H421ZM135 2320Q97 2320 68 2349T39 2416Q39 2456 68 2484T135 2512Q175 2512 203 2484T231 2416Q231 2378 203 2349T135 2320ZM583 2320Q545 2320 516 2349T487 2416Q487 2456 516 2484T583
-2512Q623 2512 651 2484T679 2416Q679 2378 651 2349T583 2320Z" />
-<glyph unicode="&#xd0;" horiz-adv-x="2016" d="M864 0H296V2048H908Q1196 2048 1401 1926T1715 1577T1824 1031Q1823 706 1710 476T1383 123T864 0ZM421 116H859Q1133 116 1321 225T1605 540T1701 1031Q1701 1313 1608 1514T1338 1824T903 1932H421V116ZM-41
-993V1097H759V993H-41Z" />
-<glyph unicode="&#xd1;" horiz-adv-x="2157" d="M1861 2048V0H1739L433 1821H421V0H296V2048H419L1725 225H1737V2048H1861ZM712 2288L608 2296Q617 2410 695 2481T885 2553Q950 2553 998 2530T1087 2478T1170 2425T1268 2401Q1332 2401 1384 2444T1446 2556L1549
-2540Q1534 2427 1455 2359T1272 2291Q1206 2291 1161 2314T1076 2366T994 2419T889 2443Q818 2443 768 2399T712 2288Z" />
-<glyph unicode="&#xd2;" horiz-adv-x="2117" d="M1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059
-1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024ZM1005 2267L704 2709H849L1117 2267H1005Z" />
-<glyph unicode="&#xd3;" horiz-adv-x="2117" d="M1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059
-1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024ZM1000 2267L1268 2709H1413L1112 2267H1000Z" />
-<glyph unicode="&#xd4;" horiz-adv-x="2117" d="M1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059
-1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024ZM790 2216H664V2227L1018 2616H1100L1454 2227V2216H1328L1059 2515L790 2216Z" />
-<glyph unicode="&#xd5;" horiz-adv-x="2117" d="M1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059
-1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024ZM694 2313L590 2321Q599 2435 677 2506T867 2578Q932 2578 980 2555T1069 2503T1152 2450T1250 2426Q1314 2426 1366 2469T1428 2581L1531 2565Q1516
-2452 1437 2384T1254 2316Q1188 2316 1143 2339T1058 2391T976 2444T871 2468Q800 2468 750 2424T694 2313Z" />
-<glyph unicode="&#xd6;" horiz-adv-x="2117" d="M1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925 1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059
-1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024ZM835 2320Q797 2320 768 2349T739 2416Q739 2456 768 2484T835 2512Q875 2512 903 2484T931 2416Q931 2378 903 2349T835 2320ZM1283 2320Q1245
-2320 1216 2349T1187 2416Q1187 2456 1216 2484T1283 2512Q1323 2512 1351 2484T1379 2416Q1379 2378 1351 2349T1283 2320Z" />
-<glyph unicode="&#xd7;" horiz-adv-x="1813" d="M1430 195L302 1323L383 1405L1511 277L1430 195ZM383 195L302 277L1430 1405L1511 1323L383 195Z" />
-<glyph unicode="&#xd8;" horiz-adv-x="2117" d="M403 -60L317 -1L1712 2107L1797 2048L403 -60ZM1925 1024Q1925 707 1815 471T1510 103T1059 -28Q803 -28 608 103T302 471T192 1024Q192 1341 302 1577T608 1944T1059 2076Q1314 2076 1509 1945T1815 1578T1925
-1024ZM1803 1024Q1803 1309 1708 1518T1444 1841T1059 1956Q842 1956 674 1843T411 1520T315 1024Q315 741 409 532T672 207T1059 92Q1276 92 1444 206T1707 530T1803 1024Z" />
-<glyph unicode="&#xd9;" horiz-adv-x="2104" d="M1683 2048H1808V692Q1808 483 1711 320T1444 62T1052 -33Q832 -33 661 61T393 320T296 692V2048H421V699Q421 522 501 384T724 167T1052 87Q1238 87 1380 166T1602 384T1683 699V2048ZM998 2267L697 2709H842L1110
-2267H998Z" />
-<glyph unicode="&#xda;" horiz-adv-x="2104" d="M1683 2048H1808V692Q1808 483 1711 320T1444 62T1052 -33Q832 -33 661 61T393 320T296 692V2048H421V699Q421 522 501 384T724 167T1052 87Q1238 87 1380 166T1602 384T1683 699V2048ZM993 2267L1261 2709H1406L1105
-2267H993Z" />
-<glyph unicode="&#xdb;" horiz-adv-x="2104" d="M1683 2048H1808V692Q1808 483 1711 320T1444 62T1052 -33Q832 -33 661 61T393 320T296 692V2048H421V699Q421 522 501 384T724 167T1052 87Q1238 87 1380 166T1602 384T1683 699V2048ZM783 2216H657V2227L1011
-2616H1093L1447 2227V2216H1321L1052 2515L783 2216Z" />
-<glyph unicode="&#xdc;" horiz-adv-x="2104" d="M1683 2048H1808V692Q1808 483 1711 320T1444 62T1052 -33Q832 -33 661 61T393 320T296 692V2048H421V699Q421 522 501 384T724 167T1052 87Q1238 87 1380 166T1602 384T1683 699V2048ZM828 2320Q790 2320 761 2349T732
-2416Q732 2456 761 2484T828 2512Q868 2512 896 2484T924 2416Q924 2378 896 2349T828 2320ZM1276 2320Q1238 2320 1209 2349T1180 2416Q1180 2456 1209 2484T1276 2512Q1316 2512 1344 2484T1372 2416Q1372 2378 1344 2349T1276 2320Z" />
-<glyph unicode="&#xdd;" horiz-adv-x="1776" d="M88 2048H236L881 1060H895L1540 2048H1688L951 929V0H825V929L88 2048ZM829 2267L1097 2709H1242L941 2267H829Z" />
-<glyph unicode="&#xde;" horiz-adv-x="1701" d="M272 2048H395V0H272V2048ZM332 1579H855Q1049 1579 1189 1510T1403 1317T1477 1031Q1477 866 1404 742T1192 550T855 481H332V597H855Q1015 597 1126 647T1296 794T1356 1027Q1355 1163 1296 1261T1125 1411T855
-1463H332V1579Z" />
-<glyph unicode="&#xdf;" horiz-adv-x="1651" d="M296 0V1535Q296 1700 363 1822T550 2010T831 2076Q984 2076 1101 2014T1285 1838T1351 1572Q1350 1388 1253 1257T987 1072V1060Q1209 1019 1331 887T1453 545Q1453 383 1380 261T1173 69T859 0H691V116H871Q1010
-116 1113 169T1275 318T1333 537Q1333 743 1183 867T779 992H699V1109H763Q904 1109 1009 1168T1171 1330T1229 1560Q1229 1740 1122 1850T833 1960Q644 1961 531 1844T417 1524V0H296Z" />
-<glyph unicode="&#xe0;" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295
-262 403 171T672 80ZM982 1755L681 2197H826L1094 1755H982Z" />
-<glyph unicode="&#xe1;" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295
-262 403 171T672 80ZM721 1755L989 2197H1134L833 1755H721Z" />
-<glyph unicode="&#xe2;" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295
-262 403 171T672 80ZM511 1704H385V1715L739 2104H821L1175 1715V1704H1049L780 2003L511 1704Z" />
-<glyph unicode="&#xe3;" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295
-262 403 171T672 80ZM414 1776L310 1784Q319 1898 397 1969T587 2041Q652 2041 700 2018T789 1966T872 1913T970 1889Q1034 1889 1086 1932T1148 2044L1251 2028Q1236 1915 1157 1847T974 1779Q908 1779 863 1802T778 1854T696 1907T591 1931Q520 1931 470 1887T414
-1776Z" />
-<glyph unicode="&#xe4;" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295
-262 403 171T672 80ZM812 1808Q774 1808 745 1837T716 1904Q716 1944 745 1972T812 2000Q852 2000 880 1972T908 1904Q908 1866 880 1837T812 1808ZM1260 1808Q1222 1808 1193 1837T1164 1904Q1164 1944 1193 1972T1260 2000Q1300 2000 1328 1972T1356 1904Q1356
-1866 1328 1837T1260 1808Z" />
-<glyph unicode="&#xe5;" horiz-adv-x="1545" d="M660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 886T1112 929T1165 1017V1081Q1165 1249 1059 1348T767 1447Q602 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q867 1559 960 1530T1127 1442T1244 1292T1287 1073V0H1165V277H1156Q1116 191 1047 121T880 9T660 -33ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498 690T344 581T295 419Q295
-262 403 171T672 80ZM780 1721Q704 1721 642 1756T543 1852T506 1987Q506 2061 542 2122T641 2219T780 2256Q856 2256 917 2220T1015 2122T1052 1987Q1052 1913 1016 1853T918 1757T780 1721ZM780 1815Q854 1815 903 1866T953 1987Q953 2058 903 2109T780 2161Q706
-2161 656 2109T606 1987Q606 1917 656 1866T780 1815Z" />
-<glyph unicode="&#xe6;" horiz-adv-x="2599" d="M1821 -32Q1582 -32 1418 101T1195 447L1223 1144Q1298 1333 1446 1446T1792 1559Q1921 1559 2036 1507T2240 1359T2380 1125T2431 817V741H1223V852H2308Q2308 1018 2240 1152T2056 1366T1792 1445Q1640 1445 1521
-1360T1331 1129T1259 805V755Q1259 562 1326 410T1519 170T1821 81Q1947 81 2036 123T2183 227T2269 345L2380 300Q2345 223 2272 147T2086 19T1821 -32ZM672 80Q816 80 927 150T1102 344T1165 623V849Q1142 829 1099 814T1001 787T887 767T771 751Q603 731 498
-690T344 581T295 419Q295 262 403 171T672 80ZM660 -33Q527 -33 417 20T240 175T173 423Q173 519 207 592T312 718T494 805T759 859Q876 873 968 887T1112 930T1165 1017V1081Q1165 1248 1059 1347T767 1447Q603 1447 490 1375T332 1188L217 1231Q261 1341 344
-1414T536 1523T764 1559Q863 1559 950 1532T1107 1453T1221 1323T1277 1143L1247 413H1232Q1191 262 1103 163T898 16T660 -33Z" />
-<glyph unicode="&#xe7;" horiz-adv-x="1561" d="M829 -32Q633 -32 484 71T252 355T168 763Q168 991 252 1171T485 1455T828 1559Q974 1559 1094 1499T1294 1332T1396 1084H1272Q1242 1240 1123 1342T829 1445Q673 1445 551 1358T359 1117T289 765Q289 570 357
-416T547 171T829 81Q942 81 1035 126T1192 254T1275 448H1399Q1377 306 1299 198T1100 29T829 -32ZM760 8H868L853 -72Q947 -87 1004 -145T1061 -305Q1061 -419 980 -480T741 -544L737 -449Q843 -447 895 -414T948 -308Q947 -228 893 -190T728 -151L760 8Z" />
-<glyph unicode="&#xe8;" horiz-adv-x="1629" d="M852 -32Q644 -32 490 71T252 353T168 761Q168 991 252 1171T485 1455T823 1559Q952 1559 1067 1508T1271 1359T1410 1125T1461 817V741H245V852H1339Q1339 1019 1271 1153T1086 1366T823 1445Q670 1445 551 1360T362
-1129T289 805V755Q289 563 356 411T550 170T852 81Q978 81 1067 123T1214 227T1300 345L1411 300Q1376 223 1303 147T1117 19T852 -32ZM1018 1755L717 2197H862L1130 1755H1018Z" />
-<glyph unicode="&#xe9;" horiz-adv-x="1629" d="M852 -32Q644 -32 490 71T252 353T168 761Q168 991 252 1171T485 1455T823 1559Q952 1559 1067 1508T1271 1359T1410 1125T1461 817V741H245V852H1339Q1339 1019 1271 1153T1086 1366T823 1445Q670 1445 551 1360T362
-1129T289 805V755Q289 563 356 411T550 170T852 81Q978 81 1067 123T1214 227T1300 345L1411 300Q1376 223 1303 147T1117 19T852 -32ZM757 1755L1025 2197H1170L869 1755H757Z" />
-<glyph unicode="&#xea;" horiz-adv-x="1629" d="M852 -32Q644 -32 490 71T252 353T168 761Q168 991 252 1171T485 1455T823 1559Q952 1559 1067 1508T1271 1359T1410 1125T1461 817V741H245V852H1339Q1339 1019 1271 1153T1086 1366T823 1445Q670 1445 551 1360T362
-1129T289 805V755Q289 563 356 411T550 170T852 81Q978 81 1067 123T1214 227T1300 345L1411 300Q1376 223 1303 147T1117 19T852 -32ZM547 1704H421V1715L775 2104H857L1211 1715V1704H1085L816 2003L547 1704Z" />
-<glyph unicode="&#xeb;" horiz-adv-x="1629" d="M852 -32Q644 -32 490 71T252 353T168 761Q168 991 252 1171T485 1455T823 1559Q952 1559 1067 1508T1271 1359T1410 1125T1461 817V741H245V852H1339Q1339 1019 1271 1153T1086 1366T823 1445Q670 1445 551 1360T362
-1129T289 805V755Q289 563 356 411T550 170T852 81Q978 81 1067 123T1214 227T1300 345L1411 300Q1376 223 1303 147T1117 19T852 -32ZM848 1808Q810 1808 781 1837T752 1904Q752 1944 781 1972T848 2000Q888 2000 916 1972T944 1904Q944 1866 916 1837T848 1808ZM1296
-1808Q1258 1808 1229 1837T1200 1904Q1200 1944 1229 1972T1296 2000Q1336 2000 1364 1972T1392 1904Q1392 1866 1364 1837T1296 1808Z" />
-<glyph unicode="&#xec;" horiz-adv-x="639" d="M259 0V1536H380V0H259ZM266 1755L-35 2197H110L378 1755H266Z" />
-<glyph unicode="&#xed;" horiz-adv-x="639" d="M259 0V1536H380V0H259ZM261 1755L529 2197H674L373 1755H261Z" />
-<glyph unicode="&#xee;" horiz-adv-x="639" d="M259 0V1536H380V0H259ZM51 1704H-75V1715L279 2104H361L715 1715V1704H589L320 2003L51 1704Z" />
-<glyph unicode="&#xef;" horiz-adv-x="639" d="M259 0V1536H380V0H259ZM96 1808Q58 1808 29 1837T0 1904Q0 1944 29 1972T96 2000Q136 2000 164 1972T192 1904Q192 1866 164 1837T96 1808ZM544 1808Q506 1808 477 1837T448 1904Q448 1944 477 1972T544 2000Q584
-2000 612 1972T640 1904Q640 1866 612 1837T544 1808Z" />
-<glyph unicode="&#xf0;" horiz-adv-x="1644" d="M1335 1989L539 1620L505 1711L1301 2080L1335 1989ZM823 -32Q631 -32 482 65T247 331T161 711Q161 923 247 1091T478 1356T803 1453Q918 1453 1012 1415T1181 1308T1308 1145H1321Q1278 1279 1208 1409T1040 1660T814
-1886T525 2077L573 2176Q768 2073 933 1924T1222 1591T1414 1195T1483 753Q1483 511 1397 335T1162 63T823 -32ZM823 81Q982 81 1104 164T1296 389T1365 705Q1365 876 1298 1021T1108 1256T823 1345Q664 1345 543 1260T352 1030T283 711Q283 538 350 395T539 167T823
-81Z" />
-<glyph unicode="&#xf1;" horiz-adv-x="1616" d="M375 996V0H253V1536H372V1293H384Q438 1412 557 1484T840 1556Q995 1556 1112 1490T1296 1303T1363 1013V0H1241V1008Q1241 1204 1125 1323T817 1443Q689 1443 589 1387T432 1231T375 996ZM444 1776L340 1784Q349
-1898 427 1969T617 2041Q682 2041 730 2018T819 1966T902 1913T1000 1889Q1064 1889 1116 1932T1178 2044L1281 2028Q1266 1915 1187 1847T1004 1779Q938 1779 893 1802T808 1854T726 1907T621 1931Q550 1931 500 1887T444 1776Z" />
-<glyph unicode="&#xf2;" horiz-adv-x="1648" d="M824 -32Q634 -32 486 70T253 353T168 763Q168 994 253 1173T486 1456T824 1559Q1014 1559 1162 1456T1395 1173T1480 763Q1480 533 1395 353T1162 71T824 -32ZM824 81Q985 81 1105 172T1292 418T1359 763Q1359
-953 1292 1107T1105 1353T824 1445Q663 1445 543 1354T356 1108T289 763Q289 573 355 418T542 172T824 81ZM1026 1755L725 2197H870L1138 1755H1026Z" />
-<glyph unicode="&#xf3;" horiz-adv-x="1648" d="M824 -32Q634 -32 486 70T253 353T168 763Q168 994 253 1173T486 1456T824 1559Q1014 1559 1162 1456T1395 1173T1480 763Q1480 533 1395 353T1162 71T824 -32ZM824 81Q985 81 1105 172T1292 418T1359 763Q1359
-953 1292 1107T1105 1353T824 1445Q663 1445 543 1354T356 1108T289 763Q289 573 355 418T542 172T824 81ZM765 1755L1033 2197H1178L877 1755H765Z" />
-<glyph unicode="&#xf4;" horiz-adv-x="1648" d="M824 -32Q634 -32 486 70T253 353T168 763Q168 994 253 1173T486 1456T824 1559Q1014 1559 1162 1456T1395 1173T1480 763Q1480 533 1395 353T1162 71T824 -32ZM824 81Q985 81 1105 172T1292 418T1359 763Q1359
-953 1292 1107T1105 1353T824 1445Q663 1445 543 1354T356 1108T289 763Q289 573 355 418T542 172T824 81ZM555 1704H429V1715L783 2104H865L1219 1715V1704H1093L824 2003L555 1704Z" />
-<glyph unicode="&#xf5;" horiz-adv-x="1648" d="M824 -32Q634 -32 486 70T253 353T168 763Q168 994 253 1173T486 1456T824 1559Q1014 1559 1162 1456T1395 1173T1480 763Q1480 533 1395 353T1162 71T824 -32ZM824 81Q985 81 1105 172T1292 418T1359 763Q1359
-953 1292 1107T1105 1353T824 1445Q663 1445 543 1354T356 1108T289 763Q289 573 355 418T542 172T824 81ZM458 1776L354 1784Q363 1898 441 1969T631 2041Q696 2041 744 2018T833 1966T916 1913T1014 1889Q1078 1889 1130 1932T1192 2044L1295 2028Q1280 1915
-1201 1847T1018 1779Q952 1779 907 1802T822 1854T740 1907T635 1931Q564 1931 514 1887T458 1776Z" />
-<glyph unicode="&#xf6;" horiz-adv-x="1648" d="M824 -32Q634 -32 486 70T253 353T168 763Q168 994 253 1173T486 1456T824 1559Q1014 1559 1162 1456T1395 1173T1480 763Q1480 533 1395 353T1162 71T824 -32ZM824 81Q985 81 1105 172T1292 418T1359 763Q1359
-953 1292 1107T1105 1353T824 1445Q663 1445 543 1354T356 1108T289 763Q289 573 355 418T542 172T824 81ZM856 1808Q818 1808 789 1837T760 1904Q760 1944 789 1972T856 2000Q896 2000 924 1972T952 1904Q952 1866 924 1837T856 1808ZM1304 1808Q1266 1808 1237
-1837T1208 1904Q1208 1944 1237 1972T1304 2000Q1344 2000 1372 1972T1400 1904Q1400 1866 1372 1837T1304 1808Z" />
-<glyph unicode="&#xf7;" horiz-adv-x="1813" d="M1507 859V741H307V859H1507ZM907 216Q862 216 831 247T799 324Q799 368 830 399T907 431Q950 431 981 400T1013 324Q1013 279 982 248T907 216ZM907 1169Q877 1169 852 1183T813 1222T799 1278Q799 1321 830 1352T907
-1384Q950 1384 981 1353T1013 1278Q1013 1232 982 1201T907 1169Z" />
-<glyph unicode="&#xf8;" horiz-adv-x="1648" d="M335 -55L247 4L1313 1587L1401 1528L335 -55ZM824 -29Q634 -29 486 73T253 355T168 765Q168 997 253 1176T486 1458T824 1561Q1014 1561 1162 1458T1395 1176T1480 765Q1480 535 1395 356T1162 74T824 -29ZM824
-84Q985 84 1105 175T1292 420T1359 765Q1359 955 1292 1110T1105 1356T824 1448Q663 1448 543 1357T356 1110T289 765Q289 575 355 421T542 175T824 84Z" />
-<glyph unicode="&#xf9;" horiz-adv-x="1612" d="M1237 545V1536H1359V0H1237V249H1227Q1173 130 1053 55T768 -20Q617 -20 501 46T319 233T253 523V1536H375V528Q375 336 491 218T793 99Q913 99 1014 153T1176 307T1237 545ZM1009 1755L708 2197H853L1121 1755H1009Z" />
-<glyph unicode="&#xfa;" horiz-adv-x="1612" d="M1237 545V1536H1359V0H1237V249H1227Q1173 130 1053 55T768 -20Q617 -20 501 46T319 233T253 523V1536H375V528Q375 336 491 218T793 99Q913 99 1014 153T1176 307T1237 545ZM748 1755L1016 2197H1161L860 1755H748Z" />
-<glyph unicode="&#xfb;" horiz-adv-x="1612" d="M1237 545V1536H1359V0H1237V249H1227Q1173 130 1053 55T768 -20Q617 -20 501 46T319 233T253 523V1536H375V528Q375 336 491 218T793 99Q913 99 1014 153T1176 307T1237 545ZM538 1704H412V1715L766 2104H848L1202
-1715V1704H1076L807 2003L538 1704Z" />
-<glyph unicode="&#xfc;" horiz-adv-x="1612" d="M1237 545V1536H1359V0H1237V249H1227Q1173 130 1053 55T768 -20Q617 -20 501 46T319 233T253 523V1536H375V528Q375 336 491 218T793 99Q913 99 1014 153T1176 307T1237 545ZM839 1808Q801 1808 772 1837T743 1904Q743
-1944 772 1972T839 2000Q879 2000 907 1972T935 1904Q935 1866 907 1837T839 1808ZM1287 1808Q1249 1808 1220 1837T1191 1904Q1191 1944 1220 1972T1287 2000Q1327 2000 1355 1972T1383 1904Q1383 1866 1355 1837T1287 1808Z" />
-<glyph unicode="&#xfd;" horiz-adv-x="1505" d="M351 -577Q305 -577 260 -567T179 -541L215 -435Q357 -488 457 -438T621 -213L691 -21L128 1536H259L747 157H757L1245 1536L1377 1535L728 -261Q690 -366 635 -436T508 -542T351 -577ZM693 1755L961 2197H1106L805
-1755H693Z" />
-<glyph unicode="&#xfe;" horiz-adv-x="1653" d="M248 2048H369V1020L336 773L369 485V-576H248V2048ZM317 1193H381Q418 1292 483 1375T646 1508T876 1559Q1062 1559 1199 1457T1413 1176T1489 764Q1489 532 1413 353T1199 71T877 -32Q747 -32 649 18T484 151T381
-335H317V1193ZM1368 765Q1368 958 1309 1112T1138 1355T867 1445Q710 1445 599 1357T428 1115T368 765Q368 569 428 415T599 171T867 81Q1024 81 1136 171T1308 417T1368 765Z" />
-<glyph unicode="&#xff;" horiz-adv-x="1505" d="M351 -577Q305 -577 260 -567T179 -541L215 -435Q357 -488 457 -438T621 -213L691 -21L128 1536H259L747 157H757L1245 1536L1377 1535L728 -261Q690 -366 635 -436T508 -542T351 -577ZM528 1808Q490 1808 461 1837T432
-1904Q432 1944 461 1972T528 2000Q568 2000 596 1972T624 1904Q624 1866 596 1837T528 1808ZM976 1808Q938 1808 909 1837T880 1904Q880 1944 909 1972T976 2000Q1016 2000 1044 1972T1072 1904Q1072 1866 1044 1837T976 1808Z" />
-<glyph unicode="&#x2013;" horiz-adv-x="1408" d="M1408 936V820H0V936H1408Z" />
-<glyph unicode="&#x2014;" horiz-adv-x="2816" d="M2816 936V820H0V936H2816Z" />
-<glyph unicode="&#x2018;" horiz-adv-x="509" d="M139 1408V1588Q139 1670 168 1762T254 1941T391 2092L457 2025Q370 1941 313 1824T256 1589V1408H139Z" />
-<glyph unicode="&#x2019;" horiz-adv-x="467" d="M371 2048V1868Q371 1786 342 1694T256 1515T119 1364L52 1431Q140 1515 196 1632T253 1867V2048H371Z" />
-<glyph unicode="&#x201a;" horiz-adv-x="509" d="M370 256V76Q370 -6 341 -98T255 -277T118 -428L51 -361Q139 -277 195 -160T252 75V256H370Z" />
-<glyph unicode="&#x201c;" horiz-adv-x="936" d="M139 1408V1588Q139 1670 168 1762T254 1941T391 2092L457 2025Q370 1941 313 1824T256 1589V1408H139ZM566 1408V1588Q566 1670 595 1762T681 1941T818 2092L884 2025Q797 1941 740 1824T683 1589V1408H566Z" />
-<glyph unicode="&#x201d;" horiz-adv-x="936" d="M371 2048V1868Q371 1786 342 1694T256 1515T119 1364L52 1431Q140 1515 196 1632T253 1867V2048H371ZM798 2048V1868Q798 1786 769 1694T683 1515T546 1364L479 1431Q567 1515 623 1632T680 1867V2048H798Z" />
-<glyph unicode="&#x201e;" horiz-adv-x="936" d="M371 256V76Q371 -6 342 -98T256 -277T119 -428L52 -361Q140 -277 196 -160T253 75V256H371ZM798 256V76Q798 -6 769 -98T683 -277T546 -428L479 -361Q567 -277 623 -160T680 75V256H798Z" />
-<glyph unicode="&#x2022;" horiz-adv-x="1584" d="M792 499Q704 499 632 541T518 656T475 816Q475 904 517 976T632 1090T792 1133Q879 1133 951 1091T1067 976T1109 816Q1109 729 1066 657T951 542T792 499Z" />
-<glyph unicode="&#x2039;" horiz-adv-x="981" d="M741 220H621L217 820V840H336L741 220ZM741 1448L336 828H217V848L621 1448H741Z" />
-<glyph unicode="&#x203a;" horiz-adv-x="981" d="M233 220L639 840H757V820L353 220H233ZM233 1448H353L757 848V828H639L233 1448Z" />
-</font>
-</defs>
-</svg>

BIN
frontend/dist/fonts/inter-v3-latin-200.ttf


BIN
frontend/dist/fonts/inter-v3-latin-200.woff


BIN
frontend/dist/fonts/inter-v3-latin-200.woff2


BIN
frontend/dist/fonts/inter-v3-latin-600.eot


+ 0 - 351
frontend/dist/fonts/inter-v3-latin-600.svg

@@ -1,351 +0,0 @@
-<?xml version="1.0" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg xmlns="http://www.w3.org/2000/svg">
-<defs >
-<font id="Inter" horiz-adv-x="1838" ><font-face
-    font-family="Inter SemiBold"
-    units-per-em="2816"
-    panose-1="2 11 5 2 3 0 0 0 0 4"
-    ascent="2728"
-    descent="-680"
-    alphabetic="0" />
-<glyph unicode=" " horiz-adv-x="699" />
-<glyph unicode="!" horiz-adv-x="870" d="M629 2048L596 602H274L242 2048H629ZM435 -22Q344 -22 280 42T215 198Q215 288 279 352T435 416Q524 416 589 352T655 198Q655 137 625 87T545 8T435 -22Z" />
-<glyph unicode="&quot;" horiz-adv-x="1115" d="M436 2048V1294H160V2048H436ZM951 2048V1294H675V2048H951Z" />
-<glyph unicode="#" horiz-adv-x="1811" d="M958 0L1294 2048H1574L1238 0H958ZM35 528L82 808H1658L1611 528H35ZM238 0L574 2048H854L518 0H238ZM154 1240L200 1520H1776L1730 1240H154Z" />
-<glyph unicode="$" horiz-adv-x="1828" d="M811 -256L827 2304H1039L1023 -256H811ZM1315 1485Q1301 1616 1198 1689T929 1762Q813 1762 730 1727T603 1632T558 1495Q558 1431 587 1384T668 1305T781 1250T906 1213L1098 1165Q1214 1138 1321 1092T1514 976T1650
-807T1700 575Q1700 395 1608 259T1343 46T923 -31Q684 -31 509 43T235 259T128 605H493Q501 498 559 427T710 321T920 286Q1041 286 1132 322T1276 424T1329 578Q1328 658 1282 710T1154 798T961 862L728 922Q475 987 329 1119T182 1472Q182 1653 280 1789T549
-2000T934 2076Q1152 2076 1316 2001T1575 1792T1672 1485H1315Z" />
-<glyph unicode="%" horiz-adv-x="2376" d="M1277 384V492Q1277 611 1327 711T1475 872T1710 933Q1852 933 1948 873T2094 712T2143 492V384Q2144 265 2094 165T1947 4T1710 -57Q1570 -57 1474 4T1327 165T1277 384ZM1538 492V384Q1539 305 1576 239T1710 173Q1807
-173 1844 238T1880 384V492Q1881 572 1846 638T1710 705Q1616 705 1578 639T1538 492ZM236 1556V1664Q236 1784 287 1884T435 2044T670 2105Q811 2105 907 2045T1053 1884T1102 1664V1556Q1103 1436 1053 1337T906 1177T670 1117Q529 1117 433 1177T286 1337T236
-1556ZM499 1664V1556Q500 1476 537 1411T670 1345Q766 1345 803 1410T839 1556V1664Q840 1744 805 1810T670 1877Q575 1877 538 1810T499 1664ZM326 0L1734 2048H1985L577 0H326Z" />
-<glyph unicode="&amp;" horiz-adv-x="1866" d="M798 -30Q588 -30 437 46T205 251T123 544Q123 667 173 762T313 938T521 1099L884 1346Q964 1398 1005 1456T1047 1592Q1047 1658 992 1717T839 1776Q774 1776 725 1746T648 1666T620 1562Q620 1495 657 1426T755
-1283T885 1126L1846 0H1454L656 914Q567 1018 485 1119T350 1329T297 1567Q297 1712 364 1826T551 2007T834 2073Q996 2073 1112 2009T1291 1839T1354 1608Q1354 1463 1282 1349T1082 1141L668 838Q566 764 522 689T478 560Q478 479 519 413T634 308T809 268Q919
-268 1026 317T1221 459T1361 679T1413 965H1724Q1724 771 1681 628T1570 386T1426 228Q1402 211 1380 194T1334 160Q1226 62 1082 16T798 -30Z" />
-<glyph unicode="&apos;" horiz-adv-x="600" d="M436 2048V1294H160V2048H436Z" />
-<glyph unicode="(" horiz-adv-x="1108" d="M276 835Q276 1210 375 1524T672 2106H1013Q937 2007 871 1864T757 1550T681 1196T653 835Q653 598 700 355T829 -95T1013 -435H672Q475 -168 376 146T276 835Z" />
-<glyph unicode=")" horiz-adv-x="1108" d="M832 835Q832 461 733 147T436 -435H95Q172 -336 237 -193T351 121T427 475T455 835Q455 1072 408 1315T279 1766T95 2106H436Q633 1839 732 1525T832 835Z" />
-<glyph unicode="*" horiz-adv-x="1530" d="M643 896L666 1301L326 1078L204 1290L567 1472L204 1654L326 1866L666 1643L643 2048H886L864 1643L1204 1866L1326 1654L962 1472L1326 1290L1204 1078L864 1301L886 896H643Z" />
-<glyph unicode="+" horiz-adv-x="1894" d="M790 121V1505H1104V121H790ZM255 656V970H1639V656H255Z" />
-<glyph unicode="," horiz-adv-x="831" d="M611 280L598 170Q584 44 549 -86T474 -327T411 -500H167Q181 -439 205 -334T253 -98T284 168L291 280H611Z" />
-<glyph unicode="-" horiz-adv-x="1312" d="M1117 1002V704H195V1002H1117Z" />
-<glyph unicode="." horiz-adv-x="818" d="M409 -22Q318 -22 253 42T189 198Q188 288 253 352T409 416Q497 416 562 352T629 198Q628 137 598 87T518 8T409 -22Z" />
-<glyph unicode="/" horiz-adv-x="1066" d="M1022 2144L362 -308H45L705 2144H1022Z" />
-<glyph unicode="0" horiz-adv-x="1878" d="M939 -39Q692 -39 516 86T245 449T150 1022Q151 1357 245 1592T516 1952T939 2076Q1185 2076 1362 1952T1633 1592T1728 1022Q1728 686 1634 449T1363 86T939 -39ZM939 274Q1131 274 1242 463T1354 1022Q1354 1266 1303
-1432T1159 1682T939 1767Q748 1767 637 1578T525 1022Q524 777 574 610T718 359T939 274Z" />
-<glyph unicode="1" horiz-adv-x="1354" d="M1015 2048V0H644V1687H632L153 1381V1721L662 2048H1015Z" />
-<glyph unicode="2" horiz-adv-x="1750" d="M182 0V268L893 965Q995 1068 1063 1148T1165 1303T1199 1463Q1199 1560 1155 1629T1034 1737T859 1775Q758 1775 682 1734T565 1616T523 1435H170Q170 1630 259 1774T504 1997T864 2076Q1070 2076 1226 1999T1468 1788T1555
-1482Q1555 1367 1511 1256T1357 1010T1047 683L694 324V310H1586V0H182Z" />
-<glyph unicode="3" horiz-adv-x="1835" d="M917 -28Q701 -28 534 46T269 252T166 557H542Q547 474 597 413T730 317T916 283Q1026 283 1111 321T1244 429T1291 588Q1292 681 1243 752T1102 863T879 903H698V1189H879Q986 1189 1066 1226T1193 1330T1238 1487Q1239
-1574 1200 1638T1089 1739T921 1775Q827 1775 747 1741T618 1645T566 1496H209Q213 1668 308 1798T564 2002T923 2076Q1128 2076 1279 2000T1514 1794T1597 1509Q1598 1336 1496 1219T1227 1066V1050Q1443 1020 1558 891T1673 569Q1673 397 1576 262T1308 49T917
--28Z" />
-<glyph unicode="4" horiz-adv-x="1875" d="M145 380V675L1014 2048H1260V1628H1110L525 701V685H1738V380H145ZM1122 0V470L1126 602V2048H1476V0H1122Z" />
-<glyph unicode="5" horiz-adv-x="1781" d="M894 -28Q694 -28 536 47T286 255T187 557H547Q557 431 656 352T894 272Q1003 272 1088 322T1222 461T1270 664Q1271 780 1221 870T1084 1011T884 1063Q792 1064 703 1029T562 937L227 992L334 2048H1522V1738H641L582
-1195H594Q651 1262 755 1306T983 1351Q1169 1351 1315 1264T1545 1023T1629 673Q1629 470 1536 312T1277 63T894 -28Z" />
-<glyph unicode="6" horiz-adv-x="1825" d="M932 -28Q785 -27 646 23T394 188T216 493T150 966Q150 1231 206 1438T367 1788T620 2007T953 2082Q1146 2082 1295 2006T1537 1797T1651 1496H1286Q1258 1618 1172 1689T953 1761Q740 1761 626 1575T510 1062H524Q573
-1150 651 1213T827 1310T1036 1345Q1216 1345 1359 1259T1587 1023T1670 678Q1671 476 1578 316T1319 63T932 -28ZM930 272Q1039 272 1125 325T1261 468T1310 670Q1311 780 1263 869T1129 1010T935 1062Q854 1062 784 1031T662 946T581 820T550 667Q551 560 600
-470T735 326T930 272Z" />
-<glyph unicode="7" horiz-adv-x="1653" d="M257 0L1128 1724V1738H117V2048H1512V1731L642 0H257Z" />
-<glyph unicode="8" horiz-adv-x="1819" d="M910 -28Q687 -28 515 47T245 252T148 549Q147 678 204 786T358 966T576 1059V1073Q418 1108 321 1234T224 1527Q223 1685 312 1809T556 2004T910 2076Q1107 2076 1261 2005T1505 1809T1595 1527Q1595 1361 1497 1235T1243
-1073V1059Q1363 1039 1459 967T1613 786T1671 549Q1671 383 1573 253T1304 47T910 -28ZM910 258Q1025 258 1110 297T1242 409T1290 575Q1289 673 1240 748T1105 867T910 910Q800 910 714 867T579 749T530 575Q529 481 576 410T708 298T910 258ZM910 1191Q1004 1191
-1076 1229T1191 1335T1234 1492Q1233 1580 1192 1646T1078 1749T910 1786Q813 1786 740 1750T626 1647T586 1492Q585 1403 626 1336T741 1230T910 1191Z" />
-<glyph unicode="9" horiz-adv-x="1825" d="M893 2082Q1040 2081 1179 2030T1431 1865T1609 1560T1675 1087Q1675 823 1619 616T1458 265T1205 47T872 -28Q678 -28 529 48T287 256T174 558H539Q567 436 653 365T872 293Q1085 293 1200 478T1315 992H1301Q1252 904
-1174 841T998 743T789 709Q609 709 465 795T238 1031T154 1376Q154 1578 246 1738T505 1990T893 2082ZM894 1782Q786 1782 700 1729T564 1586T514 1384Q514 1274 562 1185T696 1043T890 991Q971 991 1041 1022T1163 1109T1244 1235T1274 1386Q1274 1493 1225 1583T1090
-1727T894 1782Z" />
-<glyph unicode=":" horiz-adv-x="818" d="M409 -22Q318 -22 253 42T189 198Q188 288 253 352T409 416Q497 416 562 352T629 198Q628 137 598 87T518 8T409 -22ZM409 1075Q318 1075 253 1139T189 1295Q188 1385 253 1449T409 1513Q497 1513 562 1449T629 1295Q628
-1234 598 1184T518 1105T409 1075Z" />
-<glyph unicode=";" horiz-adv-x="828" d="M611 280L598 170Q584 44 549 -86T474 -327T411 -500H167Q181 -439 205 -334T253 -98T284 168L291 280H611ZM445 1075Q354 1075 289 1139T225 1295Q224 1385 289 1449T445 1513Q533 1513 598 1449T665 1295Q664 1234 634
-1184T554 1105T445 1075Z" />
-<glyph unicode="&lt;" horiz-adv-x="1894" d="M263 669V957L1631 1589V1234L659 817L670 836V790L659 809L1631 392V37L263 669Z" />
-<glyph unicode="=" horiz-adv-x="1894" d="M282 978V1287H1613V978H282ZM282 338V647H1613V338H282Z" />
-<glyph unicode="&gt;" horiz-adv-x="1894" d="M1631 669L263 37V392L1235 809L1224 790V836L1235 817L263 1234V1589L1631 957V669Z" />
-<glyph unicode="?" horiz-adv-x="1532" d="M532 602V628Q533 809 566 916T662 1088T813 1209Q874 1247 922 1292T999 1394T1027 1521Q1027 1600 990 1658T891 1748T751 1780Q680 1780 617 1750T512 1658T465 1502H109Q114 1692 201 1820T433 2012T753 2076Q944
-2076 1089 2009T1316 1819T1398 1526Q1398 1412 1362 1322T1258 1162T1099 1037Q1018 987 967 933T891 807T865 628V602H532ZM706 -22Q616 -22 551 42T486 198Q486 288 551 352T706 416Q795 416 860 352T926 198Q926 137 896 87T816 8T706 -22Z" />
-<glyph unicode="@" horiz-adv-x="2814" d="M1473 -574Q1158 -574 914 -489T501 -238T246 166T158 710Q159 1009 247 1249T504 1661T917 1925T1472 2017Q1765 2017 1991 1928T2374 1681T2611 1311T2692 852Q2692 681 2666 529T2578 258T2408 71T2140 -5Q2039 -10
-1963 14T1841 89T1782 216H1770Q1747 156 1684 107T1527 30T1320 7Q1198 13 1095 63T915 205T796 427T754 722Q754 883 800 1006T923 1216T1099 1352T1301 1416Q1409 1431 1504 1413T1662 1358T1742 1277H1756V1389H2035V437Q2036 362 2071 316T2171 270Q2251 270
-2300 333T2372 529T2394 869Q2395 1039 2350 1177T2221 1421T2023 1596T1766 1703T1465 1739Q1220 1739 1034 1665T723 1453T534 1129T470 714Q470 471 536 284T732 -32T1051 -226T1490 -292Q1604 -292 1713 -274T1909 -232T2043 -190L2128 -444Q2066 -476 1961
--506T1729 -555T1473 -574ZM1403 294Q1530 294 1606 343T1715 491T1747 735Q1747 871 1713 958T1604 1087T1405 1129Q1293 1129 1215 1078T1095 938T1053 734Q1053 620 1086 520T1196 357T1403 294Z" />
-<glyph unicode="A" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803Z" />
-<glyph unicode="B" horiz-adv-x="1851" d="M202 0V2048H986Q1208 2048 1355 1979T1576 1789T1650 1517Q1650 1392 1602 1304T1473 1160T1292 1081V1061Q1401 1055 1501 995T1666 824T1730 558Q1730 399 1653 273T1421 73T1031 0H202ZM573 310H972Q1174 310 1263
-387T1353 587Q1353 678 1308 751T1180 867T982 910H573V310ZM573 1177H940Q1036 1177 1113 1212T1234 1313T1279 1469Q1279 1588 1196 1665T948 1742H573V1177Z" />
-<glyph unicode="C" horiz-adv-x="2094" d="M1955 1357H1581Q1565 1449 1522 1520T1415 1642T1270 1717T1094 1743Q927 1743 798 1660T596 1416T523 1024Q523 789 596 629T798 387T1093 305Q1185 305 1265 329T1410 402T1519 520T1581 680L1955 678Q1934 532 1865
-404T1683 179T1421 27T1088 -28Q818 -28 606 97T272 458T150 1024Q150 1355 273 1590T608 1951T1088 2076Q1259 2076 1406 2028T1668 1888T1857 1662T1955 1357Z" />
-<glyph unicode="D" horiz-adv-x="2040" d="M896 0H202V2048H910Q1215 2048 1434 1926T1772 1574T1890 1026Q1890 706 1772 476T1431 123T896 0ZM573 321H878Q1092 321 1235 399T1450 634T1522 1026Q1522 1261 1450 1416T1238 1649T889 1727H573V321Z" />
-<glyph unicode="E" horiz-adv-x="1711" d="M202 0V2048H1534V1737H573V1181H1465V870H573V311H1542V0H202Z" />
-<glyph unicode="F" horiz-adv-x="1649" d="M202 0V2048H1514V1737H573V1181H1424V870H573V0H202Z" />
-<glyph unicode="G" horiz-adv-x="2126" d="M1574 1394Q1549 1475 1506 1539T1402 1650T1263 1719T1093 1743Q928 1743 799 1660T597 1417T523 1026Q523 794 596 633T799 389T1101 305Q1257 305 1371 365T1548 535T1610 795L1694 782H1138V1072H1969V826Q1969 563
-1857 371T1549 76T1099 -28Q816 -28 603 99T270 462T150 1022Q150 1270 220 1465T418 1796T716 2004T1088 2076Q1258 2076 1405 2027T1666 1886T1855 1670T1952 1394H1574Z" />
-<glyph unicode="H" horiz-adv-x="2095" d="M202 0V2048H573V1181H1522V2048H1894V0H1522V870H573V0H202Z" />
-<glyph unicode="I" horiz-adv-x="774" d="M573 2048V0H202V2048H573Z" />
-<glyph unicode="J" horiz-adv-x="1579" d="M1010 2048H1378V608Q1377 410 1294 268T1063 49T717 -28Q537 -28 394 36T166 228T82 544H451Q452 461 487 401T586 309T731 277Q820 277 882 314T976 426T1010 608V2048Z" />
-<glyph unicode="K" horiz-adv-x="1906" d="M202 0V2048H573V1107H598L1397 2048H1850L1058 1129L1857 0H1411L800 878L573 610V0H202Z" />
-<glyph unicode="L" horiz-adv-x="1594" d="M202 0V2048H573V311H1475V0H202Z" />
-<glyph unicode="M" horiz-adv-x="2552" d="M202 2048H656L1264 564H1288L1896 2048H2350V0H1994V1407H1975L1409 6H1143L577 1410H558V0H202V2048Z" />
-<glyph unicode="N" horiz-adv-x="2086" d="M1885 2048V0H1555L590 1395H573V0H202V2048H534L1498 652H1516V2048H1885Z" />
-<glyph unicode="O" horiz-adv-x="2182" d="M2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091
-1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024Z" />
-<glyph unicode="P" horiz-adv-x="1812" d="M202 0V2048H970Q1206 2048 1366 1960T1609 1719T1692 1370Q1692 1173 1609 1020T1364 780T965 692H456V997H915Q1053 997 1141 1045T1271 1177T1314 1370Q1314 1479 1272 1562T1141 1691T913 1738H573V0H202Z" />
-<glyph unicode="Q" horiz-adv-x="2184" d="M965 688H1301L1500 430L1642 262L1983 -176H1623L1391 118L1292 258L965 688ZM2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572
-1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091 1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024Z" />
-<glyph unicode="R" horiz-adv-x="1832" d="M202 0V2048H970Q1206 2048 1366 1966T1609 1737T1692 1393Q1692 1196 1609 1052T1363 829T965 750H418V1058H915Q1053 1058 1141 1096T1271 1209T1314 1393Q1314 1502 1271 1579T1140 1697T913 1738H573V0H202ZM1260
-928L1767 0H1353L855 928H1260Z" />
-<glyph unicode="S" horiz-adv-x="1828" d="M1315 1485Q1301 1616 1198 1689T929 1762Q813 1762 730 1727T603 1632T558 1495Q558 1431 587 1384T668 1305T781 1250T906 1213L1098 1165Q1214 1138 1321 1092T1514 976T1650 807T1700 575Q1700 395 1608 259T1343
-46T923 -31Q684 -31 509 43T235 259T128 605H493Q501 498 559 427T710 321T920 286Q1041 286 1132 322T1276 424T1329 578Q1328 658 1282 710T1154 798T961 862L728 922Q475 987 329 1119T182 1472Q182 1653 280 1789T549 2000T934 2076Q1152 2076 1316 2001T1575
-1792T1672 1485H1315Z" />
-<glyph unicode="T" horiz-adv-x="1858" d="M112 1737V2048H1746V1737H1113V0H745V1737H112Z" />
-<glyph unicode="U" horiz-adv-x="2066" d="M1493 2048H1864V710Q1864 490 1761 323T1470 63T1033 -31Q782 -31 595 62T305 323T202 710V2048H573V741Q573 613 629 513T789 357T1033 300Q1174 300 1277 356T1437 513T1493 741V2048Z" />
-<glyph unicode="V" horiz-adv-x="2038" d="M476 2048L1009 436H1030L1562 2048H1970L1248 0H790L69 2048H476Z" />
-<glyph unicode="W" horiz-adv-x="2835" d="M633 0L55 2048H454L823 543H842L1236 2048H1599L1994 542H2012L2381 2048H2780L2202 0H1836L1426 1437H1410L999 0H633Z" />
-<glyph unicode="X" horiz-adv-x="1944" d="M512 2048L963 1298H979L1432 2048H1855L1224 1024L1865 0H1435L979 745H963L507 0H79L726 1024L87 2048H512Z" />
-<glyph unicode="Y" horiz-adv-x="1984" d="M62 2048H481L982 1142H1002L1503 2048H1922L1177 764V0H807V764L62 2048Z" />
-<glyph unicode="Z" d="M164 0V231L1214 1737H158V2048H1674V1817L625 311H1680V0H164Z" />
-<glyph unicode="[" horiz-adv-x="1108" d="M310 -435V2106H1033V1828H658V-158H1033V-435H310Z" />
-<glyph unicode="\" horiz-adv-x="1066" d="M45 2144H362L1022 -308H705L45 2144Z" />
-<glyph unicode="]" horiz-adv-x="1108" d="M798 2106V-435H75V-158H450V1828H75V2106H798Z" />
-<glyph unicode="^" horiz-adv-x="1355" d="M642 1842H714V1735H642V1842ZM99 1120L518 1997H838L1256 1120H962L667 1775H688L395 1120H99Z" />
-<glyph unicode="_" horiz-adv-x="1318" d="M1321 0V-289H-2V0H1321Z" />
-<glyph unicode="`" horiz-adv-x="1400" d="M718 1738L411 2195H766L989 1738H718Z" />
-<glyph unicode="a" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296 1366T521
-1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463 337 536
-286T722 235Z" />
-<glyph unicode="b" horiz-adv-x="1775" d="M202 0V2048H564V1282H579Q607 1338 658 1401T796 1510T1018 1556Q1196 1556 1339 1466T1567 1199T1652 766Q1652 513 1569 336T1343 66T1019 -27Q887 -27 800 17T661 123T579 242H558V0H202ZM557 768Q557 619 599 507T722
-333T918 270Q1038 270 1119 334T1241 511T1283 768Q1283 911 1242 1022T1120 1196T918 1259Q802 1259 721 1198T599 1027T557 768Z" />
-<glyph unicode="c" horiz-adv-x="1626" d="M856 -30Q626 -30 462 71T209 350T120 762Q120 996 210 1175T464 1455T854 1556Q1041 1556 1185 1488T1416 1295T1514 1004H1168Q1147 1115 1069 1189T859 1264Q748 1264 664 1205T534 1034T487 768Q487 611 533 499T662
-326T859 265Q939 265 1002 295T1109 384T1168 525H1514Q1501 362 1418 237T1192 41T856 -30Z" />
-<glyph unicode="d" horiz-adv-x="1775" d="M756 -27Q575 -27 432 66T206 336T123 766Q123 1022 207 1198T436 1465T757 1556Q892 1556 979 1511T1117 1402T1196 1282H1211V2048H1574V0H1218V242H1196Q1168 186 1115 124T975 17T756 -27ZM857 270Q972 270 1053
-332T1176 507T1218 768Q1218 917 1177 1027T1055 1198T857 1259Q736 1259 655 1196T533 1022T492 768Q492 624 533 512T656 335T857 270Z" />
-<glyph unicode="e" horiz-adv-x="1669" d="M866 -30Q635 -30 468 66T210 340T120 759Q120 996 210 1175T464 1455T847 1556Q989 1556 1115 1511T1339 1370T1493 1129T1549 780V669H290V913H1202Q1201 1017 1157 1098T1035 1227T852 1274Q741 1274 657 1221T527
-1081T479 891V678Q479 544 528 449T665 303T871 252Q950 252 1014 274T1125 341T1196 450L1534 412Q1502 278 1413 179T1184 25T866 -30Z" />
-<glyph unicode="f" horiz-adv-x="1062" d="M964 1536V1256H56V1536H964ZM283 0V1681Q283 1836 347 1939T521 2093T763 2144Q857 2144 930 2129T1038 2102L966 1822Q943 1829 908 1836T830 1844Q729 1844 688 1796T646 1657V0H283Z" />
-<glyph unicode="g" horiz-adv-x="1761" d="M854 -602Q659 -602 519 -550T294 -411T176 -220L502 -141Q524 -182 566 -225T679 -298T860 -328Q1014 -328 1115 -254T1216 -15V276H1198Q1170 220 1117 161T976 62T756 22Q579 22 436 105T208 355T123 773Q123 1026
-207 1200T436 1465T757 1556Q892 1556 979 1511T1119 1402T1198 1282H1218V1536H1575V-25Q1575 -218 1481 -346T1224 -538T854 -602ZM857 306Q972 306 1053 362T1176 523T1218 775Q1218 920 1177 1029T1055 1198T857 1259Q736 1259 655 1196T533 1024T492 775Q492
-634 533 529T656 365T857 306Z" />
-<glyph unicode="h" horiz-adv-x="1726" d="M548 900V0H186V2048H540V1275H558Q612 1405 725 1480T1015 1556Q1175 1556 1294 1489T1478 1293T1544 978V0H1182V922Q1182 1077 1103 1163T880 1250Q784 1250 709 1209T591 1089T548 900Z" />
-<glyph unicode="i" horiz-adv-x="734" d="M186 0V1536H548V0H186ZM624 1754Q537 1754 476 1811T414 1950Q414 2032 476 2089T624 2147Q710 2147 771 2090T833 1950Q833 1869 772 1812T624 1754Z" />
-<glyph unicode="j" horiz-adv-x="734" d="M186 1536H548V-85Q548 -251 485 -360T302 -523T9 -573Q-7 -572 -23 -572T-60 -571V-276Q-45 -277 -33 -277T-8 -278Q96 -278 141 -228T186 -80V1536ZM622 1754Q535 1754 474 1811T412 1950Q412 2032 474 2089T622 2147Q708
-2147 769 2090T831 1950Q831 1869 770 1812T622 1754Z" />
-<glyph unicode="k" horiz-adv-x="1602" d="M516 482L515 919H573L1125 1536H1548L869 780H794L516 482ZM186 0V2048H548V0H186ZM1150 0L650 699L894 954L1583 0H1150Z" />
-<glyph unicode="l" horiz-adv-x="734" d="M548 2048V0H186V2048H548Z" />
-<glyph unicode="m" horiz-adv-x="2528" d="M186 0V1536H532V1275H550Q598 1407 709 1481T974 1556Q1130 1556 1237 1481T1389 1275H1405Q1456 1403 1577 1479T1866 1556Q2078 1556 2212 1422T2346 1031V0H1983V975Q1983 1118 1907 1184T1721 1251Q1590 1251 1517
-1170T1443 958V0H1088V990Q1088 1109 1017 1180T830 1251Q752 1251 688 1212T586 1101T548 934V0H186Z" />
-<glyph unicode="n" horiz-adv-x="1717" d="M548 900V0H186V1536H532V1275H550Q603 1404 719 1480T1008 1556Q1167 1556 1285 1488T1469 1291T1534 978V0H1172V922Q1172 1076 1093 1163T873 1250Q778 1250 705 1209T590 1089T548 900Z" />
-<glyph unicode="o" horiz-adv-x="1712" d="M856 -30Q631 -30 466 69T211 346T120 762Q120 1000 210 1179T466 1457T856 1556Q1081 1556 1246 1457T1501 1179T1592 762Q1592 524 1502 346T1246 69T856 -30ZM858 260Q980 260 1062 327T1184 509T1225 763Q1225 904
-1185 1018T1062 1201T858 1269Q733 1269 651 1201T528 1019T487 763Q487 623 527 509T650 328T858 260Z" />
-<glyph unicode="p" horiz-adv-x="1759" d="M186 -576V1536H542V1282H563Q591 1338 642 1401T780 1510T1002 1556Q1180 1556 1323 1466T1551 1199T1636 766Q1636 513 1553 336T1327 66T1003 -27Q871 -27 784 17T645 123T563 242H548V-576H186ZM541 768Q541 619
-583 507T706 333T902 270Q1022 270 1103 334T1225 511T1267 768Q1267 911 1226 1022T1104 1196T902 1259Q786 1259 705 1198T583 1027T541 768Z" />
-<glyph unicode="q" horiz-adv-x="1759" d="M1574 -576H1211V242H1196Q1168 186 1115 124T975 17T756 -27Q575 -27 432 66T206 336T123 766Q123 1022 207 1198T436 1465T757 1556Q892 1556 979 1511T1117 1402T1196 1282H1218V1536H1574V-576ZM1218 768Q1218 917
-1177 1027T1055 1198T857 1259Q736 1259 655 1196T533 1022T492 768Q492 624 533 512T656 335T857 270Q972 270 1053 332T1176 507T1218 768Z" />
-<glyph unicode="r" horiz-adv-x="1117" d="M186 0V1536H537V1280H553Q595 1413 697 1485T932 1558Q962 1558 999 1556T1062 1548V1215Q1039 1223 990 1229T894 1236Q795 1236 717 1194T593 1076T548 903V0H186Z" />
-<glyph unicode="s" horiz-adv-x="1546" d="M1400 1130L1070 1094Q1056 1144 1022 1188T929 1259T787 1286Q674 1286 598 1237T522 1110Q521 1043 571 1001T739 932L1001 876Q1219 829 1325 727T1433 460Q1432 315 1349 205T1117 32T777 -30Q495 -30 323 88T118
-419L471 453Q495 349 573 296T776 243Q905 243 983 296T1062 427Q1062 493 1012 536T855 602L593 657Q372 703 266 812T161 1090Q160 1232 238 1336T457 1498T782 1556Q1052 1556 1207 1441T1400 1130Z" />
-<glyph unicode="t" horiz-adv-x="1070" d="M953 1536V1256H70V1536H953ZM288 1904H650V462Q650 389 672 351T732 298T814 284Q848 284 876 289T920 298L981 15Q952 5 899 -7T768 -21Q632 -25 523 20T351 162T288 402V1904Z" />
-<glyph unicode="u" horiz-adv-x="1713" d="M1165 646V1536H1527V0H1176V273H1160Q1108 144 990 62T698 -20Q547 -20 432 47T251 244T186 558V1536H548V614Q548 468 628 382T838 296Q918 296 993 335T1116 451T1165 646Z" />
-<glyph unicode="v" horiz-adv-x="1622" d="M1558 1536L1011 0H611L64 1536H450L803 395H819L1173 1536H1558Z" />
-<glyph unicode="w" horiz-adv-x="2358" d="M503 0L69 1536H438L708 456H722L998 1536H1363L1639 462H1654L1920 1536H2290L1855 0H1478L1190 1038H1169L881 0H503Z" />
-<glyph unicode="x" horiz-adv-x="1584" d="M482 1536L792 969L1107 1536H1490L1027 768L1498 0H1117L792 553L470 0H86L554 768L98 1536H482Z" />
-<glyph unicode="y" horiz-adv-x="1624" d="M411 -573Q337 -573 275 -562T175 -534L259 -253Q377 -287 457 -265T590 -115L621 -32L64 1536H448L802 376H818L1173 1536L1560 1534L943 -186Q900 -306 829 -393T656 -526T411 -573Z" />
-<glyph unicode="z" horiz-adv-x="1582" d="M166 0V230L958 1223V1236H192V1536H1401V1289L647 313V300H1427V0H166Z" />
-<glyph unicode="{" horiz-adv-x="1108" d="M93 828V1003Q263 1003 330 1072T398 1299V1554Q398 1723 440 1831T565 1999T765 2082T1033 2106V1828Q915 1828 852 1796T766 1696T742 1525V1197Q742 1120 716 1053T621 936T425 857T93 828ZM1033 -435Q883 -435 765
--412T565 -328T441 -160T398 117V371Q398 528 331 597T93 667V842Q297 842 424 814T621 735T716 617T742 474V146Q742 42 765 -25T852 -125T1033 -158V-435ZM93 667V1003H410V667H93Z" />
-<glyph unicode="|" horiz-adv-x="1008" d="M672 2708V-660H336V2708H672Z" />
-<glyph unicode="}" horiz-adv-x="1108" d="M1015 842V667Q845 667 778 598T710 371V117Q710 -52 668 -160T543 -328T343 -411T75 -435V-158Q193 -158 256 -125T342 -25T366 146V474Q366 550 392 617T487 734T683 813T1015 842ZM75 2106Q225 2106 343 2083T543
-1999T667 1831T710 1554V1299Q710 1142 777 1073T1015 1003V828Q811 828 684 856T487 935T392 1053T366 1197V1525Q366 1629 343 1696T256 1795T75 1828V2106ZM1015 1003V667H698V1003H1015Z" />
-<glyph unicode="~" horiz-adv-x="1894" d="M221 571Q216 753 269 874T419 1055T641 1116Q738 1116 823 1076T1018 938Q1081 884 1122 857T1216 830Q1292 830 1339 888T1384 1066H1674Q1679 885 1625 764T1473 583T1254 522Q1154 522 1068 564T875 699Q809 757
-770 782T678 807Q605 807 557 753T510 571H221Z" />
-<glyph unicode="&#xa0;" horiz-adv-x="699" />
-<glyph unicode="&#xa1;" horiz-adv-x="870" d="M435 1552Q524 1552 589 1488T655 1332Q655 1242 590 1178T435 1114Q344 1114 280 1178T215 1332Q215 1393 245 1443T325 1522T435 1552ZM629 -518H242L274 928H596L629 -518Z" />
-<glyph unicode="&#xa2;" horiz-adv-x="1626" d="M760 -256V1780H938V-256H760ZM856 -30Q626 -30 462 71T209 350T120 762Q120 996 210 1175T464 1455T854 1556Q1041 1556 1185 1488T1416 1295T1514 1004H1168Q1147 1115 1069 1189T859 1264Q748 1264 664 1205T534
-1034T487 768Q487 611 533 499T662 326T859 265Q939 265 1002 295T1109 384T1168 525H1514Q1501 362 1418 237T1192 41T856 -30Z" />
-<glyph unicode="&#xa3;" horiz-adv-x="1853" d="M1638 0H194V311H1638V0ZM1230 809H187V1112H1230V809ZM756 1455L788 598Q794 465 745 369T571 210L307 311Q356 318 382 362T417 466T424 582L394 1455Q387 1658 471 1796T702 2005T1025 2076Q1177 2076 1297 2030T1500
-1902T1621 1712T1646 1479L1299 1508Q1306 1599 1270 1657T1170 1743T1034 1771Q960 1771 897 1737T795 1633T756 1455Z" />
-<glyph unicode="&#xa4;" horiz-adv-x="2114" d="M1055 -28Q883 -28 732 37T465 221T286 496T222 836Q221 1019 285 1175T465 1449T731 1631T1055 1696Q1227 1696 1378 1631T1643 1449T1823 1176T1890 836Q1889 653 1824 496T1644 221T1378 38T1055 -28ZM1055 219Q1177
-219 1283 267T1470 399T1596 596T1642 836Q1642 1009 1563 1148T1351 1370T1055 1453Q891 1453 757 1371T544 1149T466 836Q465 665 544 525T757 302T1055 219ZM1654 1262L1474 1450L1754 1734L1939 1546L1654 1262ZM1754 -30L1474 254L1654 438L1939 158L1754
--30ZM361 -30L180 158L461 438L646 254L361 -30ZM461 1262L180 1546L361 1734L646 1450L461 1262Z" />
-<glyph unicode="&#xa5;" horiz-adv-x="1589" d="M428 2048L885 980L626 806L40 2048H428ZM707 986L1161 2048H1549L962 806L707 986ZM974 1078V0H606V1078H974ZM1394 1044V819H166V1044H1394ZM1394 648V423H166V648H1394Z" />
-<glyph unicode="&#xa6;" horiz-adv-x="897" d="M285 565H612V-384H285V565ZM612 2048V1103H285V2048H612Z" />
-<glyph unicode="&#xa7;" horiz-adv-x="1630" d="M1360 1548H1034Q1034 1642 978 1705T804 1768Q739 1768 690 1747T613 1688T586 1602Q586 1550 617 1506T695 1430T784 1377L1169 1182Q1208 1163 1263 1129T1369 1040T1451 909T1477 728Q1472 648 1430 585T1330
-481T1216 422L1213 418Q1293 348 1333 259T1374 62Q1374 -92 1298 -198T1094 -359T810 -414Q634 -414 499 -348T289 -162T213 124H541Q540 12 614 -48T813 -105Q901 -104 972 -64T1045 62Q1045 122 1013 164T936 236T850 287L465 478Q425 497 370 532T264 622T181
-754T155 936Q160 1016 199 1079T297 1184T418 1244V1249Q346 1319 302 1405T258 1602Q258 1738 328 1845T521 2014T804 2076Q972 2076 1097 2008T1291 1821T1360 1548ZM1170 714Q1174 756 1154 793T1094 863T1000 921L646 1098Q608 1117 566 1102T493 1047T460
-950Q455 883 503 832T633 741L979 570Q1014 552 1057 566T1134 619T1170 714Z" />
-<glyph unicode="&#xa8;" horiz-adv-x="1558" d="M480 1762Q401 1762 344 1818T286 1950Q286 2030 343 2084T480 2139Q561 2139 616 2085T672 1950Q672 1874 617 1818T480 1762ZM1080 1762Q1001 1762 944 1818T886 1950Q886 2030 943 2084T1080 2139Q1161 2139
-1216 2085T1272 1950Q1272 1874 1217 1818T1080 1762Z" />
-<glyph unicode="&#xa9;" horiz-adv-x="2576" d="M722 1024Q722 1182 799 1312T1007 1520T1296 1598Q1435 1598 1552 1538T1745 1374T1839 1139H1560Q1543 1224 1468 1277T1296 1331Q1212 1331 1143 1290T1032 1179T990 1025Q990 940 1031 871T1142 759T1296 717Q1393
-717 1462 770T1548 909H1827Q1810 778 1737 674T1549 510T1296 450Q1138 450 1008 527T800 735T722 1024ZM1288 -32Q1070 -32 879 50T542 277T314 614T232 1024Q233 1243 315 1434T543 1770T879 1998T1288 2080Q1507 2080 1698 1998T2034 1771T2261 1434T2344 1024Q2345
-805 2263 614T2036 278T1699 50T1288 -32ZM1288 267Q1445 267 1583 326T1824 488T1986 729T2045 1023Q2044 1180 1986 1318T1823 1559T1582 1722T1288 1781Q1132 1781 995 1722T754 1559T591 1318T531 1023Q531 868 589 731T752 489T993 326T1288 267Z" />
-<glyph unicode="&#xaa;" horiz-adv-x="1336" d="M838 1184V1652Q838 1737 799 1782T675 1828Q584 1828 533 1793T482 1692L239 1710Q239 1850 358 1941T675 2032Q859 2032 971 1937T1083 1652V1220Q1083 1159 1090 1097T1121 970H872Q857 1017 848 1072T838 1184ZM900
-1582V1422H697Q576 1422 520 1376T464 1270Q464 1215 503 1183T627 1150Q684 1150 742 1175T840 1237T880 1308L904 1146Q873 1079 791 1016T566 952Q398 952 308 1037T217 1264Q217 1417 336 1499T697 1582H900Z" />
-<glyph unicode="&#xab;" horiz-adv-x="1682" d="M902 220H576L172 820V840H518L902 220ZM902 1448L518 828H172V848L576 1448H902ZM1512 220H1186L782 820V840H1128L1512 220ZM1512 1448L1128 828H782V848L1186 1448H1512Z" />
-<glyph unicode="&#xac;" horiz-adv-x="1595" d="M1386 1108V872H215V1108H1386ZM1386 1000V516H1105V1000H1386Z" />
-<glyph unicode="&#xad;" horiz-adv-x="1312" d="M1117 1002V704H195V1002H1117Z" />
-<glyph unicode="&#xae;" horiz-adv-x="1875" d="M651 916V1654H1016Q1078 1654 1134 1627T1226 1545T1262 1412Q1262 1333 1225 1278T1130 1193T1007 1164H752V1297H963Q1002 1297 1035 1326T1068 1412Q1068 1472 1035 1496T966 1520H833V916H651ZM1114 1259L1288
-916H1088L923 1259H1114ZM938 476Q772 476 627 538T372 710T200 965T138 1276Q138 1442 200 1587T372 1842T627 2014T938 2076Q1104 2076 1249 2014T1504 1842T1676 1587T1738 1276Q1738 1110 1676 965T1504 710T1249 538T938 476ZM938 681Q1102 681 1237 761T1452
-976T1533 1276Q1533 1440 1453 1575T1237 1791T938 1871Q773 1871 638 1791T423 1576T342 1276Q342 1112 422 977T638 762T938 681Z" />
-<glyph unicode="&#xaf;" horiz-adv-x="1482" d="M1222 2048V1827H260V2048H1222Z" />
-<glyph unicode="&#xb0;" horiz-adv-x="1290" d="M645 1101Q512 1101 403 1165T229 1339T164 1582Q164 1714 229 1823T403 1997T645 2062Q778 2062 887 1998T1061 1824T1126 1582Q1126 1449 1061 1340T887 1166T645 1101ZM645 1357Q707 1357 758 1387T839 1468T870
-1582Q870 1644 840 1695T758 1776T645 1806Q583 1806 532 1776T451 1695T420 1582Q420 1520 450 1469T531 1387T645 1357Z" />
-<glyph unicode="&#xb1;" horiz-adv-x="1894" d="M263 83V384H1631V83H263ZM263 907V1202H1631V907H263ZM790 541V1568H1104V541H790Z" />
-<glyph unicode="&#xb2;" horiz-adv-x="1190" d="M129 1024V1223L610 1622Q670 1672 713 1713T779 1795T802 1884Q802 1962 740 2006T588 2050Q492 2050 434 2002T375 1867H110Q110 2053 245 2160T592 2268Q735 2268 841 2218T1007 2081T1066 1888Q1066 1802 1031
-1729T922 1582T733 1423L527 1259V1250H1088V1024H129Z" />
-<glyph unicode="&#xb3;" horiz-adv-x="1234" d="M614 1008Q467 1008 352 1054T171 1184T101 1376H383Q387 1312 452 1275T614 1238Q713 1238 779 1279T846 1391Q846 1460 777 1504T589 1548H462V1753H589Q688 1753 751 1794T814 1903Q814 1968 760 2009T618 2050Q526
-2050 462 2010T394 1906H129Q131 2013 196 2094T372 2222T619 2268Q758 2268 862 2222T1025 2097T1083 1920Q1083 1812 1009 1746T829 1664V1654Q975 1638 1054 1563T1134 1372Q1134 1266 1067 1184T883 1055T614 1008Z" />
-<glyph unicode="&#xb4;" horiz-adv-x="1400" d="M542 1738L766 2195H1120L813 1738H542Z" />
-<glyph unicode="&#xb5;" horiz-adv-x="1835" d="M186 -563V1536H548V646Q548 542 594 463T724 340T918 296Q1028 296 1111 340T1241 464T1287 646V1536H1650V0H1298V222H1286Q1235 115 1136 57T918 -1Q798 -1 700 57T549 222H537V-563H186Z" />
-<glyph unicode="&#xb6;" horiz-adv-x="1707" d="M1381 1756H949V760H831Q608 760 455 843T222 1072T142 1404Q142 1588 221 1733T454 1963T831 2048H1381V1756ZM1197 0V2048H1506V0H1197Z" />
-<glyph unicode="&#xb7;" horiz-adv-x="818" d="M409 677Q318 677 253 741T189 897Q188 987 253 1051T409 1115Q497 1115 562 1051T629 897Q628 836 598 786T518 707T409 677Z" />
-<glyph unicode="&#xb8;" horiz-adv-x="954" d="M381 8H577L561 -77Q655 -91 720 -149T786 -318Q787 -460 659 -546T294 -632L290 -454Q405 -454 473 -424T542 -327Q544 -263 495 -234T338 -193L381 8Z" />
-<glyph unicode="&#xb9;" horiz-adv-x="896" d="M416 1024V1993H407L106 1800V2046L427 2252H680V1024H416Z" />
-<glyph unicode="&#xba;" horiz-adv-x="1331" d="M180 1436V1542Q180 1685 239 1795T408 1969T666 2032Q891 2032 1021 1895T1151 1542V1436Q1151 1294 1093 1185T927 1013T670 950Q443 950 312 1086T180 1436ZM425 1542V1436Q425 1313 486 1233T670 1152Q789 1152
-847 1232T906 1436V1542Q906 1662 847 1745T666 1828Q546 1828 486 1745T425 1542Z" />
-<glyph unicode="&#xbb;" horiz-adv-x="1688" d="M158 220L542 840H888V820L484 220H158ZM158 1448H484L888 848V828H542L158 1448ZM774 220L1158 840H1504V820L1100 220H774ZM774 1448H1100L1504 848V828H1158L774 1448Z" />
-<glyph unicode="&#xbc;" horiz-adv-x="2480" d="M353 0L1761 2048H2012L604 0H353ZM360 1216V2056H352L88 1886V2123L371 2304H613V1216H360ZM1417 182V384L1930 1088H2104V814H2002L1678 395V388H2407V182H1417ZM2007 0V243L2011 334V1088H2250V0H2007Z" />
-<glyph unicode="&#xbd;" horiz-adv-x="2589" d="M353 0L1761 2048H2012L604 0H353ZM360 1216V2056H352L88 1886V2123L371 2304H613V1216H360ZM1629 0V184L2067 533Q2146 597 2191 646T2236 757Q2236 825 2181 863T2044 902Q1961 902 1908 861T1854 744H1611Q1611
-909 1733 1006T2049 1104Q2245 1104 2361 1005T2478 761Q2478 684 2447 619T2348 489T2176 350L1997 218V208H2499V0H1629Z" />
-<glyph unicode="&#xbe;" horiz-adv-x="2702" d="M576 0L1984 2048H2235L827 0H576ZM1639 182V384L2152 1088H2326V814H2224L1900 395V388H2629V182H1639ZM2229 0V243L2233 334V1088H2472V0H2229ZM556 1200Q418 1200 314 1243T150 1362T90 1532H350Q352 1477 409
-1444T551 1410Q641 1410 700 1447T760 1542Q760 1601 699 1638T533 1675H416V1863H533Q620 1863 676 1899T733 1992Q733 2047 685 2083T554 2120Q477 2120 420 2086T361 1996H114Q116 2091 175 2164T334 2278T559 2320Q685 2320 780 2279T928 2167T982 2010Q982
-1919 918 1859T751 1784V1774Q885 1760 955 1693T1026 1525Q1026 1430 965 1357T798 1242T556 1200Z" />
-<glyph unicode="&#xbf;" horiz-adv-x="1532" d="M826 1543Q916 1543 981 1479T1046 1323Q1046 1233 981 1169T826 1105Q737 1105 672 1169T606 1323Q606 1384 636 1434T717 1513T826 1543ZM1000 919V893Q999 713 966 606T870 433T719 312Q659 274 610 229T533
-127T505 0Q505 -79 542 -137T641 -227T781 -259Q852 -259 915 -229T1021 -137T1067 18H1423Q1418 -172 1331 -299T1099 -491T779 -555Q588 -555 443 -488T216 -298T134 -6Q134 110 170 199T274 359T433 484Q515 534 566 588T641 714T667 893V919H1000Z" />
-<glyph unicode="&#xc0;" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803ZM887 2250L580 2707H935L1158 2250H887Z" />
-<glyph unicode="&#xc1;" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803ZM880 2250L1104 2707H1458L1151 2250H880Z" />
-<glyph unicode="&#xc2;" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803ZM816 2231H499V2246L878 2678H1161L1539 2246V2231H1222L1019 2487L816 2231Z" />
-<glyph unicode="&#xc3;" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803ZM725 2250L506 2255Q506 2433 598 2534T825 2635Q890 2635 938 2614T1026 2564T1106 2515T1192 2493Q1249 2493 1282 2536T1317
-2646L1532 2637Q1531 2458 1439 2360T1213 2260Q1141 2259 1092 2281T1007 2331T933 2380T846 2402Q793 2402 759 2360T725 2250Z" />
-<glyph unicode="&#xc4;" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803ZM720 2274Q641 2274 584 2330T526 2462Q526 2542 583 2596T720 2651Q801 2651 856 2597T912 2462Q912 2386 857 2330T720
-2274ZM1320 2274Q1241 2274 1184 2330T1126 2462Q1126 2542 1183 2596T1320 2651Q1401 2651 1456 2597T1512 2462Q1512 2386 1457 2330T1320 2274Z" />
-<glyph unicode="&#xc5;" horiz-adv-x="2038" d="M465 0H69L790 2048H1248L1970 0H1574L1027 1628H1011L465 0ZM478 803H1558V505H478V803ZM1019 2166Q929 2166 855 2208T736 2320T692 2477Q692 2562 736 2633T854 2745T1019 2787Q1110 2787 1184 2746T1302 2633T1346
-2477Q1346 2391 1302 2321T1184 2208T1019 2166ZM1019 2333Q1081 2333 1125 2375T1169 2477Q1169 2537 1125 2579T1019 2621Q958 2620 914 2579T870 2477Q870 2417 913 2375T1019 2333Z" />
-<glyph unicode="&#xc6;" horiz-adv-x="2863" d="M69 0L984 2048H2686V1737H1725V1181H2617V870H1725V311H2694V0H1354V1690H1209L477 0H69ZM527 505V803H1559V505H527Z" />
-<glyph unicode="&#xc7;" horiz-adv-x="2094" d="M1955 1357H1581Q1565 1449 1522 1520T1415 1642T1270 1717T1094 1743Q927 1743 798 1660T596 1416T523 1024Q523 789 596 629T798 387T1093 305Q1185 305 1265 329T1410 402T1519 520T1581 680L1955 678Q1934 532
-1865 404T1683 179T1421 27T1088 -28Q818 -28 606 97T272 458T150 1024Q150 1355 273 1590T608 1951T1088 2076Q1259 2076 1406 2028T1668 1888T1857 1662T1955 1357ZM983 2H1179L1163 -83Q1257 -97 1322 -155T1388 -324Q1389 -466 1261 -552T896 -638L892 -460Q1007
--460 1075 -430T1144 -333Q1146 -269 1097 -240T940 -199L983 2Z" />
-<glyph unicode="&#xc8;" horiz-adv-x="1711" d="M202 0V2048H1534V1737H573V1181H1465V870H573V311H1542V0H202ZM736 2250L429 2707H784L1007 2250H736Z" />
-<glyph unicode="&#xc9;" horiz-adv-x="1711" d="M202 0V2048H1534V1737H573V1181H1465V870H573V311H1542V0H202ZM730 2250L954 2707H1308L1001 2250H730Z" />
-<glyph unicode="&#xca;" horiz-adv-x="1711" d="M202 0V2048H1534V1737H573V1181H1465V870H573V311H1542V0H202ZM666 2231H349V2246L728 2678H1011L1389 2246V2231H1072L869 2487L666 2231Z" />
-<glyph unicode="&#xcb;" horiz-adv-x="1711" d="M202 0V2048H1534V1737H573V1181H1465V870H573V311H1542V0H202ZM570 2274Q491 2274 434 2330T376 2462Q376 2542 433 2596T570 2651Q651 2651 706 2597T762 2462Q762 2386 707 2330T570 2274ZM1170 2274Q1091 2274
-1034 2330T976 2462Q976 2542 1033 2596T1170 2651Q1251 2651 1306 2597T1362 2462Q1362 2386 1307 2330T1170 2274Z" />
-<glyph unicode="&#xcc;" horiz-adv-x="774" d="M573 2048V0H202V2048H573ZM256 2250L-51 2707H304L527 2250H256Z" />
-<glyph unicode="&#xcd;" horiz-adv-x="774" d="M573 2048V0H202V2048H573ZM249 2250L473 2707H827L520 2250H249Z" />
-<glyph unicode="&#xce;" horiz-adv-x="774" d="M573 2048V0H202V2048H573ZM185 2231H-132V2246L247 2678H530L908 2246V2231H591L388 2487L185 2231Z" />
-<glyph unicode="&#xcf;" horiz-adv-x="774" d="M573 2048V0H202V2048H573ZM89 2274Q10 2274 -47 2330T-105 2462Q-105 2542 -48 2596T89 2651Q170 2651 225 2597T281 2462Q281 2386 226 2330T89 2274ZM689 2274Q610 2274 553 2330T495 2462Q495 2542 552 2596T689
-2651Q770 2651 825 2597T881 2462Q881 2386 826 2330T689 2274Z" />
-<glyph unicode="&#xd0;" horiz-adv-x="2040" d="M896 0H202V2048H910Q1215 2048 1434 1926T1772 1574T1890 1026Q1890 706 1772 476T1431 123T896 0ZM573 321H878Q1092 321 1235 399T1450 634T1522 1026Q1522 1261 1450 1416T1238 1649T889 1727H573V321ZM-93
-958V1187H865V958H-93Z" />
-<glyph unicode="&#xd1;" horiz-adv-x="2086" d="M1885 2048V0H1555L590 1395H573V0H202V2048H534L1498 652H1516V2048H1885ZM750 2235L531 2240Q531 2418 623 2519T850 2620Q915 2620 963 2599T1051 2549T1131 2500T1217 2478Q1274 2478 1307 2521T1342 2631L1557
-2622Q1556 2443 1464 2345T1238 2245Q1166 2244 1117 2266T1032 2316T958 2365T871 2387Q818 2387 784 2345T750 2235Z" />
-<glyph unicode="&#xd2;" horiz-adv-x="2182" d="M2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091
-1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024ZM959 2250L652 2707H1007L1230 2250H959Z" />
-<glyph unicode="&#xd3;" horiz-adv-x="2182" d="M2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091
-1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024ZM952 2250L1176 2707H1530L1223 2250H952Z" />
-<glyph unicode="&#xd4;" horiz-adv-x="2182" d="M2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091
-1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024ZM888 2231H571V2246L950 2678H1233L1611 2246V2231H1294L1091 2487L888 2231Z" />
-<glyph unicode="&#xd5;" horiz-adv-x="2182" d="M2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091
-1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024ZM797 2250L578 2255Q578 2433 670 2534T897 2635Q962 2635 1010 2614T1098 2564T1178 2515T1264 2493Q1321 2493 1354 2536T1389 2646L1604
-2637Q1603 2458 1511 2360T1285 2260Q1213 2259 1164 2281T1079 2331T1005 2380T918 2402Q865 2402 831 2360T797 2250Z" />
-<glyph unicode="&#xd6;" horiz-adv-x="2182" d="M2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659 1024Q1659 1257 1587 1417T1386 1660T1091
-1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024ZM792 2274Q713 2274 656 2330T598 2462Q598 2542 655 2596T792 2651Q873 2651 928 2597T984 2462Q984 2386 929 2330T792 2274ZM1392 2274Q1313
-2274 1256 2330T1198 2462Q1198 2542 1255 2596T1392 2651Q1473 2651 1528 2597T1584 2462Q1584 2386 1529 2330T1392 2274Z" />
-<glyph unicode="&#xd7;" horiz-adv-x="1894" d="M1421 114L248 1285L474 1512L1646 339L1421 114ZM474 114L248 339L1421 1512L1646 1285L474 114Z" />
-<glyph unicode="&#xd8;" horiz-adv-x="2182" d="M453 -94L272 30L1725 2138L1906 2014L453 -94ZM2032 1024Q2032 693 1909 458T1573 97T1091 -28Q822 -28 610 97T274 458T150 1024Q150 1355 273 1590T609 1951T1091 2076Q1360 2076 1572 1951T1908 1591T2032 1024ZM1659
-1024Q1659 1257 1587 1417T1386 1660T1091 1743Q924 1743 796 1661T596 1418T523 1024Q523 791 595 631T796 388T1091 305Q1258 305 1386 387T1586 630T1659 1024Z" />
-<glyph unicode="&#xd9;" horiz-adv-x="2066" d="M1493 2048H1864V710Q1864 490 1761 323T1470 63T1033 -31Q782 -31 595 62T305 323T202 710V2048H573V741Q573 613 629 513T789 357T1033 300Q1174 300 1277 356T1437 513T1493 741V2048ZM900 2250L593 2707H948L1171
-2250H900Z" />
-<glyph unicode="&#xda;" horiz-adv-x="2066" d="M1493 2048H1864V710Q1864 490 1761 323T1470 63T1033 -31Q782 -31 595 62T305 323T202 710V2048H573V741Q573 613 629 513T789 357T1033 300Q1174 300 1277 356T1437 513T1493 741V2048ZM894 2250L1118 2707H1472L1165
-2250H894Z" />
-<glyph unicode="&#xdb;" horiz-adv-x="2066" d="M1493 2048H1864V710Q1864 490 1761 323T1470 63T1033 -31Q782 -31 595 62T305 323T202 710V2048H573V741Q573 613 629 513T789 357T1033 300Q1174 300 1277 356T1437 513T1493 741V2048ZM830 2231H513V2246L892
-2678H1175L1553 2246V2231H1236L1033 2487L830 2231Z" />
-<glyph unicode="&#xdc;" horiz-adv-x="2066" d="M1493 2048H1864V710Q1864 490 1761 323T1470 63T1033 -31Q782 -31 595 62T305 323T202 710V2048H573V741Q573 613 629 513T789 357T1033 300Q1174 300 1277 356T1437 513T1493 741V2048ZM734 2274Q655 2274 598
-2330T540 2462Q540 2542 597 2596T734 2651Q815 2651 870 2597T926 2462Q926 2386 871 2330T734 2274ZM1334 2274Q1255 2274 1198 2330T1140 2462Q1140 2542 1197 2596T1334 2651Q1415 2651 1470 2597T1526 2462Q1526 2386 1471 2330T1334 2274Z" />
-<glyph unicode="&#xdd;" horiz-adv-x="1984" d="M62 2048H481L982 1142H1002L1503 2048H1922L1177 764V0H807V764L62 2048ZM853 2250L1077 2707H1431L1124 2250H853Z" />
-<glyph unicode="&#xde;" horiz-adv-x="1851" d="M194 2048H565V0H194V2048ZM366 1656H897Q1134 1656 1298 1575T1547 1355T1632 1042Q1632 868 1547 730T1298 511T897 430H366V726H897Q1026 726 1110 766T1236 876T1278 1038Q1278 1130 1237 1203T1111 1317T897
-1359H366V1656Z" />
-<glyph unicode="&#xdf;" horiz-adv-x="1818" d="M202 0V1513Q202 1688 285 1814T518 2008T869 2076Q1058 2076 1214 2013T1462 1829T1554 1530Q1554 1366 1472 1251T1250 1094V1072Q1448 1057 1568 918T1687 564Q1687 390 1607 264T1383 69T1050 0H795V305H1006Q1102
-305 1173 341T1283 441T1322 591Q1321 724 1226 807T969 890H803V1186H917Q999 1186 1059 1226T1152 1333T1186 1477Q1186 1600 1105 1682T879 1765Q740 1765 652 1684T564 1471V0H202Z" />
-<glyph unicode="&#xe0;" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296
-1366T521 1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463
-337 536 286T722 235ZM955 1738L648 2195H1003L1226 1738H955Z" />
-<glyph unicode="&#xe1;" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296
-1366T521 1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463
-337 536 286T722 235ZM692 1738L916 2195H1270L963 1738H692Z" />
-<glyph unicode="&#xe2;" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296
-1366T521 1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463
-337 536 286T722 235ZM628 1719H311V1734L690 2166H973L1351 1734V1719H1034L831 1975L628 1719Z" />
-<glyph unicode="&#xe3;" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296
-1366T521 1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463
-337 536 286T722 235ZM537 1723L318 1728Q318 1906 410 2007T637 2108Q702 2108 750 2087T838 2037T918 1988T1004 1966Q1061 1966 1094 2009T1129 2119L1344 2110Q1343 1931 1251 1833T1025 1733Q953 1732 904 1754T819 1804T745 1853T658 1875Q605 1875 571 1833T537
-1723Z" />
-<glyph unicode="&#xe4;" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296
-1366T521 1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463
-337 536 286T722 235ZM532 1762Q453 1762 396 1818T338 1950Q338 2030 395 2084T532 2139Q613 2139 668 2085T724 1950Q724 1874 669 1818T532 1762ZM1132 1762Q1053 1762 996 1818T938 1950Q938 2030 995 2084T1132 2139Q1213 2139 1268 2085T1324 1950Q1324 1874
-1269 1818T1132 1762Z" />
-<glyph unicode="&#xe5;" horiz-adv-x="1618" d="M628 -31Q482 -31 366 21T182 177T114 431Q114 561 162 646T293 782T480 859T697 898Q832 912 916 923T1038 959T1077 1036V1042Q1077 1155 1010 1217T817 1279Q684 1279 607 1221T502 1084L164 1132Q204 1272 296
-1366T521 1508T815 1556Q926 1556 1036 1530T1237 1445T1383 1283T1439 1028V0H1091V211H1079Q1046 147 987 92T838 3T628 -31ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646 601 621T500 552T463 437Q463
-337 536 286T722 235ZM831 1703Q734 1703 655 1747T529 1867T482 2035Q482 2128 528 2205T654 2328T831 2374Q928 2374 1007 2328T1132 2205T1178 2035Q1178 1942 1132 1867T1007 1748T831 1703ZM831 1873Q904 1873 951 1922T999 2035Q999 2101 952 2152T831 2202Q759
-2202 711 2152T663 2035Q663 1972 711 1922T831 1873Z" />
-<glyph unicode="&#xe6;" horiz-adv-x="2583" d="M1798 -30Q1571 -30 1408 73T1192 359L1198 1216Q1286 1385 1427 1470T1762 1556Q1903 1556 2029 1511T2254 1370T2407 1129T2463 780V669H1225V913H2116Q2115 1017 2071 1098T1949 1227T1766 1274Q1655 1274 1571
-1221T1441 1080T1394 891V678Q1394 544 1443 449T1580 303T1786 252Q1864 252 1928 274T2039 341T2110 450L2449 412Q2417 278 2327 179T2102 25T1798 -30ZM722 235Q831 235 911 278T1034 394T1078 551V732Q1061 718 1021 706T930 685T831 669T746 657Q665 646
-601 621T500 552T463 437Q463 337 536 286T722 235ZM644 -31Q490 -31 370 21T182 177T114 431Q114 561 162 646T294 782T481 860T697 898Q833 912 916 924T1038 960T1077 1036V1042Q1077 1155 1010 1217T817 1279Q685 1279 607 1221T502 1084L164 1132Q204 1272
-296 1366T521 1508T820 1556Q888 1556 960 1544T1101 1500T1225 1414T1310 1273L1246 261H1221Q1184 175 1098 109T894 6T644 -31Z" />
-<glyph unicode="&#xe7;" horiz-adv-x="1626" d="M856 -30Q626 -30 462 71T209 350T120 762Q120 996 210 1175T464 1455T854 1556Q1041 1556 1185 1488T1416 1295T1514 1004H1168Q1147 1115 1069 1189T859 1264Q748 1264 664 1205T534 1034T487 768Q487 611 533
-499T662 326T859 265Q939 265 1002 295T1109 384T1168 525H1514Q1501 362 1418 237T1192 41T856 -30ZM752 8H948L932 -77Q1026 -91 1091 -149T1157 -318Q1158 -460 1030 -546T665 -632L661 -454Q776 -454 844 -424T913 -327Q915 -263 866 -234T709 -193L752 8Z"
-/>
-<glyph unicode="&#xe8;" horiz-adv-x="1669" d="M866 -30Q635 -30 468 66T210 340T120 759Q120 996 210 1175T464 1455T847 1556Q989 1556 1115 1511T1339 1370T1493 1129T1549 780V669H290V913H1202Q1201 1017 1157 1098T1035 1227T852 1274Q741 1274 657 1221T527
-1081T479 891V678Q479 544 528 449T665 303T871 252Q950 252 1014 274T1125 341T1196 450L1534 412Q1502 278 1413 179T1184 25T866 -30ZM960 1738L653 2195H1008L1231 1738H960Z" />
-<glyph unicode="&#xe9;" horiz-adv-x="1669" d="M866 -30Q635 -30 468 66T210 340T120 759Q120 996 210 1175T464 1455T847 1556Q989 1556 1115 1511T1339 1370T1493 1129T1549 780V669H290V913H1202Q1201 1017 1157 1098T1035 1227T852 1274Q741 1274 657 1221T527
-1081T479 891V678Q479 544 528 449T665 303T871 252Q950 252 1014 274T1125 341T1196 450L1534 412Q1502 278 1413 179T1184 25T866 -30ZM698 1738L922 2195H1276L969 1738H698Z" />
-<glyph unicode="&#xea;" horiz-adv-x="1669" d="M866 -30Q635 -30 468 66T210 340T120 759Q120 996 210 1175T464 1455T847 1556Q989 1556 1115 1511T1339 1370T1493 1129T1549 780V669H290V913H1202Q1201 1017 1157 1098T1035 1227T852 1274Q741 1274 657 1221T527
-1081T479 891V678Q479 544 528 449T665 303T871 252Q950 252 1014 274T1125 341T1196 450L1534 412Q1502 278 1413 179T1184 25T866 -30ZM634 1719H317V1734L696 2166H979L1357 1734V1719H1040L837 1975L634 1719Z" />
-<glyph unicode="&#xeb;" horiz-adv-x="1669" d="M866 -30Q635 -30 468 66T210 340T120 759Q120 996 210 1175T464 1455T847 1556Q989 1556 1115 1511T1339 1370T1493 1129T1549 780V669H290V913H1202Q1201 1017 1157 1098T1035 1227T852 1274Q741 1274 657 1221T527
-1081T479 891V678Q479 544 528 449T665 303T871 252Q950 252 1014 274T1125 341T1196 450L1534 412Q1502 278 1413 179T1184 25T866 -30ZM538 1762Q459 1762 402 1818T344 1950Q344 2030 401 2084T538 2139Q619 2139 674 2085T730 1950Q730 1874 675 1818T538 1762ZM1138
-1762Q1059 1762 1002 1818T944 1950Q944 2030 1001 2084T1138 2139Q1219 2139 1274 2085T1330 1950Q1330 1874 1275 1818T1138 1762Z" />
-<glyph unicode="&#xec;" horiz-adv-x="734" d="M186 0V1536H548V0H186ZM236 1738L-71 2195H284L507 1738H236Z" />
-<glyph unicode="&#xed;" horiz-adv-x="734" d="M186 0V1536H548V0H186ZM229 1738L453 2195H807L500 1738H229Z" />
-<glyph unicode="&#xee;" horiz-adv-x="734" d="M186 0V1536H548V0H186ZM165 1719H-152V1734L227 2166H510L888 1734V1719H571L368 1975L165 1719Z" />
-<glyph unicode="&#xef;" horiz-adv-x="734" d="M186 0V1536H548V0H186ZM69 1762Q-10 1762 -67 1818T-125 1950Q-125 2030 -68 2084T69 2139Q150 2139 205 2085T261 1950Q261 1874 206 1818T69 1762ZM669 1762Q590 1762 533 1818T475 1950Q475 2030 532 2084T669
-2139Q750 2139 805 2085T861 1950Q861 1874 806 1818T669 1762Z" />
-<glyph unicode="&#xf0;" horiz-adv-x="1618" d="M1294 1902L449 1478L349 1671L1194 2094L1294 1902ZM808 -32Q599 -32 445 63T207 324T123 704Q123 916 199 1073T400 1322T666 1429Q754 1435 829 1416T965 1359T1064 1268H1084Q1039 1358 981 1444T843 1614T651
-1779T387 1942L547 2176Q719 2089 887 1954T1192 1639T1412 1237T1494 752Q1494 504 1409 329T1169 61T808 -32ZM819 260Q932 260 1010 313T1129 464T1170 699Q1170 832 1131 935T1014 1096T819 1154Q708 1154 631 1100T514 946T473 706Q473 571 511 471T627 316T819
-260Z" />
-<glyph unicode="&#xf1;" horiz-adv-x="1717" d="M548 900V0H186V1536H532V1275H550Q603 1404 719 1480T1008 1556Q1167 1556 1285 1488T1469 1291T1534 978V0H1172V922Q1172 1076 1093 1163T873 1250Q778 1250 705 1209T590 1089T548 900ZM565 1723L346 1728Q346
-1906 438 2007T665 2108Q730 2108 778 2087T866 2037T946 1988T1032 1966Q1089 1966 1122 2009T1157 2119L1372 2110Q1371 1931 1279 1833T1053 1733Q981 1732 932 1754T847 1804T773 1853T686 1875Q633 1875 599 1833T565 1723Z" />
-<glyph unicode="&#xf2;" horiz-adv-x="1712" d="M856 -30Q631 -30 466 69T211 346T120 762Q120 1000 210 1179T466 1457T856 1556Q1081 1556 1246 1457T1501 1179T1592 762Q1592 524 1502 346T1246 69T856 -30ZM858 260Q980 260 1062 327T1184 509T1225 763Q1225
-904 1185 1018T1062 1201T858 1269Q733 1269 651 1201T528 1019T487 763Q487 623 527 509T650 328T858 260ZM724 1738L417 2195H772L995 1738H724Z" />
-<glyph unicode="&#xf3;" horiz-adv-x="1712" d="M856 -30Q631 -30 466 69T211 346T120 762Q120 1000 210 1179T466 1457T856 1556Q1081 1556 1246 1457T1501 1179T1592 762Q1592 524 1502 346T1246 69T856 -30ZM858 260Q980 260 1062 327T1184 509T1225 763Q1225
-904 1185 1018T1062 1201T858 1269Q733 1269 651 1201T528 1019T487 763Q487 623 527 509T650 328T858 260ZM717 1738L941 2195H1295L988 1738H717Z" />
-<glyph unicode="&#xf4;" horiz-adv-x="1712" d="M856 -30Q631 -30 466 69T211 346T120 762Q120 1000 210 1179T466 1457T856 1556Q1081 1556 1246 1457T1501 1179T1592 762Q1592 524 1502 346T1246 69T856 -30ZM858 260Q980 260 1062 327T1184 509T1225 763Q1225
-904 1185 1018T1062 1201T858 1269Q733 1269 651 1201T528 1019T487 763Q487 623 527 509T650 328T858 260ZM653 1719H336V1734L715 2166H998L1376 1734V1719H1059L856 1975L653 1719Z" />
-<glyph unicode="&#xf5;" horiz-adv-x="1712" d="M856 -30Q631 -30 466 69T211 346T120 762Q120 1000 210 1179T466 1457T856 1556Q1081 1556 1246 1457T1501 1179T1592 762Q1592 524 1502 346T1246 69T856 -30ZM858 260Q980 260 1062 327T1184 509T1225 763Q1225
-904 1185 1018T1062 1201T858 1269Q733 1269 651 1201T528 1019T487 763Q487 623 527 509T650 328T858 260ZM562 1723L343 1728Q343 1906 435 2007T662 2108Q727 2108 775 2087T863 2037T943 1988T1029 1966Q1086 1966 1119 2009T1154 2119L1369 2110Q1368 1931
-1276 1833T1050 1733Q978 1732 929 1754T844 1804T770 1853T683 1875Q630 1875 596 1833T562 1723Z" />
-<glyph unicode="&#xf6;" horiz-adv-x="1712" d="M856 -30Q631 -30 466 69T211 346T120 762Q120 1000 210 1179T466 1457T856 1556Q1081 1556 1246 1457T1501 1179T1592 762Q1592 524 1502 346T1246 69T856 -30ZM858 260Q980 260 1062 327T1184 509T1225 763Q1225
-904 1185 1018T1062 1201T858 1269Q733 1269 651 1201T528 1019T487 763Q487 623 527 509T650 328T858 260ZM557 1762Q478 1762 421 1818T363 1950Q363 2030 420 2084T557 2139Q638 2139 693 2085T749 1950Q749 1874 694 1818T557 1762ZM1157 1762Q1078 1762 1021
-1818T963 1950Q963 2030 1020 2084T1157 2139Q1238 2139 1293 2085T1349 1950Q1349 1874 1294 1818T1157 1762Z" />
-<glyph unicode="&#xf7;" horiz-adv-x="1894" d="M1627 963V662H267V963H1627ZM947 50Q855 50 791 114T726 271Q726 361 790 425T947 489Q1037 489 1101 425T1165 271Q1165 179 1101 115T947 50ZM947 1137Q886 1137 836 1166T756 1246T726 1358Q726 1448 790 1512T947
-1576Q1037 1576 1101 1512T1165 1358Q1165 1267 1101 1202T947 1137Z" />
-<glyph unicode="&#xf8;" horiz-adv-x="1712" d="M399 -94L218 30L1311 1618L1494 1494L399 -94ZM856 -32Q631 -32 466 67T211 344T120 760Q120 998 210 1177T466 1455T856 1554Q1081 1554 1246 1455T1501 1177T1592 760Q1592 523 1502 345T1246 67T856 -32ZM858
-258Q980 258 1062 325T1184 507T1225 762Q1225 902 1185 1016T1062 1199T858 1267Q733 1267 651 1199T528 1017T487 762Q487 621 527 507T650 326T858 258Z" />
-<glyph unicode="&#xf9;" horiz-adv-x="1713" d="M1165 646V1536H1527V0H1176V273H1160Q1108 144 990 62T698 -20Q547 -20 432 47T251 244T186 558V1536H548V614Q548 468 628 382T838 296Q918 296 993 335T1116 451T1165 646ZM726 1738L419 2195H774L997 1738H726Z" />
-<glyph unicode="&#xfa;" horiz-adv-x="1713" d="M1165 646V1536H1527V0H1176V273H1160Q1108 144 990 62T698 -20Q547 -20 432 47T251 244T186 558V1536H548V614Q548 468 628 382T838 296Q918 296 993 335T1116 451T1165 646ZM720 1738L944 2195H1298L991 1738H720Z" />
-<glyph unicode="&#xfb;" horiz-adv-x="1713" d="M1165 646V1536H1527V0H1176V273H1160Q1108 144 990 62T698 -20Q547 -20 432 47T251 244T186 558V1536H548V614Q548 468 628 382T838 296Q918 296 993 335T1116 451T1165 646ZM655 1719H338V1734L717 2166H1000L1378
-1734V1719H1061L858 1975L655 1719Z" />
-<glyph unicode="&#xfc;" horiz-adv-x="1713" d="M1165 646V1536H1527V0H1176V273H1160Q1108 144 990 62T698 -20Q547 -20 432 47T251 244T186 558V1536H548V614Q548 468 628 382T838 296Q918 296 993 335T1116 451T1165 646ZM559 1762Q480 1762 423 1818T365 1950Q365
-2030 422 2084T559 2139Q640 2139 695 2085T751 1950Q751 1874 696 1818T559 1762ZM1159 1762Q1080 1762 1023 1818T965 1950Q965 2030 1022 2084T1159 2139Q1240 2139 1295 2085T1351 1950Q1351 1874 1296 1818T1159 1762Z" />
-<glyph unicode="&#xfd;" horiz-adv-x="1624" d="M411 -573Q337 -573 275 -562T175 -534L259 -253Q377 -287 457 -265T590 -115L621 -32L64 1536H448L802 376H818L1173 1536L1560 1534L943 -186Q900 -306 829 -393T656 -526T411 -573ZM671 1738L895 2195H1249L942
-1738H671Z" />
-<glyph unicode="&#xfe;" horiz-adv-x="1722" d="M186 2048H548V1228L429 762L548 298V-576H186V2048ZM402 1337H566Q593 1386 641 1436T769 1521T966 1556Q1142 1556 1285 1466T1514 1199T1599 766Q1600 512 1516 335T1289 66T966 -27Q852 -27 773 6T644 90T566
-191H402V1337ZM1251 768Q1252 914 1211 1024T1086 1197T877 1259Q752 1259 673 1195T556 1021T518 768Q519 627 556 514T672 336T877 270Q1001 270 1084 333T1209 508T1251 768Z" />
-<glyph unicode="&#xff;" horiz-adv-x="1624" d="M411 -573Q337 -573 275 -562T175 -534L259 -253Q377 -287 457 -265T590 -115L621 -32L64 1536H448L802 376H818L1173 1536L1560 1534L943 -186Q900 -306 829 -393T656 -526T411 -573ZM510 1762Q431 1762 374 1818T316
-1950Q316 2030 373 2084T510 2139Q591 2139 646 2085T702 1950Q702 1874 647 1818T510 1762ZM1110 1762Q1031 1762 974 1818T916 1950Q916 2030 973 2084T1110 2139Q1191 2139 1246 2085T1302 1950Q1302 1874 1247 1818T1110 1762Z" />
-<glyph unicode="&#x2013;" horiz-adv-x="1408" d="M1408 1002V704H0V1002H1408Z" />
-<glyph unicode="&#x2014;" horiz-adv-x="2816" d="M2816 1002V704H0V1002H2816Z" />
-<glyph unicode="&#x2018;" horiz-adv-x="642" d="M149 1408V1612Q149 1700 182 1795T274 1976T406 2121L574 2012Q515 1923 476 1827T437 1614V1408H149Z" />
-<glyph unicode="&#x2019;" horiz-adv-x="602" d="M493 2048V1844Q493 1756 460 1662T368 1482T236 1335L68 1444Q123 1527 164 1623T205 1842V2048H493Z" />
-<glyph unicode="&#x201a;" horiz-adv-x="642" d="M491 256V52Q491 -36 458 -130T366 -310T234 -457L66 -348Q121 -265 162 -169T203 50V256H491Z" />
-<glyph unicode="&#x201c;" horiz-adv-x="1149" d="M149 1408V1612Q149 1700 182 1795T274 1976T406 2121L574 2012Q515 1923 476 1827T437 1614V1408H149ZM656 1408V1612Q656 1700 689 1795T781 1976T913 2121L1081 2012Q1022 1923 983 1827T944 1614V1408H656Z" />
-<glyph unicode="&#x201d;" horiz-adv-x="1147" d="M493 2048V1844Q493 1756 460 1662T368 1482T236 1335L68 1444Q123 1527 164 1623T205 1842V2048H493ZM999 2048V1844Q999 1756 966 1662T874 1482T742 1335L574 1444Q629 1527 670 1623T711 1842V2048H999Z" />
-<glyph unicode="&#x201e;" horiz-adv-x="1147" d="M490 256V52Q490 -36 457 -130T365 -310T233 -457L65 -348Q120 -265 161 -169T202 50V256H490ZM996 256V52Q996 -36 963 -130T871 -310T739 -457L571 -348Q626 -265 667 -169T708 50V256H996Z" />
-<glyph unicode="&#x2022;" horiz-adv-x="1398" d="M699 376Q578 376 478 435T319 595T259 816Q259 938 318 1037T478 1196T699 1256Q821 1256 920 1197T1079 1038T1139 816Q1139 695 1080 595T921 436T699 376Z" />
-<glyph unicode="&#x2039;" horiz-adv-x="1072" d="M902 220H576L172 820V840H518L902 220ZM902 1448L518 828H172V848L576 1448H902Z" />
-<glyph unicode="&#x203a;" horiz-adv-x="1072" d="M158 220L542 840H888V820L484 220H158ZM158 1448H484L888 848V828H542L158 1448Z" />
-</font>
-</defs>
-</svg>

BIN
frontend/dist/fonts/inter-v3-latin-600.ttf


BIN
frontend/dist/fonts/inter-v3-latin-600.woff


BIN
frontend/dist/fonts/inter-v3-latin-600.woff2


BIN
frontend/dist/fonts/inter-v3-latin-800.eot


+ 0 - 350
frontend/dist/fonts/inter-v3-latin-800.svg

@@ -1,350 +0,0 @@
-<?xml version="1.0" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg xmlns="http://www.w3.org/2000/svg">
-<defs >
-<font id="Inter" horiz-adv-x="1838" ><font-face
-    font-family="Inter ExtraBold"
-    units-per-em="2816"
-    panose-1="2 11 5 2 3 0 0 0 0 4"
-    ascent="2728"
-    descent="-680"
-    alphabetic="0" />
-<glyph unicode=" " horiz-adv-x="606" />
-<glyph unicode="!" horiz-adv-x="957" d="M730 2048L684 627H273L227 2048H730ZM478 -29Q371 -29 295 47T218 231Q218 337 294 412T478 488Q581 488 659 413T738 231Q738 159 702 100T607 6T478 -29Z" />
-<glyph unicode="&quot;" horiz-adv-x="1094" d="M452 2048V1245H120V2048H452ZM970 2048V1245H638V2048H970Z" />
-<glyph unicode="#" horiz-adv-x="1846" d="M935 0L1271 2048H1631L1295 0H935ZM22 488L83 848H1707L1646 488H22ZM215 0L551 2048H911L575 0H215ZM139 1200L200 1560H1824L1763 1200H139Z" />
-<glyph unicode="$" horiz-adv-x="1860" d="M806 -256L838 2304H1098L1066 -256H806ZM1262 1434Q1252 1544 1174 1605T950 1667Q854 1667 790 1642T694 1574T661 1474Q659 1428 679 1393T739 1332T835 1285T961 1250L1137 1210Q1289 1177 1405 1122T1601 991T1722
-816T1764 594Q1763 400 1666 261T1388 48T950 -26Q693 -26 502 51T205 285T96 682H570Q576 583 623 517T752 417T944 383Q1044 383 1114 410T1223 485T1262 595Q1261 653 1226 694T1119 766T934 823L720 873Q454 934 301 1070T149 1440Q148 1630 251 1773T536 1996T951
-2076Q1189 2076 1364 1996T1637 1770T1736 1434H1262Z" />
-<glyph unicode="%" horiz-adv-x="2464" d="M1298 384V492Q1298 618 1352 725T1511 897T1765 962Q1918 962 2023 898T2181 727T2234 492V384Q2235 258 2181 151T2022 -21T1765 -86Q1613 -86 1510 -21T1352 151T1298 384ZM1617 492V384Q1618 320 1649 261T1765 202Q1852
-202 1883 259T1912 384V492Q1914 559 1885 618T1765 678Q1681 678 1650 619T1617 492ZM236 1556V1664Q237 1792 292 1898T451 2069T703 2134Q856 2134 961 2070T1120 1899T1173 1664V1556Q1174 1428 1120 1322T960 1153T703 1090Q552 1090 448 1154T291 1324T236
-1556ZM558 1664V1556Q560 1489 590 1432T703 1374Q791 1374 821 1431T850 1556V1664Q852 1731 823 1790T703 1850Q619 1850 590 1789T558 1664ZM341 0L1749 2048H2022L614 0H341Z" />
-<glyph unicode="&amp;" horiz-adv-x="1931" d="M799 -31Q583 -31 427 46T186 253T102 544Q102 662 148 752T277 919T470 1070L900 1345Q973 1391 1003 1442T1034 1544Q1034 1599 992 1644T866 1688Q813 1688 774 1666T714 1607T692 1523Q692 1462 727 1398T821
-1262T954 1103L1935 0H1437L616 891Q546 969 469 1066T339 1278T286 1530Q286 1683 358 1807T560 2004T865 2078Q1033 2078 1155 2009T1343 1826T1409 1576Q1409 1432 1338 1312T1137 1098L716 797Q649 749 611 695T573 576Q573 510 604 461T694 384T830 356Q934
-356 1038 405T1228 545T1366 760T1418 1034H1812Q1812 850 1773 693T1657 411T1465 204Q1429 184 1395 166T1325 128Q1207 43 1070 6T799 -31Z" />
-<glyph unicode="&apos;" horiz-adv-x="576" d="M452 2048V1245H120V2048H452Z" />
-<glyph unicode="(" horiz-adv-x="1196" d="M252 806Q252 1177 352 1488T664 2067H1138Q1058 1968 989 1826T870 1515T791 1165T762 806Q762 570 811 330T945 -116T1138 -454H664Q452 -187 352 124T252 806Z" />
-<glyph unicode=")" horiz-adv-x="1196" d="M944 806Q944 436 844 125T532 -454H58Q139 -355 207 -213T326 98T405 448T434 806Q434 1043 385 1283T251 1729T58 2067H532Q744 1800 844 1489T944 806Z" />
-<glyph unicode="*" horiz-adv-x="1651" d="M678 896L707 1266L399 1055L252 1313L586 1472L252 1631L399 1889L707 1678L678 2048H973L944 1678L1252 1889L1399 1631L1065 1472L1399 1313L1252 1055L944 1266L973 896H678Z" />
-<glyph unicode="+" horiz-adv-x="1933" d="M765 102V1550H1168V102H765ZM242 624V1027H1690V624H242Z" />
-<glyph unicode="," horiz-adv-x="874" d="M654 280L645 169Q633 31 597 -100T519 -337T454 -500H146Q162 -442 189 -337T241 -100T268 168L270 280H654Z" />
-<glyph unicode="-" horiz-adv-x="1328" d="M1138 1017V640H190V1017H1138Z" />
-<glyph unicode="." horiz-adv-x="859" d="M430 -29Q322 -29 246 47T170 231Q169 337 245 412T430 488Q532 488 610 413T690 231Q689 159 653 100T558 6T430 -29Z" />
-<glyph unicode="/" horiz-adv-x="1129" d="M1103 2144L443 -308H26L686 2144H1103Z" />
-<glyph unicode="0" horiz-adv-x="1997" d="M998 -50Q730 -50 536 77T237 444T133 1021Q134 1358 238 1593T536 1952T998 2076Q1266 2076 1460 1953T1760 1593T1864 1021Q1864 683 1759 443T1461 77T998 -50ZM998 355Q1158 355 1257 518T1355 1021Q1355 1243 1310
-1387T1185 1603T998 1674Q839 1674 741 1514T642 1021Q641 796 685 648T811 428T998 355Z" />
-<glyph unicode="1" horiz-adv-x="1401" d="M1098 2048V0H604V1586H592L134 1306V1734L639 2048H1098Z" />
-<glyph unicode="2" horiz-adv-x="1797" d="M151 0V356L898 1010Q982 1086 1041 1149T1131 1277T1162 1418Q1162 1503 1125 1563T1023 1657T874 1690Q789 1690 725 1656T626 1556T590 1398H121Q121 1607 215 1759T479 1993T876 2076Q1109 2076 1281 1998T1547 1781T1642
-1457Q1642 1340 1595 1225T1427 971T1082 638L837 412V399H1667V0H151Z" />
-<glyph unicode="3" horiz-adv-x="1878" d="M930 -28Q696 -28 516 52T231 275T125 602H623Q626 535 666 484T775 403T932 374Q1020 374 1087 405T1193 492T1230 620Q1231 692 1189 748T1071 835T890 866H691V1218H890Q981 1218 1050 1248T1159 1332T1197 1458Q1198
-1527 1165 1579T1072 1661T934 1690Q852 1690 786 1661T680 1579T639 1456H166Q168 1639 268 1778T540 1997T934 2076Q1154 2076 1321 1999T1581 1790T1674 1490Q1675 1317 1562 1205T1270 1067V1051Q1508 1023 1629 897T1750 582Q1750 404 1646 267T1356 51T930
--28Z" />
-<glyph unicode="4" horiz-adv-x="1942" d="M126 340V726L965 2048H1308V1524H1109L618 746V730H1833V340H126ZM1115 0V458L1125 627V2048H1588V0H1115Z" />
-<glyph unicode="5" horiz-adv-x="1850" d="M935 -28Q711 -28 537 53T263 276T158 602H638Q644 489 730 421T935 352Q1028 352 1100 393T1213 508T1253 680Q1254 779 1213 853T1099 968T932 1010Q843 1011 764 971T643 862L206 944L295 2048H1609V1649H702L655
-1166H667Q718 1251 831 1306T1090 1362Q1274 1362 1418 1277T1646 1040T1730 694Q1730 483 1631 320T1353 65T935 -28Z" />
-<glyph unicode="6" horiz-adv-x="1894" d="M972 -28Q804 -28 651 26T378 200T189 514T119 989Q120 1245 181 1447T355 1792T627 2008T982 2083Q1197 2083 1361 2000T1625 1774T1742 1456H1255Q1233 1559 1159 1610T982 1662Q794 1662 702 1498T607 1055H620Q662
-1147 741 1213T922 1314T1140 1350Q1324 1350 1465 1265T1686 1033T1765 695Q1766 481 1665 318T1386 63T972 -28ZM969 352Q1060 352 1132 395T1245 512T1285 679Q1286 773 1246 846T1134 962T970 1005Q903 1005 845 980T745 911T678 807T653 678Q654 587 695 513T807
-396T969 352Z" />
-<glyph unicode="7" horiz-adv-x="1698" d="M238 0L1064 1636V1649H98V2048H1576V1646L747 0H238Z" />
-<glyph unicode="8" horiz-adv-x="1902" d="M951 -28Q712 -28 525 48T231 256T124 554Q124 681 185 787T350 963T584 1054V1070Q415 1101 308 1227T200 1522Q200 1682 297 1807T564 2004T951 2076Q1168 2076 1337 2004T1604 1807T1702 1522Q1702 1352 1594 1227T1318
-1070V1054Q1446 1034 1550 964T1716 787T1778 554Q1778 388 1671 257T1378 49T951 -28ZM951 323Q1042 323 1111 357T1219 454T1259 594Q1259 674 1219 736T1110 833T951 869Q863 869 793 834T683 736T643 594Q643 516 682 455T791 358T951 323ZM951 1218Q1029 1218
-1089 1250T1184 1339T1219 1468Q1219 1540 1185 1595T1090 1681T951 1713Q873 1713 812 1682T717 1595T683 1468Q683 1396 717 1340T813 1251T951 1218Z" />
-<glyph unicode="9" horiz-adv-x="1894" d="M922 2083Q1090 2083 1243 2029T1516 1855T1705 1541T1774 1066Q1774 810 1713 608T1539 263T1267 47T912 -28Q696 -28 532 55T269 281T151 599H638Q661 497 735 446T912 394Q1100 394 1193 557T1286 1000H1274Q1231
-908 1153 842T971 741T754 706Q570 706 429 790T208 1022T129 1360Q128 1574 228 1737T507 1992T922 2083ZM925 1703Q833 1703 762 1660T649 1543T609 1376Q608 1283 648 1210T760 1093T923 1050Q991 1050 1048 1075T1149 1145T1216 1248T1241 1377Q1240 1468 1199
-1542T1087 1659T925 1703Z" />
-<glyph unicode=":" horiz-adv-x="859" d="M430 -29Q322 -29 246 47T170 231Q169 337 245 412T430 488Q532 488 610 413T690 231Q689 159 653 100T558 6T430 -29ZM430 1025Q322 1025 246 1101T170 1285Q169 1391 245 1466T430 1542Q532 1542 610 1467T690 1285Q689
-1213 653 1154T558 1060T430 1025Z" />
-<glyph unicode=";" horiz-adv-x="868" d="M654 280L645 169Q633 31 597 -100T519 -337T454 -500H146Q162 -442 189 -337T241 -100T268 168L270 280H654ZM466 1025Q358 1025 282 1101T206 1285Q205 1391 281 1466T466 1542Q568 1542 646 1467T726 1285Q725 1213
-689 1154T594 1060T466 1025Z" />
-<glyph unicode="&lt;" horiz-adv-x="1933" d="M242 618V1034L1690 1642V1187L734 830L749 852V799L734 822L1690 464V10L242 618Z" />
-<glyph unicode="=" horiz-adv-x="1933" d="M251 969V1370H1682V969H251ZM251 281V682H1682V281H251Z" />
-<glyph unicode="&gt;" horiz-adv-x="1933" d="M1690 618L242 10V464L1198 822L1184 799V852L1198 830L242 1187V1642L1690 1034V618Z" />
-<glyph unicode="?" horiz-adv-x="1636" d="M540 627V668Q540 838 568 939T652 1101T786 1214Q849 1254 898 1295T977 1385T1006 1494Q1006 1553 978 1598T902 1667T794 1692Q735 1692 684 1665T600 1589T566 1469H106Q109 1679 203 1813T454 2011T798 2076Q1004
-2076 1164 2012T1417 1823T1509 1517Q1509 1398 1469 1307T1358 1147T1190 1020Q1115 976 1066 928T991 819T966 668V627H540ZM761 -29Q653 -29 577 47T501 231Q500 337 576 412T761 488Q864 488 942 413T1021 231Q1020 159 984 100T889 6T761 -29Z" />
-<glyph unicode="@" horiz-adv-x="2991" d="M1550 -607Q1226 -607 972 -520T542 -264T274 146T181 695Q182 1000 274 1250T545 1682T983 1963T1576 2062Q1880 2062 2122 1976T2534 1728T2795 1337T2884 820Q2885 623 2846 467T2728 200T2528 26T2244 -50Q2112 -61
-2044 -34T1948 44T1909 160H1897Q1874 99 1797 52T1612 -20T1392 -38Q1278 -30 1170 16T976 153T839 382T787 707Q788 894 839 1028T974 1252T1158 1391T1354 1456Q1470 1476 1578 1459T1760 1404T1853 1322H1868V1434H2214V434Q2216 369 2240 340T2302 311Q2360
-311 2404 368T2473 545T2497 850Q2498 1056 2444 1202T2296 1445T2085 1595T1834 1672T1574 1694Q1319 1694 1133 1618T825 1406T644 1094T583 713Q584 482 649 305T840 7T1151 -174T1577 -236Q1694 -236 1805 -221T2007 -183T2158 -135L2272 -460Q2209 -499 2099
--532T1847 -586T1550 -607ZM1526 333Q1645 333 1714 377T1812 509T1838 722Q1838 826 1812 906T1718 1032T1522 1078Q1412 1078 1336 1035T1219 912T1178 719Q1178 620 1211 532T1320 388T1526 333Z" />
-<glyph unicode="A" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806Z" />
-<glyph unicode="B" horiz-adv-x="1870" d="M155 0V2048H1009Q1239 2048 1394 1983T1628 1801T1707 1530Q1707 1413 1658 1322T1523 1170T1324 1086V1066Q1448 1060 1553 999T1723 830T1787 573Q1787 408 1703 279T1460 75T1074 0H155ZM650 399H956Q1117 399 1193
-460T1270 630Q1270 709 1233 766T1128 855T965 887H650V399ZM650 1206H924Q1005 1206 1068 1233T1166 1311T1202 1434Q1202 1537 1129 1596T932 1655H650V1206Z" />
-<glyph unicode="C" horiz-adv-x="2141" d="M2014 1306H1514Q1504 1383 1473 1445T1391 1551T1271 1618T1117 1642Q968 1642 861 1569T696 1358T638 1024Q638 820 696 683T861 476T1114 406Q1196 406 1263 427T1382 488T1467 587T1514 720L2014 717Q2001 581 1936
-449T1756 209T1479 36T1104 -28Q826 -28 607 94T260 452T133 1024Q133 1362 262 1597T611 1954T1104 2076Q1290 2076 1448 2025T1726 1875T1921 1633T2014 1306Z" />
-<glyph unicode="D" horiz-adv-x="2056" d="M912 0H155V2048H911Q1224 2048 1450 1926T1800 1574T1923 1025Q1923 705 1801 475T1451 123T912 0ZM650 422H893Q1065 422 1184 480T1365 671T1427 1025Q1427 1246 1365 1377T1183 1567T886 1626H650V422Z" />
-<glyph unicode="E" horiz-adv-x="1738" d="M155 0V2048H1583V1646H650V1226H1510V823H650V402H1583V0H155Z" />
-<glyph unicode="F" horiz-adv-x="1646" d="M155 0V2048H1553V1646H650V1226H1464V823H650V0H155Z" />
-<glyph unicode="G" horiz-adv-x="2159" d="M1499 1379Q1482 1442 1449 1490T1368 1573T1258 1624T1122 1642Q973 1642 865 1570T697 1361T638 1027Q638 830 695 691T860 479T1122 406Q1258 406 1352 450T1496 577T1545 770L1637 759H1139V1120H2022V849Q2022 574
-1906 378T1586 77T1118 -28Q825 -28 603 99T257 461T133 1021Q133 1273 208 1468T417 1800T729 2006T1112 2076Q1290 2076 1443 2025T1715 1881T1908 1660T2000 1379H1499Z" />
-<glyph unicode="H" horiz-adv-x="2106" d="M155 0V2048H650V1226H1457V2048H1951V0H1457V823H650V0H155Z" />
-<glyph unicode="I" horiz-adv-x="805" d="M650 2048V0H155V2048H650Z" />
-<glyph unicode="J" horiz-adv-x="1630" d="M987 2048H1475V632Q1474 433 1380 285T1121 54T738 -28Q546 -28 390 39T141 247T49 608H542Q545 529 572 474T649 390T766 362Q837 362 886 392T960 483T987 632V2048Z" />
-<glyph unicode="K" horiz-adv-x="1977" d="M155 0V2048H650V1182H677L1354 2048H1937L1211 1134L1950 0H1358L848 805L650 555V0H155Z" />
-<glyph unicode="L" horiz-adv-x="1603" d="M155 0V2048H650V402H1502V0H155Z" />
-<glyph unicode="M" horiz-adv-x="2600" d="M155 2048H768L1288 780H1312L1832 2048H2445V0H1963V1258H1946L1454 13H1146L654 1265H637V0H155V2048Z" />
-<glyph unicode="N" horiz-adv-x="2053" d="M1898 2048V0H1478L663 1182H650V0H155V2048H581L1387 868H1404V2048H1898Z" />
-<glyph unicode="O" horiz-adv-x="2221" d="M2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110
-1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024Z" />
-<glyph unicode="P" horiz-adv-x="1836" d="M155 0V2048H1001Q1231 2048 1398 1959T1657 1710T1748 1339Q1748 1127 1656 969T1392 723T986 636H464V1026H894Q1006 1026 1081 1065T1196 1174T1235 1339Q1235 1434 1196 1503T1081 1611T894 1649H650V0H155Z" />
-<glyph unicode="Q" horiz-adv-x="2224" d="M938 736H1338L1508 519L1731 263L2074 -160H1626L1386 127L1244 331L938 736ZM2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606
-1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110 1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024Z" />
-<glyph unicode="R" horiz-adv-x="1864" d="M155 0V2048H1001Q1231 2048 1398 1966T1657 1730T1748 1366Q1748 1153 1655 1005T1391 779T986 701H451V1091H894Q1006 1091 1081 1120T1196 1210T1235 1366Q1235 1461 1196 1523T1081 1617T894 1649H650V0H155ZM1308
-936L1818 0H1278L778 936H1308Z" />
-<glyph unicode="S" horiz-adv-x="1860" d="M1262 1434Q1252 1544 1174 1605T950 1667Q854 1667 790 1642T694 1574T661 1474Q659 1428 679 1393T739 1332T835 1285T961 1250L1137 1210Q1289 1177 1405 1122T1601 991T1722 816T1764 594Q1763 400 1666 261T1388
-48T950 -26Q693 -26 502 51T205 285T96 682H570Q576 583 623 517T752 417T944 383Q1044 383 1114 410T1223 485T1262 595Q1261 653 1226 694T1119 766T934 823L720 873Q454 934 301 1070T149 1440Q148 1630 251 1773T536 1996T951 2076Q1189 2076 1364 1996T1637
-1770T1736 1434H1262Z" />
-<glyph unicode="T" horiz-adv-x="1907" d="M88 1646V2048H1819V1646H1198V0H710V1646H88Z" />
-<glyph unicode="U" horiz-adv-x="2043" d="M1394 2048H1888V727Q1888 498 1779 329T1475 67T1022 -26Q761 -26 566 66T263 328T155 727V2048H650V770Q650 664 696 581T827 451T1022 404Q1133 404 1216 451T1347 581T1394 770V2048Z" />
-<glyph unicode="V" horiz-adv-x="2173" d="M620 2048L1078 548H1095L1553 2048H2107L1416 0H757L66 2048H620Z" />
-<glyph unicode="W" horiz-adv-x="2998" d="M646 0L50 2048H597L906 706H923L1276 2048H1722L2075 703H2092L2402 2048H2948L2353 0H1876L1507 1242H1491L1122 0H646Z" />
-<glyph unicode="X" horiz-adv-x="2080" d="M656 2048L1030 1401H1046L1424 2048H1978L1360 1024L1998 0H1430L1046 654H1030L646 0H82L717 1024L98 2048H656Z" />
-<glyph unicode="Y" horiz-adv-x="2096" d="M53 2048H606L1039 1191H1057L1490 2048H2043L1294 684V0H802V684L53 2048Z" />
-<glyph unicode="Z" horiz-adv-x="1917" d="M148 0V282L1143 1646H149V2048H1769V1766L774 402H1768V0H148Z" />
-<glyph unicode="[" horiz-adv-x="1196" d="M287 -454V2067H1142V1708H761V-95H1142V-454H287Z" />
-<glyph unicode="\" horiz-adv-x="1129" d="M26 2144H443L1103 -308H686L26 2144Z" />
-<glyph unicode="]" horiz-adv-x="1196" d="M909 2067V-454H54V-95H435V1708H54V2067H909Z" />
-<glyph unicode="^" horiz-adv-x="1390" d="M659 1811H731V1698H659V1811ZM86 1120L511 2010H879L1304 1120H953L686 1738H704L438 1120H86Z" />
-<glyph unicode="_" horiz-adv-x="1365" d="M1366 0V-358H-1V0H1366Z" />
-<glyph unicode="`" horiz-adv-x="1400" d="M685 1731L382 2182H815L1018 1731H685Z" />
-<glyph unicode="a" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294Z" />
-<glyph unicode="b" horiz-adv-x="1802" d="M155 0V2048H644V1273H654Q684 1343 739 1408T881 1514T1089 1556Q1249 1556 1388 1472T1614 1213T1700 767Q1700 506 1618 330T1396 66T1086 -22Q970 -22 885 16T743 116T654 249H639V0H155ZM634 768Q634 644 667 552T764
-410T917 359Q1007 359 1070 409T1165 552T1198 768Q1198 892 1166 983T1071 1124T917 1174Q827 1174 764 1125T668 985T634 768Z" />
-<glyph unicode="c" horiz-adv-x="1681" d="M872 -29Q629 -29 456 71T189 349T96 763Q96 999 189 1177T456 1456T871 1556Q1084 1556 1242 1479T1490 1262T1585 932H1128Q1114 1050 1049 1117T878 1184Q793 1184 729 1137T630 997T594 768Q594 632 629 539T729
-398T878 350Q946 350 998 379T1085 464T1128 602H1585Q1578 412 1491 270T1246 50T872 -29Z" />
-<glyph unicode="d" horiz-adv-x="1802" d="M716 -22Q546 -22 407 66T185 330T102 767Q102 1038 188 1213T413 1472T714 1556Q835 1556 921 1515T1063 1408T1148 1273H1158V2048H1647V0H1163V249H1148Q1117 178 1060 116T918 16T716 -22ZM886 359Q975 359 1038
-409T1135 552T1169 768Q1169 894 1136 985T1039 1125T886 1174Q795 1174 732 1124T637 983T604 768Q604 644 637 552T732 410T886 359Z" />
-<glyph unicode="e" horiz-adv-x="1698" d="M873 -29Q632 -29 458 66T190 339T96 762Q96 1000 190 1178T455 1456T858 1556Q1022 1556 1158 1505T1393 1354T1547 1108T1602 772V650H267V934H1147Q1146 1012 1110 1073T1012 1168T868 1203Q786 1203 721 1167T618
-1068T578 926V637Q578 543 615 473T720 363T882 324Q947 324 1000 342T1091 395T1148 481L1597 468Q1569 317 1475 206T1227 33T873 -29Z" />
-<glyph unicode="f" horiz-adv-x="1109" d="M1028 1536V1176H40V1536H1028ZM246 0V1614Q246 1792 313 1909T499 2085T774 2144Q876 2144 966 2129T1101 2101L1023 1743Q996 1752 958 1758T885 1764Q799 1764 767 1726T735 1622V0H246Z" />
-<glyph unicode="g" horiz-adv-x="1806" d="M871 -595Q652 -595 496 -535T251 -372T144 -140L607 -114Q620 -155 655 -187T748 -237T884 -256Q1007 -256 1087 -198T1168 -2V268H1151Q1122 197 1063 141T917 53T716 21Q548 21 409 99T186 343T102 770Q102 1039 188
-1213T413 1472T714 1556Q835 1556 921 1515T1064 1408T1149 1273H1163V1536H1650V-6Q1650 -195 1552 -326T1278 -526T871 -595ZM886 377Q975 377 1038 424T1135 559T1169 770Q1169 895 1136 985T1039 1125T886 1174Q795 1174 732 1124T637 984T604 770Q604 647
-637 559T732 424T886 377Z" />
-<glyph unicode="h" horiz-adv-x="1789" d="M644 876V0H155V2048H628V1254H645Q697 1396 815 1476T1106 1556Q1268 1556 1388 1485T1574 1283T1640 979V0H1151V883Q1152 1011 1087 1083T904 1155Q827 1155 769 1122T678 1026T644 876Z" />
-<glyph unicode="i" horiz-adv-x="799" d="M155 0V1536H644V0H155ZM657 1715Q553 1715 479 1783T405 1949Q405 2045 479 2113T656 2182Q760 2182 833 2114T907 1949Q907 1852 834 1784T657 1715Z" />
-<glyph unicode="j" horiz-adv-x="799" d="M155 1536H644V-58Q644 -248 567 -362T346 -526T6 -570Q-14 -569 -34 -569T-84 -566V-196Q-66 -198 -52 -198T-24 -199Q77 -199 116 -162T155 -48V1536ZM656 1715Q552 1715 478 1783T404 1949Q404 2045 478 2113T655 2182Q759
-2182 832 2114T906 1949Q906 1852 833 1784T656 1715Z" />
-<glyph unicode="k" horiz-adv-x="1673" d="M596 403L598 986H666L1098 1536H1652L1010 748H881L596 403ZM155 0V2048H644V0H155ZM1109 0L707 638L1029 985L1674 0H1109Z" />
-<glyph unicode="l" horiz-adv-x="799" d="M644 2048V0H155V2048H644Z" />
-<glyph unicode="m" horiz-adv-x="2608" d="M155 0V1536H620V1254H637Q685 1394 799 1475T1071 1556Q1231 1556 1345 1474T1490 1254H1506Q1549 1391 1674 1473T1969 1556Q2187 1556 2323 1417T2459 1034V0H1970V922Q1970 1037 1911 1097T1758 1158Q1657 1158 1600
-1093T1542 917V0H1072V927Q1072 1034 1014 1096T861 1158Q797 1158 748 1128T672 1041T644 909V0H155Z" />
-<glyph unicode="n" horiz-adv-x="1786" d="M644 876V0H155V1536H620V1254H637Q688 1395 811 1475T1104 1556Q1266 1556 1385 1484T1571 1282T1637 979V0H1148V883Q1149 1011 1084 1083T902 1155Q825 1155 767 1122T677 1026T644 876Z" />
-<glyph unicode="o" horiz-adv-x="1744" d="M872 -29Q630 -29 457 70T190 348T96 763Q96 1000 189 1178T456 1456T872 1556Q1114 1556 1287 1457T1554 1179T1648 763Q1648 527 1555 349T1288 71T872 -29ZM875 340Q963 340 1024 394T1117 544T1150 766Q1150 894
-1118 990T1024 1140T875 1194Q784 1194 722 1140T627 990T594 766Q594 640 626 544T721 394T875 340Z" />
-<glyph unicode="p" horiz-adv-x="1802" d="M155 -576V1536H639V1273H654Q684 1343 739 1408T881 1514T1089 1556Q1249 1556 1388 1472T1614 1213T1700 767Q1700 506 1618 330T1396 66T1086 -22Q970 -22 885 16T743 116T654 249H644V-576H155ZM634 768Q634 644
-667 552T764 410T917 359Q1007 359 1070 409T1165 552T1198 768Q1198 892 1166 983T1071 1124T917 1174Q827 1174 764 1125T668 985T634 768Z" />
-<glyph unicode="q" horiz-adv-x="1802" d="M1647 -576H1158V249H1148Q1117 178 1060 116T918 16T716 -22Q546 -22 407 66T185 330T102 767Q102 1038 188 1213T413 1472T714 1556Q835 1556 921 1515T1063 1408T1148 1273H1163V1536H1647V-576ZM1169 768Q1169 894
-1136 985T1039 1125T886 1174Q795 1174 732 1124T637 983T604 768Q604 644 637 552T732 410T886 359Q975 359 1038 409T1135 552T1169 768Z" />
-<glyph unicode="r" horiz-adv-x="1186" d="M155 0V1536H630V1256H646Q688 1408 783 1482T1004 1557Q1038 1557 1074 1553T1141 1539V1114Q1106 1126 1050 1133T949 1140Q862 1140 793 1102T684 995T644 834V0H155Z" />
-<glyph unicode="s" horiz-adv-x="1619" d="M1496 1067L1047 1055Q1040 1103 1009 1140T929 1199T814 1221Q729 1221 670 1187T611 1095Q610 1050 647 1017T782 964L1078 908Q1308 864 1420 762T1534 492Q1533 334 1441 217T1188 36T822 -29Q493 -29 304 106T87
-470L570 482Q586 398 653 354T824 310Q918 310 977 345T1037 438Q1036 490 991 521T850 571L582 622Q351 664 238 777T126 1067Q125 1221 208 1330T444 1498T805 1556Q1117 1556 1297 1426T1496 1067Z" />
-<glyph unicode="t" horiz-adv-x="1117" d="M1022 1536V1176H53V1536H1022ZM256 1904H745V483Q745 438 759 411T802 372T869 360Q897 360 928 365T976 375L1050 22Q1015 12 952 -3T800 -22Q628 -30 506 18T319 170T256 429V1904Z" />
-<glyph unicode="u" horiz-adv-x="1790" d="M1146 663V1536H1634V0H1168V286H1152Q1101 145 979 63T683 -20Q526 -20 407 52T222 253T155 557V1536H644V653Q645 528 710 456T887 384Q960 384 1018 416T1111 511T1146 663Z" />
-<glyph unicode="v" horiz-adv-x="1677" d="M1645 1536L1118 0H558L32 1536H547L830 478H846L1130 1536H1645Z" />
-<glyph unicode="w" horiz-adv-x="2429" d="M442 0L42 1536H533L740 552H753L975 1536H1454L1682 559H1695L1896 1536H2387L1986 0H1463L1223 895H1206L966 0H442Z" />
-<glyph unicode="x" horiz-adv-x="1648" d="M571 1536L824 1030L1086 1536H1579L1150 768L1595 0H1106L824 510L549 0H53L499 768L75 1536H571Z" />
-<glyph unicode="y" horiz-adv-x="1677" d="M454 -574Q366 -574 288 -561T154 -525L262 -170Q370 -205 444 -195T559 -102L578 -56L32 1536H544L827 440H843L1130 1536L1645 1535L1066 -147Q1024 -271 948 -367T751 -519T454 -574Z" />
-<glyph unicode="z" horiz-adv-x="1639" d="M159 0V279L871 1146V1156H184V1536H1454V1230L794 390V380H1478V0H159Z" />
-<glyph unicode="{" horiz-adv-x="1196" d="M82 796V1022Q256 1022 323 1091T391 1310V1499Q391 1700 450 1815T613 1984T853 2052T1142 2067V1708Q1022 1708 963 1679T883 1591T863 1442V1186Q863 1110 832 1040T718 915T482 828T82 796ZM1142 -454Q989 -454 853
--440T614 -371T451 -202T391 114V302Q391 453 324 521T82 590V817Q328 817 481 785T718 698T832 573T863 427V171Q863 81 883 22T962 -66T1142 -95V-454ZM82 590V1022H481V590H82Z" />
-<glyph unicode="|" horiz-adv-x="1096" d="M784 2708V-660H312V2708H784Z" />
-<glyph unicode="}" horiz-adv-x="1196" d="M1114 817V590Q941 590 873 522T805 302V114Q805 -87 746 -202T583 -371T344 -439T54 -454V-95Q174 -95 233 -66T313 22T333 171V427Q333 503 364 573T478 697T714 784T1114 817ZM54 2067Q208 2067 343 2053T582 1984T745
-1815T805 1499V1310Q805 1160 873 1091T1114 1022V796Q868 796 715 828T478 915T364 1040T333 1186V1442Q333 1532 313 1591T234 1679T54 1708V2067ZM1114 1022V590H715V1022H1114Z" />
-<glyph unicode="~" horiz-adv-x="1933" d="M210 534Q203 732 253 873T405 1089T654 1164Q745 1164 831 1127T1027 993Q1080 947 1118 920T1208 893Q1274 893 1319 946T1360 1121H1723Q1731 924 1679 783T1525 567T1279 491Q1181 491 1094 531T902 662Q844 714
-810 738T725 762Q658 762 614 710T573 534H210Z" />
-<glyph unicode="&#xa0;" horiz-adv-x="606" />
-<glyph unicode="&#xa1;" horiz-adv-x="957" d="M478 1560Q581 1560 659 1484T738 1300Q738 1194 660 1119T478 1043Q371 1043 295 1118T218 1300Q218 1372 253 1431T347 1525T478 1560ZM730 -517H227L273 904H684L730 -517Z" />
-<glyph unicode="&#xa2;" horiz-adv-x="1681" d="M776 -256V1780H969V-256H776ZM872 -29Q629 -29 456 71T189 349T96 763Q96 999 189 1177T456 1456T871 1556Q1084 1556 1242 1479T1490 1262T1585 932H1128Q1114 1050 1049 1117T878 1184Q793 1184 729 1137T630
-997T594 768Q594 632 629 539T729 398T878 350Q946 350 998 379T1085 464T1128 602H1585Q1578 412 1491 270T1246 50T872 -29Z" />
-<glyph unicode="&#xa3;" horiz-adv-x="1930" d="M1735 0H203V402H1735V0ZM1351 822H198V1200H1351V822ZM900 1482L932 637Q937 496 867 410T630 257L350 402Q389 415 408 443T434 513T440 605L409 1482Q402 1670 483 1803T715 2006T1062 2076Q1223 2076 1347 2036T1556
-1918T1686 1733T1733 1490L1270 1484Q1269 1562 1242 1606T1169 1668T1067 1686Q1015 1686 978 1664T920 1596T900 1482Z" />
-<glyph unicode="&#xa4;" horiz-adv-x="2193" d="M1098 -28Q922 -28 768 37T496 221T313 496T247 836Q246 1019 312 1175T496 1449T767 1631T1098 1696Q1272 1696 1425 1631T1696 1449T1879 1176T1947 836Q1946 653 1880 496T1696 221T1426 38T1098 -28ZM1098 246Q1218
-246 1323 291T1509 416T1634 604T1681 836Q1680 1004 1602 1137T1391 1348T1098 1426Q934 1426 802 1349T592 1138T515 836Q514 670 591 536T801 324T1098 246ZM1717 1253L1515 1459L1801 1743L2006 1537L1717 1253ZM1801 -39L1515 245L1717 447L2006 167L1801
--39ZM398 -39L196 167L482 447L687 245L398 -39ZM482 1253L196 1537L398 1743L687 1459L482 1253Z" />
-<glyph unicode="&#xa5;" horiz-adv-x="1634" d="M540 2048L930 1052L603 783L40 2048H540ZM710 1065L1094 2048H1594L1027 783L710 1065ZM1053 1079V0H565V1079H1053ZM1443 1068V798H133V1068H1443ZM1443 672V402H133V672H1443Z" />
-<glyph unicode="&#xa6;" horiz-adv-x="1046" d="M306 578H740V-384H306V578ZM740 2048V1090H306V2048H740Z" />
-<glyph unicode="&#xa7;" horiz-adv-x="1709" d="M1448 1532H1025Q1024 1591 976 1631T836 1672Q796 1672 755 1662T686 1627T659 1555Q659 1511 688 1476T760 1418T840 1382L1222 1221Q1261 1204 1321 1172T1437 1085T1525 949T1546 752Q1536 665 1491 614T1392
-536T1304 501L1298 497Q1386 419 1432 324T1479 109Q1479 -52 1399 -169T1179 -349T859 -413Q666 -413 520 -343T292 -148T210 148H634Q633 71 693 29T866 -6Q930 -3 989 22T1050 109Q1050 153 1020 186T950 243T875 282L494 439Q452 456 392 488T276 576T187 714T166
-912Q176 1000 219 1051T316 1127T411 1164V1166Q331 1243 282 1338T233 1555Q233 1705 309 1822T522 2008T836 2076Q1019 2076 1156 2006T1370 1812T1448 1532ZM1153 723Q1159 749 1146 776T1097 828T1000 878L685 1003Q652 1016 625 1011T580 988T556 941Q546
-902 584 861T710 786L1030 659Q1058 648 1083 653T1127 676T1153 723Z" />
-<glyph unicode="&#xa8;" horiz-adv-x="1453" d="M416 1731Q322 1731 256 1795T189 1949Q189 2039 255 2102T416 2166Q509 2166 574 2103T640 1949Q640 1859 575 1795T416 1731ZM1040 1731Q946 1731 880 1795T813 1949Q813 2039 879 2102T1040 2166Q1133 2166 1198
-2103T1264 1949Q1264 1859 1199 1795T1040 1731Z" />
-<glyph unicode="&#xa9;" horiz-adv-x="2576" d="M741 1024Q741 1177 815 1302T1016 1503T1295 1578Q1429 1578 1543 1519T1731 1357T1820 1126H1504Q1490 1193 1431 1234T1295 1276Q1226 1276 1169 1243T1078 1152T1043 1025Q1043 956 1077 899T1169 807T1295
-772Q1372 772 1429 813T1500 922H1816Q1801 793 1729 691T1543 530T1295 470Q1142 470 1016 545T816 745T741 1024ZM1288 -32Q1070 -32 879 50T542 277T314 614T232 1024Q233 1243 315 1434T543 1770T879 1998T1288 2080Q1507 2080 1698 1998T2034 1771T2261 1434T2344
-1024Q2345 805 2263 614T2036 278T1699 50T1288 -32ZM1288 334Q1432 334 1557 388T1777 536T1924 755T1978 1023Q1977 1167 1924 1292T1776 1512T1556 1660T1288 1714Q1146 1714 1022 1660T802 1511T653 1291T598 1023Q598 881 651 757T800 537T1020 388T1288 334Z"
-/>
-<glyph unicode="&#xaa;" horiz-adv-x="1408" d="M877 1184V1652Q877 1734 837 1781T710 1828Q618 1828 567 1793T515 1692L266 1709Q266 1849 388 1940T710 2032Q898 2032 1012 1937T1126 1652V1220Q1126 1164 1133 1100T1166 969H912Q897 1014 887 1069T877 1184ZM940
-1581V1421H734Q611 1421 554 1375T496 1269Q496 1214 536 1182T662 1149Q720 1149 779 1174T879 1237T920 1308L944 1145Q912 1076 829 1014T599 952Q429 952 338 1037T246 1264Q246 1417 366 1499T734 1581H940Z" />
-<glyph unicode="&#xab;" horiz-adv-x="1843" d="M1005 220H560L156 820V840H621L1005 220ZM1005 1448L621 828H156V848L560 1448H1005ZM1712 220H1267L863 820V840H1328L1712 220ZM1712 1448L1328 828H863V848L1267 1448H1712Z" />
-<glyph unicode="&#xac;" horiz-adv-x="1670" d="M1435 1108V872H242V1108H1435ZM1435 1000V516H1150V1000H1435Z" />
-<glyph unicode="&#xad;" horiz-adv-x="1328" d="M1138 1017V640H190V1017H1138Z" />
-<glyph unicode="&#xae;" horiz-adv-x="1862" d="M638 916V1654H995Q1073 1654 1133 1624T1227 1539T1261 1407Q1261 1329 1226 1275T1130 1192T988 1163H748V1308H950Q995 1308 1023 1333T1051 1407Q1051 1458 1023 1481T951 1505H840V916H638ZM1118 1254L1281
-916H1060L910 1254H1118ZM931 476Q765 476 620 538T365 710T193 965T131 1276Q131 1442 193 1587T365 1842T620 2014T931 2076Q1097 2076 1242 2014T1497 1842T1669 1587T1731 1276Q1731 1110 1669 965T1497 710T1242 538T931 476ZM931 694Q1093 694 1225 772T1435
-983T1514 1276Q1514 1437 1436 1569T1225 1779T931 1858Q770 1858 638 1780T428 1569T349 1276Q349 1115 427 983T638 773T931 694Z" />
-<glyph unicode="&#xaf;" horiz-adv-x="1699" d="M1343 2048V1806H356V2048H1343Z" />
-<glyph unicode="&#xb0;" horiz-adv-x="1299" d="M650 1058Q511 1058 397 1125T216 1307T148 1559Q148 1698 215 1812T397 1993T650 2061Q789 2061 902 1994T1083 1812T1151 1559Q1151 1421 1084 1307T903 1126T650 1058ZM650 1370Q702 1370 745 1395T813 1464T839
-1559Q839 1612 814 1655T745 1723T650 1749Q597 1749 554 1724T486 1655T460 1560Q460 1507 485 1464T554 1395T650 1370Z" />
-<glyph unicode="&#xb1;" horiz-adv-x="1933" d="M242 70V448H1690V70H242ZM242 910V1275H1690V910H242ZM765 586V1600H1168V586H765Z" />
-<glyph unicode="&#xb2;" horiz-adv-x="1197" d="M118 1024V1266L611 1647Q662 1687 701 1720T762 1789T785 1868Q785 1934 730 1968T596 2003Q512 2003 461 1964T410 1846H87Q87 2046 231 2157T600 2268Q754 2268 867 2215T1043 2072T1105 1872Q1105 1782 1068
-1705T953 1556T754 1410L602 1310V1299H1112V1024H118Z" />
-<glyph unicode="&#xb3;" horiz-adv-x="1257" d="M623 1008Q468 1008 347 1055T156 1187T82 1384H426Q432 1337 487 1312T623 1287Q704 1287 762 1316T821 1402Q821 1455 763 1489T602 1524H447V1774H602Q689 1774 740 1807T791 1890Q791 1942 746 1972T627 2003Q547
-2003 492 1974T433 1899H110Q112 2008 181 2091T368 2221T630 2268Q778 2268 888 2220T1058 2091T1118 1912Q1118 1804 1044 1741T866 1664V1653Q1010 1641 1091 1570T1173 1380Q1173 1270 1102 1187T906 1056T623 1008Z" />
-<glyph unicode="&#xb4;" horiz-adv-x="1400" d="M525 1731L727 2182H1160L858 1731H525Z" />
-<glyph unicode="&#xb5;" horiz-adv-x="1918" d="M155 -550V1536H644V663Q645 581 684 518T795 420T960 384Q1053 384 1124 419T1235 518T1274 663V1536H1763V0H1297V215H1285Q1244 123 1157 71T960 18Q849 18 762 70T634 215H622V-550H155Z" />
-<glyph unicode="&#xb6;" horiz-adv-x="1726" d="M1322 1684H954V760H834Q607 760 451 843T214 1072T133 1404Q133 1588 213 1733T450 1963T834 2048H1322V1684ZM1202 0V2048H1571V0H1202Z" />
-<glyph unicode="&#xb7;" horiz-adv-x="859" d="M430 637Q322 637 246 713T170 897Q169 1003 245 1078T430 1154Q532 1154 610 1079T690 897Q689 825 653 766T558 672T430 637Z" />
-<glyph unicode="&#xb8;" horiz-adv-x="1155" d="M482 8H678L662 -82Q772 -98 838 -161T905 -327Q906 -483 757 -573T341 -664L339 -463Q476 -463 551 -432T629 -338Q632 -280 584 -246T435 -198L482 8Z" />
-<glyph unicode="&#xb9;" horiz-adv-x="911" d="M393 1024V1952H386L99 1776V2055L416 2252H713V1024H393Z" />
-<glyph unicode="&#xba;" horiz-adv-x="1398" d="M204 1436V1541Q204 1684 265 1795T437 1969T699 2032Q928 2032 1061 1894T1194 1541V1436Q1194 1294 1135 1184T966 1012T703 949Q472 949 338 1086T204 1436ZM454 1541V1436Q454 1313 516 1233T703 1152Q825 1152
-885 1232T945 1436V1541Q945 1661 885 1744T699 1828Q577 1828 516 1745T454 1541Z" />
-<glyph unicode="&#xbb;" horiz-adv-x="1856" d="M127 220L511 840H976V820L572 220H127ZM127 1448H572L976 848V828H511L127 1448ZM847 220L1231 840H1696V820L1292 220H847ZM847 1448H1292L1696 848V828H1231L847 1448Z" />
-<glyph unicode="&#xbc;" horiz-adv-x="2472" d="M323 0L1731 2048H2004L596 0H323ZM347 1216V2025H340L80 1865V2126L368 2304H644V1216H347ZM1366 167V408L1867 1088H2072V767H1955L1671 422V412H2410V167H1366ZM1962 0V238L1966 349V1088H2249V0H1962Z" />
-<glyph unicode="&#xbd;" horiz-adv-x="2570" d="M323 0L1731 2048H2004L596 0H323ZM347 1216V2025H340L80 1865V2126L368 2304H644V1216H347ZM1607 0V216L2055 554Q2117 601 2163 644T2210 746Q2210 804 2160 833T2036 863Q1961 863 1915 830T1869 728H1583Q1583
-905 1713 1004T2043 1104Q2249 1104 2371 1001T2493 750Q2493 668 2459 599T2354 468T2176 343L2039 259V248H2495V0H1607Z" />
-<glyph unicode="&#xbe;" horiz-adv-x="2733" d="M584 0L1992 2048H2265L857 0H584ZM1627 167V408L2128 1088H2333V767H2216L1932 422V412H2671V167H1627ZM2223 0V238L2227 349V1088H2510V0H2223ZM564 1200Q417 1200 309 1244T139 1363T75 1532H383Q388 1492 436
-1470T554 1447Q634 1447 689 1474T744 1549Q744 1594 694 1624T546 1654H400V1882H546Q621 1882 667 1910T714 1984Q714 2028 674 2056T561 2084Q496 2084 445 2060T390 1996H99Q103 2092 166 2165T335 2279T570 2320Q702 2320 800 2277T952 2162T1007 2003Q1007
-1915 946 1856T786 1784V1773Q919 1762 988 1697T1057 1530Q1057 1433 992 1359T816 1242T564 1200Z" />
-<glyph unicode="&#xbf;" horiz-adv-x="1636" d="M875 1538Q983 1538 1060 1463T1135 1278Q1137 1172 1060 1097T875 1022Q772 1022 695 1097T615 1278Q616 1351 652 1410T748 1503T875 1538ZM1096 882V842Q1096 672 1068 571T985 408T850 296Q788 255 738 214T659
-125T630 16Q630 -44 658 -88T734 -157T842 -182Q901 -182 952 -156T1036 -79T1070 41H1530Q1527 -169 1433 -303T1183 -502T838 -566Q633 -566 472 -502T219 -314T127 -7Q127 113 167 203T278 363T446 489Q522 535 571 582T645 691T670 842V882H1096Z" />
-<glyph unicode="&#xc0;" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806ZM923 2243L620 2694H1053L1256 2243H923Z" />
-<glyph unicode="&#xc1;" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806ZM918 2243L1120 2694H1553L1251 2243H918Z" />
-<glyph unicode="&#xc2;" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806ZM888 2230H478V2243L911 2691H1262L1694 2243V2230H1285L1086 2462L888 2230Z" />
-<glyph unicode="&#xc3;" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806ZM807 2248L552 2250Q552 2453 649 2557T885 2662Q959 2662 1011 2639T1104 2584T1180 2529T1256 2505Q1313 2506 1340 2547T1369
-2660L1620 2657Q1617 2456 1521 2351T1287 2244Q1211 2243 1158 2267T1067 2322T993 2376T916 2400Q867 2400 837 2361T807 2248Z" />
-<glyph unicode="&#xc4;" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806ZM776 2243Q682 2243 616 2307T549 2461Q549 2551 615 2614T776 2678Q869 2678 934 2615T1000 2461Q1000 2371 935 2307T776
-2243ZM1400 2243Q1306 2243 1240 2307T1173 2461Q1173 2551 1239 2614T1400 2678Q1493 2678 1558 2615T1624 2461Q1624 2371 1559 2307T1400 2243Z" />
-<glyph unicode="&#xc5;" horiz-adv-x="2173" d="M598 0H66L757 2048H1416L2107 0H1575L1094 1532H1078L598 0ZM527 806H1639V430H527V806ZM1086 2149Q998 2149 925 2190T808 2302T764 2458Q764 2543 807 2613T924 2724T1086 2766Q1176 2766 1249 2725T1365 2613T1409
-2458Q1409 2372 1366 2302T1249 2191T1086 2149ZM1086 2330Q1142 2330 1182 2367T1222 2458Q1222 2511 1182 2548T1086 2586Q1031 2585 991 2548T951 2458Q951 2405 990 2367T1086 2330Z" />
-<glyph unicode="&#xc6;" horiz-adv-x="2938" d="M66 0L904 2048H2783V1646H1850V1226H2710V823H1850V402H2783V0H1355V1553H1214L610 0H66ZM546 430V806H1642V430H546Z" />
-<glyph unicode="&#xc7;" horiz-adv-x="2141" d="M2014 1306H1514Q1504 1383 1473 1445T1391 1551T1271 1618T1117 1642Q968 1642 861 1569T696 1358T638 1024Q638 820 696 683T861 476T1114 406Q1196 406 1263 427T1382 488T1467 587T1514 720L2014 717Q2001 581
-1936 449T1756 209T1479 36T1104 -28Q826 -28 607 94T260 452T133 1024Q133 1362 262 1597T611 1954T1104 2076Q1290 2076 1448 2025T1726 1875T1921 1633T2014 1306ZM997 11H1193L1177 -79Q1287 -95 1353 -158T1420 -324Q1421 -480 1272 -570T856 -661L854 -460Q991
--460 1066 -429T1144 -335Q1147 -277 1099 -243T950 -195L997 11Z" />
-<glyph unicode="&#xc8;" horiz-adv-x="1738" d="M155 0V2048H1583V1646H650V1226H1510V823H650V402H1583V0H155ZM710 2243L407 2694H840L1043 2243H710Z" />
-<glyph unicode="&#xc9;" horiz-adv-x="1738" d="M155 0V2048H1583V1646H650V1226H1510V823H650V402H1583V0H155ZM705 2243L907 2694H1340L1038 2243H705Z" />
-<glyph unicode="&#xca;" horiz-adv-x="1738" d="M155 0V2048H1583V1646H650V1226H1510V823H650V402H1583V0H155ZM675 2230H265V2243L698 2691H1049L1481 2243V2230H1072L873 2462L675 2230Z" />
-<glyph unicode="&#xcb;" horiz-adv-x="1738" d="M155 0V2048H1583V1646H650V1226H1510V823H650V402H1583V0H155ZM563 2243Q469 2243 403 2307T336 2461Q336 2551 402 2614T563 2678Q656 2678 721 2615T787 2461Q787 2371 722 2307T563 2243ZM1187 2243Q1093 2243
-1027 2307T960 2461Q960 2551 1026 2614T1187 2678Q1280 2678 1345 2615T1411 2461Q1411 2371 1346 2307T1187 2243Z" />
-<glyph unicode="&#xcc;" horiz-adv-x="805" d="M650 2048V0H155V2048H650ZM240 2243L-63 2694H370L573 2243H240Z" />
-<glyph unicode="&#xcd;" horiz-adv-x="805" d="M650 2048V0H155V2048H650ZM235 2243L437 2694H870L568 2243H235Z" />
-<glyph unicode="&#xce;" horiz-adv-x="805" d="M650 2048V0H155V2048H650ZM206 2230H-204V2243L229 2691H580L1012 2243V2230H603L404 2462L206 2230Z" />
-<glyph unicode="&#xcf;" horiz-adv-x="805" d="M650 2048V0H155V2048H650ZM94 2243Q0 2243 -66 2307T-133 2461Q-133 2551 -67 2614T94 2678Q187 2678 252 2615T318 2461Q318 2371 253 2307T94 2243ZM718 2243Q624 2243 558 2307T491 2461Q491 2551 557 2614T718
-2678Q811 2678 876 2615T942 2461Q942 2371 877 2307T718 2243Z" />
-<glyph unicode="&#xd0;" horiz-adv-x="2056" d="M912 0H155V2048H911Q1224 2048 1450 1926T1800 1574T1923 1025Q1923 705 1801 475T1451 123T912 0ZM650 422H893Q1065 422 1184 480T1365 671T1427 1025Q1427 1246 1365 1377T1183 1567T886 1626H650V422ZM-119
-952V1225H918V952H-119Z" />
-<glyph unicode="&#xd1;" horiz-adv-x="2053" d="M1898 2048V0H1478L663 1182H650V0H155V2048H581L1387 868H1404V2048H1898ZM749 2238L494 2240Q494 2443 591 2547T827 2652Q901 2652 953 2629T1046 2574T1122 2519T1198 2495Q1255 2496 1282 2537T1311 2650L1562
-2647Q1559 2446 1463 2341T1229 2234Q1153 2233 1100 2257T1009 2312T935 2366T858 2390Q809 2390 779 2351T749 2238Z" />
-<glyph unicode="&#xd2;" horiz-adv-x="2221" d="M2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110
-1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024ZM947 2243L644 2694H1077L1280 2243H947Z" />
-<glyph unicode="&#xd3;" horiz-adv-x="2221" d="M2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110
-1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024ZM942 2243L1144 2694H1577L1275 2243H942Z" />
-<glyph unicode="&#xd4;" horiz-adv-x="2221" d="M2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110
-1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024ZM912 2230H502V2243L935 2691H1286L1718 2243V2230H1309L1110 2462L912 2230Z" />
-<glyph unicode="&#xd5;" horiz-adv-x="2221" d="M2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110
-1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024ZM831 2248L576 2250Q576 2453 673 2557T909 2662Q983 2662 1035 2639T1128 2584T1204 2529T1280 2505Q1337 2506 1364 2547T1393 2660L1644
-2657Q1641 2456 1545 2351T1311 2244Q1235 2243 1182 2267T1091 2322T1017 2376T940 2400Q891 2400 861 2361T831 2248Z" />
-<glyph unicode="&#xd6;" horiz-adv-x="2221" d="M2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088 1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110
-1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024ZM800 2243Q706 2243 640 2307T573 2461Q573 2551 639 2614T800 2678Q893 2678 958 2615T1024 2461Q1024 2371 959 2307T800 2243ZM1424 2243Q1330
-2243 1264 2307T1197 2461Q1197 2551 1263 2614T1424 2678Q1517 2678 1582 2615T1648 2461Q1648 2371 1583 2307T1424 2243Z" />
-<glyph unicode="&#xd7;" horiz-adv-x="1933" d="M1426 67L208 1282L507 1584L1725 366L1426 67ZM507 67L208 366L1426 1584L1725 1282L507 67Z" />
-<glyph unicode="&#xd8;" horiz-adv-x="2221" d="M474 -103L264 39L1746 2147L1955 2005L474 -103ZM2088 1024Q2088 686 1958 451T1607 94T1110 -28Q834 -28 613 95T263 452T133 1024Q133 1362 262 1597T613 1954T1110 2076Q1385 2076 1606 1954T1958 1597T2088
-1024ZM1582 1024Q1582 1224 1526 1362T1364 1571T1110 1642Q963 1642 857 1571T695 1362T638 1024Q638 824 694 686T857 477T1110 406Q1258 406 1363 477T1525 686T1582 1024Z" />
-<glyph unicode="&#xd9;" horiz-adv-x="2043" d="M1394 2048H1888V727Q1888 498 1779 329T1475 67T1022 -26Q761 -26 566 66T263 328T155 727V2048H650V770Q650 664 696 581T827 451T1022 404Q1133 404 1216 451T1347 581T1394 770V2048ZM858 2243L555 2694H988L1191
-2243H858Z" />
-<glyph unicode="&#xda;" horiz-adv-x="2043" d="M1394 2048H1888V727Q1888 498 1779 329T1475 67T1022 -26Q761 -26 566 66T263 328T155 727V2048H650V770Q650 664 696 581T827 451T1022 404Q1133 404 1216 451T1347 581T1394 770V2048ZM853 2243L1055 2694H1488L1186
-2243H853Z" />
-<glyph unicode="&#xdb;" horiz-adv-x="2043" d="M1394 2048H1888V727Q1888 498 1779 329T1475 67T1022 -26Q761 -26 566 66T263 328T155 727V2048H650V770Q650 664 696 581T827 451T1022 404Q1133 404 1216 451T1347 581T1394 770V2048ZM823 2230H413V2243L846
-2691H1197L1629 2243V2230H1220L1021 2462L823 2230Z" />
-<glyph unicode="&#xdc;" horiz-adv-x="2043" d="M1394 2048H1888V727Q1888 498 1779 329T1475 67T1022 -26Q761 -26 566 66T263 328T155 727V2048H650V770Q650 664 696 581T827 451T1022 404Q1133 404 1216 451T1347 581T1394 770V2048ZM711 2243Q617 2243 551
-2307T484 2461Q484 2551 550 2614T711 2678Q804 2678 869 2615T935 2461Q935 2371 870 2307T711 2243ZM1335 2243Q1241 2243 1175 2307T1108 2461Q1108 2551 1174 2614T1335 2678Q1428 2678 1493 2615T1559 2461Q1559 2371 1494 2307T1335 2243Z" />
-<glyph unicode="&#xdd;" horiz-adv-x="2096" d="M53 2048H606L1039 1191H1057L1490 2048H2043L1294 684V0H802V684L53 2048ZM879 2243L1081 2694H1514L1212 2243H879Z" />
-<glyph unicode="&#xde;" horiz-adv-x="1926" d="M155 2048H650V0H155V2048ZM383 1688H918Q1174 1688 1351 1606T1620 1379T1712 1049Q1712 863 1621 720T1352 495T918 413H383V783H918Q1034 783 1105 821T1207 918T1239 1045Q1239 1113 1208 1175T1105 1275T918
-1314H383V1688Z" />
-<glyph unicode="&#xdf;" horiz-adv-x="1921" d="M155 0V1502Q155 1681 246 1809T504 2007T898 2076Q1107 2076 1283 2014T1566 1827T1673 1513Q1673 1359 1598 1249T1387 1103V1080Q1589 1068 1704 924T1818 564Q1818 379 1731 254T1491 64T1139 0H854V390H1047Q1131
-390 1193 418T1290 497T1323 618Q1322 726 1239 788T1014 851H862V1219H962Q1025 1219 1071 1249T1144 1329T1171 1442Q1173 1544 1105 1609T914 1674Q791 1674 718 1605T644 1418V0H155Z" />
-<glyph unicode="&#xe0;" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294ZM935 1731L632 2182H1065L1268 1731H935Z" />
-<glyph unicode="&#xe1;" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294ZM674 1731L876 2182H1309L1007 1731H674Z" />
-<glyph unicode="&#xe2;" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294ZM644 1718H234V1731L667 2179H1018L1450 1731V1718H1041L842 1950L644 1718Z" />
-<glyph unicode="&#xe3;" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294ZM563 1726L308 1728Q308 1931 405 2035T641 2140Q715 2140 767 2117T860 2062T936 2007T1012 1983Q1069 1984 1096 2025T1125 2138L1376 2135Q1373 1934 1277 1829T1043 1722Q967 1721 914 1745T823 1800T749 1854T672 1878Q623 1878 593 1839T563 1726Z" />
-<glyph unicode="&#xe4;" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294ZM532 1731Q438 1731 372 1795T305 1949Q305 2039 371 2102T532 2166Q625 2166 690 2103T756 1949Q756 1859 691 1795T532 1731ZM1156 1731Q1062 1731 996 1795T929 1949Q929 2039 995 2102T1156 2166Q1249 2166 1314 2103T1380 1949Q1380 1859 1315 1795T1156
-1731Z" />
-<glyph unicode="&#xe5;" horiz-adv-x="1649" d="M588 -26Q441 -26 328 23T149 173T83 426Q83 554 128 642T252 785T434 868T654 907Q785 919 865 932T981 971T1018 1044V1049Q1018 1126 965 1168T822 1210Q725 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T473
-1492T826 1556Q973 1556 1097 1522T1313 1422T1455 1261T1506 1044V0H1046V214H1034Q993 136 930 82T781 1T588 -26ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646 596T572 537T546 450Q546 374 600 334T739
-294ZM842 1698Q750 1698 674 1742T553 1859T507 2022Q507 2112 552 2186T674 2304T842 2349Q935 2349 1011 2305T1132 2186T1177 2022Q1177 1933 1132 1860T1011 1742T842 1698ZM842 1886Q903 1886 944 1926T986 2022Q986 2078 945 2120T842 2161Q782 2160 740
-2119T698 2022Q698 1967 740 1926T842 1886Z" />
-<glyph unicode="&#xe6;" horiz-adv-x="2562" d="M1773 -29Q1572 -29 1415 53T1192 298L1159 1248Q1261 1412 1401 1484T1723 1556Q1886 1556 2022 1505T2258 1354T2411 1108T2466 772V650H1174V934H2012Q2010 1012 1974 1073T1876 1168T1733 1203Q1650 1203 1585
-1167T1483 1067T1443 926V637Q1443 543 1480 473T1585 363T1747 324Q1811 324 1864 342T1956 395T2013 481L2462 468Q2434 317 2339 206T2099 33T1773 -29ZM739 294Q818 294 881 326T983 414T1021 546V692Q1000 681 971 672T906 655T834 641T763 630Q694 619 646
-596T572 537T546 450Q546 374 600 334T739 294ZM620 -26Q457 -26 336 23T149 174T83 426Q83 554 128 641T252 784T435 868T654 907Q786 919 865 933T981 972T1018 1044V1049Q1018 1127 965 1168T822 1210Q726 1210 666 1168T591 1052L140 1068Q160 1208 243 1318T474
-1492T836 1556Q910 1556 977 1543T1102 1502T1205 1430T1277 1326L1239 242H1210Q1165 156 1076 96T868 5T620 -26Z" />
-<glyph unicode="&#xe7;" horiz-adv-x="1681" d="M872 -29Q629 -29 456 71T189 349T96 763Q96 999 189 1177T456 1456T871 1556Q1084 1556 1242 1479T1490 1262T1585 932H1128Q1114 1050 1049 1117T878 1184Q793 1184 729 1137T630 997T594 768Q594 632 629 539T729
-398T878 350Q946 350 998 379T1085 464T1128 602H1585Q1578 412 1491 270T1246 50T872 -29ZM776 8H972L956 -82Q1066 -98 1132 -161T1199 -327Q1200 -483 1051 -573T635 -664L633 -463Q770 -463 845 -432T923 -338Q926 -280 878 -246T729 -198L776 8Z" />
-<glyph unicode="&#xe8;" horiz-adv-x="1698" d="M873 -29Q632 -29 458 66T190 339T96 762Q96 1000 190 1178T455 1456T858 1556Q1022 1556 1158 1505T1393 1354T1547 1108T1602 772V650H267V934H1147Q1146 1012 1110 1073T1012 1168T868 1203Q786 1203 721 1167T618
-1068T578 926V637Q578 543 615 473T720 363T882 324Q947 324 1000 342T1091 395T1148 481L1597 468Q1569 317 1475 206T1227 33T873 -29ZM686 1731L383 2182H816L1019 1731H686Z" />
-<glyph unicode="&#xe9;" horiz-adv-x="1698" d="M873 -29Q632 -29 458 66T190 339T96 762Q96 1000 190 1178T455 1456T858 1556Q1022 1556 1158 1505T1393 1354T1547 1108T1602 772V650H267V934H1147Q1146 1012 1110 1073T1012 1168T868 1203Q786 1203 721 1167T618
-1068T578 926V637Q578 543 615 473T720 363T882 324Q947 324 1000 342T1091 395T1148 481L1597 468Q1569 317 1475 206T1227 33T873 -29ZM681 1731L883 2182H1316L1014 1731H681Z" />
-<glyph unicode="&#xea;" horiz-adv-x="1698" d="M873 -29Q632 -29 458 66T190 339T96 762Q96 1000 190 1178T455 1456T858 1556Q1022 1556 1158 1505T1393 1354T1547 1108T1602 772V650H267V934H1147Q1146 1012 1110 1073T1012 1168T868 1203Q786 1203 721 1167T618
-1068T578 926V637Q578 543 615 473T720 363T882 324Q947 324 1000 342T1091 395T1148 481L1597 468Q1569 317 1475 206T1227 33T873 -29ZM651 1718H241V1731L674 2179H1025L1457 1731V1718H1048L849 1950L651 1718Z" />
-<glyph unicode="&#xeb;" horiz-adv-x="1698" d="M873 -29Q632 -29 458 66T190 339T96 762Q96 1000 190 1178T455 1456T858 1556Q1022 1556 1158 1505T1393 1354T1547 1108T1602 772V650H267V934H1147Q1146 1012 1110 1073T1012 1168T868 1203Q786 1203 721 1167T618
-1068T578 926V637Q578 543 615 473T720 363T882 324Q947 324 1000 342T1091 395T1148 481L1597 468Q1569 317 1475 206T1227 33T873 -29ZM539 1731Q445 1731 379 1795T312 1949Q312 2039 378 2102T539 2166Q632 2166 697 2103T763 1949Q763 1859 698 1795T539 1731ZM1163
-1731Q1069 1731 1003 1795T936 1949Q936 2039 1002 2102T1163 2166Q1256 2166 1321 2103T1387 1949Q1387 1859 1322 1795T1163 1731Z" />
-<glyph unicode="&#xec;" horiz-adv-x="799" d="M155 0V1536H644V0H155ZM236 1731L-67 2182H366L569 1731H236Z" />
-<glyph unicode="&#xed;" horiz-adv-x="799" d="M155 0V1536H644V0H155ZM231 1731L433 2182H866L564 1731H231Z" />
-<glyph unicode="&#xee;" horiz-adv-x="799" d="M155 0V1536H644V0H155ZM202 1718H-208V1731L225 2179H576L1008 1731V1718H599L400 1950L202 1718Z" />
-<glyph unicode="&#xef;" horiz-adv-x="799" d="M155 0V1536H644V0H155ZM90 1731Q-4 1731 -70 1795T-137 1949Q-137 2039 -71 2102T90 2166Q183 2166 248 2103T314 1949Q314 1859 249 1795T90 1731ZM714 1731Q620 1731 554 1795T487 1949Q487 2039 553 2102T714
-2166Q807 2166 872 2103T938 1949Q938 1859 873 1795T714 1731Z" />
-<glyph unicode="&#xf0;" horiz-adv-x="1603" d="M1261 1855L390 1405L258 1642L1129 2093L1261 1855ZM800 -32Q588 -32 431 63T188 324T102 704Q102 909 172 1058T352 1297T577 1418Q635 1430 699 1432T824 1417T928 1356H948Q891 1443 832 1509T702 1632T538
-1747T318 1877L534 2176Q710 2089 881 1954T1192 1638T1417 1236T1501 752Q1501 504 1414 329T1168 61T800 -32ZM822 340Q901 340 959 378T1049 498T1081 710Q1081 847 1048 924T956 1033T822 1065Q747 1065 689 1032T599 920T566 707Q566 574 598 494T687 377T822
-340Z" />
-<glyph unicode="&#xf1;" horiz-adv-x="1786" d="M644 876V0H155V1536H620V1254H637Q688 1395 811 1475T1104 1556Q1266 1556 1385 1484T1571 1282T1637 979V0H1148V883Q1149 1011 1084 1083T902 1155Q825 1155 767 1122T677 1026T644 876ZM615 1726L360 1728Q360
-1931 457 2035T693 2140Q767 2140 819 2117T912 2062T988 2007T1064 1983Q1121 1984 1148 2025T1177 2138L1428 2135Q1425 1934 1329 1829T1095 1722Q1019 1721 966 1745T875 1800T801 1854T724 1878Q675 1878 645 1839T615 1726Z" />
-<glyph unicode="&#xf2;" horiz-adv-x="1744" d="M872 -29Q630 -29 457 70T190 348T96 763Q96 1000 189 1178T456 1456T872 1556Q1114 1556 1287 1457T1554 1179T1648 763Q1648 527 1555 349T1288 71T872 -29ZM875 340Q963 340 1024 394T1117 544T1150 766Q1150
-894 1118 990T1024 1140T875 1194Q784 1194 722 1140T627 990T594 766Q594 640 626 544T721 394T875 340ZM708 1731L405 2182H838L1041 1731H708Z" />
-<glyph unicode="&#xf3;" horiz-adv-x="1744" d="M872 -29Q630 -29 457 70T190 348T96 763Q96 1000 189 1178T456 1456T872 1556Q1114 1556 1287 1457T1554 1179T1648 763Q1648 527 1555 349T1288 71T872 -29ZM875 340Q963 340 1024 394T1117 544T1150 766Q1150
-894 1118 990T1024 1140T875 1194Q784 1194 722 1140T627 990T594 766Q594 640 626 544T721 394T875 340ZM703 1731L905 2182H1338L1036 1731H703Z" />
-<glyph unicode="&#xf4;" horiz-adv-x="1744" d="M872 -29Q630 -29 457 70T190 348T96 763Q96 1000 189 1178T456 1456T872 1556Q1114 1556 1287 1457T1554 1179T1648 763Q1648 527 1555 349T1288 71T872 -29ZM875 340Q963 340 1024 394T1117 544T1150 766Q1150
-894 1118 990T1024 1140T875 1194Q784 1194 722 1140T627 990T594 766Q594 640 626 544T721 394T875 340ZM674 1718H264V1731L697 2179H1048L1480 1731V1718H1071L872 1950L674 1718Z" />
-<glyph unicode="&#xf5;" horiz-adv-x="1744" d="M872 -29Q630 -29 457 70T190 348T96 763Q96 1000 189 1178T456 1456T872 1556Q1114 1556 1287 1457T1554 1179T1648 763Q1648 527 1555 349T1288 71T872 -29ZM875 340Q963 340 1024 394T1117 544T1150 766Q1150
-894 1118 990T1024 1140T875 1194Q784 1194 722 1140T627 990T594 766Q594 640 626 544T721 394T875 340ZM593 1726L338 1728Q338 1931 435 2035T671 2140Q745 2140 797 2117T890 2062T966 2007T1042 1983Q1099 1984 1126 2025T1155 2138L1406 2135Q1403 1934 1307
-1829T1073 1722Q997 1721 944 1745T853 1800T779 1854T702 1878Q653 1878 623 1839T593 1726Z" />
-<glyph unicode="&#xf6;" horiz-adv-x="1744" d="M872 -29Q630 -29 457 70T190 348T96 763Q96 1000 189 1178T456 1456T872 1556Q1114 1556 1287 1457T1554 1179T1648 763Q1648 527 1555 349T1288 71T872 -29ZM875 340Q963 340 1024 394T1117 544T1150 766Q1150
-894 1118 990T1024 1140T875 1194Q784 1194 722 1140T627 990T594 766Q594 640 626 544T721 394T875 340ZM562 1731Q468 1731 402 1795T335 1949Q335 2039 401 2102T562 2166Q655 2166 720 2103T786 1949Q786 1859 721 1795T562 1731ZM1186 1731Q1092 1731 1026
-1795T959 1949Q959 2039 1025 2102T1186 2166Q1279 2166 1344 2103T1410 1949Q1410 1859 1345 1795T1186 1731Z" />
-<glyph unicode="&#xf7;" horiz-adv-x="1933" d="M1686 1014V637H246V1014H1686ZM966 -5Q857 -5 780 72T703 258Q703 366 780 442T966 518Q1074 518 1150 442T1226 258Q1226 149 1150 72T966 -5ZM966 1134Q894 1134 834 1169T739 1264T703 1397Q703 1505 780 1580T966
-1656Q1074 1656 1150 1581T1226 1397Q1226 1288 1150 1211T966 1134Z" />
-<glyph unicode="&#xf8;" horiz-adv-x="1744" d="M418 -103L209 39L1322 1627L1535 1485L418 -103ZM872 -32Q630 -32 457 67T190 345T96 760Q96 997 189 1175T456 1453T872 1553Q1114 1553 1287 1454T1554 1176T1648 760Q1648 524 1555 346T1288 68T872 -32ZM875
-337Q963 337 1024 390T1117 540T1150 763Q1150 891 1118 986T1024 1136T875 1190Q784 1190 722 1136T627 987T594 763Q594 636 626 540T721 391T875 337Z" />
-<glyph unicode="&#xf9;" horiz-adv-x="1790" d="M1146 663V1536H1634V0H1168V286H1152Q1101 145 979 63T683 -20Q526 -20 407 52T222 253T155 557V1536H644V653Q645 528 710 456T887 384Q960 384 1018 416T1111 511T1146 663ZM733 1731L430 2182H863L1066 1731H733Z" />
-<glyph unicode="&#xfa;" horiz-adv-x="1790" d="M1146 663V1536H1634V0H1168V286H1152Q1101 145 979 63T683 -20Q526 -20 407 52T222 253T155 557V1536H644V653Q645 528 710 456T887 384Q960 384 1018 416T1111 511T1146 663ZM728 1731L930 2182H1363L1061 1731H728Z" />
-<glyph unicode="&#xfb;" horiz-adv-x="1790" d="M1146 663V1536H1634V0H1168V286H1152Q1101 145 979 63T683 -20Q526 -20 407 52T222 253T155 557V1536H644V653Q645 528 710 456T887 384Q960 384 1018 416T1111 511T1146 663ZM698 1718H288V1731L721 2179H1072L1504
-1731V1718H1095L896 1950L698 1718Z" />
-<glyph unicode="&#xfc;" horiz-adv-x="1790" d="M1146 663V1536H1634V0H1168V286H1152Q1101 145 979 63T683 -20Q526 -20 407 52T222 253T155 557V1536H644V653Q645 528 710 456T887 384Q960 384 1018 416T1111 511T1146 663ZM586 1731Q492 1731 426 1795T359
-1949Q359 2039 425 2102T586 2166Q679 2166 744 2103T810 1949Q810 1859 745 1795T586 1731ZM1210 1731Q1116 1731 1050 1795T983 1949Q983 2039 1049 2102T1210 2166Q1303 2166 1368 2103T1434 1949Q1434 1859 1369 1795T1210 1731Z" />
-<glyph unicode="&#xfd;" horiz-adv-x="1677" d="M454 -574Q366 -574 288 -561T154 -525L262 -170Q370 -205 444 -195T559 -102L578 -56L32 1536H544L827 440H843L1130 1536L1645 1535L1066 -147Q1024 -271 948 -367T751 -519T454 -574ZM667 1731L869 2182H1302L1000
-1731H667Z" />
-<glyph unicode="&#xfe;" horiz-adv-x="1729" d="M155 2048H644V1284L466 739L644 307V-576H155V2048ZM449 1382H661Q688 1439 738 1478T859 1536T1015 1556Q1173 1556 1312 1472T1539 1213T1626 767Q1627 504 1543 329T1320 66T1013 -22Q933 -22 863 -5T741 50T661
-146H449V1382ZM1166 768Q1167 898 1134 989T1034 1127T866 1174Q759 1174 699 1119T614 972T589 768Q590 660 613 567T696 416T866 359Q966 359 1033 407T1133 546T1166 768Z" />
-<glyph unicode="&#xff;" horiz-adv-x="1677" d="M454 -574Q366 -574 288 -561T154 -525L262 -170Q370 -205 444 -195T559 -102L578 -56L32 1536H544L827 440H843L1130 1536L1645 1535L1066 -147Q1024 -271 948 -367T751 -519T454 -574ZM525 1731Q431 1731 365
-1795T298 1949Q298 2039 364 2102T525 2166Q618 2166 683 2103T749 1949Q749 1859 684 1795T525 1731ZM1149 1731Q1055 1731 989 1795T922 1949Q922 2039 988 2102T1149 2166Q1242 2166 1307 2103T1373 1949Q1373 1859 1308 1795T1149 1731Z" />
-<glyph unicode="&#x2013;" horiz-adv-x="1408" d="M1408 1017V640H0V1017H1408Z" />
-<glyph unicode="&#x2014;" horiz-adv-x="2816" d="M2816 1017V640H0V1017H2816Z" />
-<glyph unicode="&#x2018;" horiz-adv-x="723" d="M162 1408V1636Q162 1731 199 1828T297 2010T423 2150L631 2036Q577 1948 545 1853T514 1637V1408H162Z" />
-<glyph unicode="&#x2019;" horiz-adv-x="683" d="M562 2048V1820Q562 1725 525 1629T427 1448T300 1306L92 1420Q138 1496 174 1591T210 1819V2048H562Z" />
-<glyph unicode="&#x201a;" horiz-adv-x="723" d="M561 256V28Q561 -67 524 -163T426 -344T299 -486L91 -372Q137 -296 173 -201T209 27V256H561Z" />
-<glyph unicode="&#x201c;" horiz-adv-x="1226" d="M162 1408V1636Q162 1731 199 1828T297 2010T423 2150L631 2036Q577 1948 545 1853T514 1637V1408H162ZM664 1408V1636Q664 1731 701 1828T799 2010T925 2150L1133 2036Q1079 1948 1047 1853T1016 1637V1408H664Z" />
-<glyph unicode="&#x201d;" horiz-adv-x="1222" d="M562 2048V1820Q562 1725 525 1629T427 1448T300 1306L92 1420Q138 1496 174 1591T210 1819V2048H562ZM1061 2048V1820Q1061 1725 1024 1629T926 1448T799 1306L591 1420Q637 1496 673 1591T709 1819V2048H1061Z" />
-<glyph unicode="&#x201e;" horiz-adv-x="1222" d="M556 256V28Q556 -67 519 -163T421 -344T294 -486L86 -372Q132 -296 168 -201T204 27V256H556ZM1055 256V28Q1055 -67 1018 -163T920 -344T793 -486L585 -372Q631 -296 667 -201T703 27V256H1055Z" />
-<glyph unicode="&#x2022;" horiz-adv-x="1213" d="M606 376Q485 376 385 435T226 595T166 816Q166 938 225 1037T385 1196T606 1256Q728 1256 827 1197T986 1038T1046 816Q1046 695 987 595T828 436T606 376Z" />
-<glyph unicode="&#x2039;" horiz-adv-x="1136" d="M1005 220H560L156 820V840H621L1005 220ZM1005 1448L621 828H156V848L560 1448H1005Z" />
-<glyph unicode="&#x203a;" horiz-adv-x="1136" d="M127 220L511 840H976V820L572 220H127ZM127 1448H572L976 848V828H511L127 1448Z" />
-</font>
-</defs>
-</svg>

BIN
frontend/dist/fonts/inter-v3-latin-800.ttf


BIN
frontend/dist/fonts/inter-v3-latin-800.woff


BIN
frontend/dist/fonts/inter-v3-latin-800.woff2


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