Browse Source

Merge branch 'staging'

Owen Diffey 1 year ago
parent
commit
419623502d
39 changed files with 1726 additions and 665 deletions
  1. 8 1
      .wiki/Configuration.md
  2. 43 0
      CHANGELOG.md
  3. 15 1
      backend/config/template.json
  4. 2 2
      backend/logic/actions/playlists.js
  5. 14 4
      backend/logic/actions/songs.js
  6. 21 5
      backend/logic/actions/stations.js
  7. 60 0
      backend/logic/cache/index.js
  8. 1 1
      backend/logic/db/index.js
  9. 2 1
      backend/logic/db/schemas/station.js
  10. 28 0
      backend/logic/migration/migrations/migration24.js
  11. 146 12
      backend/logic/stations.js
  12. 24 24
      backend/logic/youtube.js
  13. 264 219
      backend/package-lock.json
  14. 10 10
      backend/package.json
  15. 8 2
      frontend/dist/config/template.json
  16. 279 243
      frontend/package-lock.json
  17. 23 23
      frontend/package.json
  18. 40 36
      frontend/src/components/AutoSuggest.vue
  19. 2 2
      frontend/src/components/ChristmasLights.vue
  20. 1 1
      frontend/src/components/MainFooter.vue
  21. 3 2
      frontend/src/components/MainHeader.vue
  22. 3 1
      frontend/src/components/Modal.vue
  23. 1 1
      frontend/src/components/ProfilePicture.vue
  24. 2 1
      frontend/src/components/Queue.vue
  25. 41 1
      frontend/src/components/Request.vue
  26. 1 1
      frontend/src/components/RunJobDropdown.vue
  27. 1 1
      frontend/src/components/SongItem.vue
  28. 3 1
      frontend/src/components/__snapshots__/Modal.spec.ts.snap
  29. 33 1
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  30. 2 1
      frontend/src/components/modals/EditPlaylist/index.vue
  31. 83 43
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  32. 4 4
      frontend/src/components/modals/ImportAlbum.vue
  33. 122 0
      frontend/src/components/modals/ManageStation/Settings.vue
  34. 1 1
      frontend/src/composables/useForm.ts
  35. 90 0
      frontend/src/composables/useYoutubeDirect.ts
  36. 1 1
      frontend/src/index.html
  37. 8 1
      frontend/src/main.ts
  38. 326 17
      frontend/src/pages/Station/index.vue
  39. 10 0
      frontend/vite.config.js

+ 8 - 1
.wiki/Configuration.md

@@ -57,6 +57,11 @@ Location: `backend/config/default.json`
 | `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.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
 
@@ -80,7 +85,6 @@ Location: `frontend/dist/config/default.json`
 | `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.mediasession` | Whether to enable mediasession functionality. |
 | `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. |
@@ -93,6 +97,9 @@ Location: `frontend/dist/config/default.json`
 | `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.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.
 

+ 43 - 0
CHANGELOG.md

@@ -1,5 +1,48 @@
 # Changelog
 
+## [v3.9.0] - 2023-01-01
+
+This release includes all changes from v3.9.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Fixed
+
+- fix: Draggable list items sometimes had wrong key
+- fix: Downgraded axios to 1.1.3 to fix Discogs API requests
+- fix: YouTube API_CALL job would improperly pause the current job
+whilst not waiting for child jobs
+- fix: Add/remove song to/from playlist could throw error if not an official song
+
+## [v3.9.0-rc1] - 2022-12-10
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Added station setting to configure skip vote threshold
+- feat: Added experimental configuration of song weight when autofilling station
+- feat: Added experimental configuration to prevent repeating recently played
+songs in stations
+- feat: Added experimental configuration to add user requested songs above
+autofilled songs in queue
+- feat: Added experimental station mode to allow users to close player
+- feat: Added ability to add songs to queue and playlist with YouTube URL
+- feat: Added experimental configuration to disable YouTube search
+
+### Changed
+
+- refactor: Renamed frontend configuration option `siteSettings.mediasession`
+to `experimental.media_session`
+
+### Fixed
+
+- fix: Unable to bulk update song genres and artists
+- fix: Auto suggest results blocking input
+- fix: useForm original value can be reactive
+- fix: Unable to open Edit Playlist with christmas theme in frontend production
+- fix: Blue profile picture becomes red with christmas theme
+- fix: Christmas lights can overlay and be overlayed by incorrect elements
+
 ## [v3.8.0] - 2022-11-11
 
 This release includes all changes from v3.8.0-rc1 and v3.8.0-rc2.

+ 15 - 1
backend/config/template.json

@@ -114,5 +114,19 @@
 			]
 		}
 	},
-	"configVersion": 11
+	"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
+	}
 }

+ 2 - 2
backend/logic/actions/playlists.js

@@ -1243,7 +1243,7 @@ export default {
 					const { _id, youtubeId, title, artists, thumbnail } = newSong;
 					const { likes, dislikes } = ratings;
 
-					SongsModule.runJob("UPDATE_SONG", { songId: _id });
+					if (_id) SongsModule.runJob("UPDATE_SONG", { songId: _id });
 
 					if (playlist.type === "user-liked") {
 						CacheModule.runJob("PUB", {
@@ -1903,7 +1903,7 @@ export default {
 					if (ratings && (playlist.type === "user-liked" || playlist.type === "user-disliked")) {
 						const { likes, dislikes } = ratings;
 
-						SongsModule.runJob("UPDATE_SONG", { songId: _id });
+						if (_id) SongsModule.runJob("UPDATE_SONG", { songId: _id });
 
 						if (playlist.type === "user-liked") {
 							CacheModule.runJob("PUB", {

+ 14 - 4
backend/logic/actions/songs.js

@@ -1280,8 +1280,13 @@ export default {
 							next(err);
 							return;
 						}
-						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
-						next();
+						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
 					});
 				}
 			],
@@ -1408,8 +1413,13 @@ export default {
 							next(err);
 							return;
 						}
-						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
-						next();
+						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound })
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
 					});
 				}
 			],

+ 21 - 5
backend/logic/actions/stations.js

@@ -296,7 +296,19 @@ CacheModule.runJob("SUB", {
 
 		stationModel.findOne(
 			{ _id: stationId },
-			["_id", "name", "displayName", "description", "type", "privacy", "owner", "requests", "autofill", "theme"],
+			[
+				"_id",
+				"name",
+				"displayName",
+				"description",
+				"type",
+				"privacy",
+				"owner",
+				"requests",
+				"autofill",
+				"theme",
+				"skipVoteThreshold"
+			],
 			(err, station) => {
 				WSModule.runJob("EMIT_TO_ROOMS", {
 					rooms: [`station.${stationId}`, `manage-station.${stationId}`, "admin.stations"],
@@ -820,7 +832,8 @@ export default {
 						owner: station.owner,
 						blacklist: station.blacklist,
 						theme: station.theme,
-						djs: station.djs
+						djs: station.djs,
+						skipVoteThreshold: station.skipVoteThreshold
 					};
 
 					StationsModule.userList[session.socketId] = station._id;
@@ -964,7 +977,8 @@ export default {
 						paused: station.paused,
 						currentSong: station.currentSong,
 						isFavorited: station.isFavorited,
-						djs: station.djs
+						djs: station.djs,
+						skipVoteThreshold: station.skipVoteThreshold
 					};
 
 					next(null, data);
@@ -1322,7 +1336,8 @@ export default {
 				},
 
 				(previousStation, next) => {
-					const { name, displayName, description, privacy, requests, autofill, theme } = newStation;
+					const { name, displayName, description, privacy, requests, autofill, theme, skipVoteThreshold } =
+						newStation;
 					const { enabled, limit, mode } = autofill;
 					// This object makes sure only certain properties can be changed by a user
 					const setObject = {
@@ -1334,7 +1349,8 @@ export default {
 						"autofill.enabled": enabled,
 						"autofill.limit": limit,
 						"autofill.mode": mode,
-						theme
+						theme,
+						skipVoteThreshold
 					};
 
 					stationModel.updateOne({ _id: stationId }, { $set: setObject }, { runValidators: true }, err => {

+ 60 - 0
backend/logic/cache/index.js

@@ -386,6 +386,66 @@ class _CacheModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Adds a value to a list in Redis using LPUSH
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key -  name of the list
+	 * @param {*} payload.value - the value we want to set
+	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	LPUSH(payload) {
+		return new Promise((resolve, reject) => {
+			let { key, value } = payload;
+
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+			// automatically stringify objects and arrays into JSON
+			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
+
+			CacheModule.client
+				.LPUSH(key, value)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
+		});
+	}
+
+	/**
+	 * Gets the length of a Redis list
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key -  name of the list
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	LLEN(payload) {
+		return new Promise((resolve, reject) => {
+			const { key } = payload;
+
+			CacheModule.client
+				.LLEN(key)
+				.then(len => resolve(len))
+				.catch(err => reject(new Error(err)));
+		});
+	}
+
+	/**
+	 * Removes an item from a list using RPOP
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key -  name of the list
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	RPOP(payload) {
+		return new Promise((resolve, reject) => {
+			const { key } = payload;
+
+			CacheModule.client
+				.RPOP(key)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
+		});
+	}
+
 	/**
 	 * Removes a value from a list in Redis
 	 *

+ 1 - 1
backend/logic/db/index.js

@@ -13,7 +13,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	queueSong: 1,
 	report: 6,
 	song: 9,
-	station: 8,
+	station: 9,
 	user: 4,
 	youtubeApiRequest: 1,
 	youtubeVideo: 1,

+ 2 - 1
backend/logic/db/schemas/station.js

@@ -54,5 +54,6 @@ export default {
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange", "red"], default: "blue" },
 	blacklist: [{ type: mongoose.Schema.Types.ObjectId, ref: "playlists" }],
 	djs: [{ type: mongoose.Schema.Types.ObjectId, ref: "users" }],
-	documentVersion: { type: Number, default: 8, required: true }
+	skipVoteThreshold: { type: Number, min: 0, max: 100, default: 50, required: true },
+	documentVersion: { type: Number, default: 9, required: true }
 };

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

@@ -0,0 +1,28 @@
+/**
+ * Migration 24
+ *
+ * Migration for setting station skip vote threshold
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 24. Updating stations with document version 8.`);
+		stationModel.updateMany(
+			{ documentVersion: 8 },
+			{
+				$set: {
+					documentVersion: 9,
+					skipVoteThreshold: 100
+				}
+			},
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 146 - 12
backend/logic/stations.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 
 import CoreClass from "../core";
 
@@ -12,6 +13,7 @@ let WSModule;
 let PlaylistsModule;
 let NotificationsModule;
 let MediaModule;
+let SongsModule;
 
 class _StationsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -34,6 +36,7 @@ class _StationsModule extends CoreClass {
 		PlaylistsModule = this.moduleManager.modules.playlists;
 		NotificationsModule = this.moduleManager.modules.notifications;
 		MediaModule = this.moduleManager.modules.media;
+		SongsModule = this.moduleManager.modules.songs;
 
 		this.userList = {};
 		this.usersPerStation = {};
@@ -533,7 +536,7 @@ class _StationsModule extends CoreClass {
 						} else next(null, playlist.songs, station);
 					},
 
-					(_playlistSongs, station, next) => {
+					async (_playlistSongs, station) => {
 						let playlistSongs = JSON.parse(JSON.stringify(_playlistSongs));
 						if (station.autofill.mode === "sequential") {
 							if (station.currentSongIndex <= playlistSongs.length) {
@@ -544,17 +547,77 @@ class _StationsModule extends CoreClass {
 						const currentRequests = station.queue.filter(song => !song.requestedBy).length;
 						const songsStillNeeded = station.autofill.limit - currentRequests;
 						const currentSongs = station.queue;
-						const currentYoutubeIds = station.queue.map(song => song.youtubeId);
+						let currentYoutubeIds = station.queue.map(song => song.youtubeId);
 						const songsToAdd = [];
 						let lastSongAdded = null;
 
 						if (station.currentSong && station.currentSong.youtubeId)
 							currentYoutubeIds.push(station.currentSong.youtubeId);
 
+						// 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];
+						}
+
+						// Block for experiment: weight_stations
+						if (
+							config.has(`experimental.weight_stations.${stationId}`) &&
+							!!config.get(`experimental.weight_stations.${stationId}`)
+						) {
+							const weightTagName =
+								config.get(`experimental.weight_stations.${stationId}`) === true
+									? "weight"
+									: config.get(`experimental.weight_stations.${stationId}`);
+							const weightMap = {};
+							const getYoutubeIds = playlistSongs
+								.map(playlistSong => playlistSong.youtubeId)
+								.filter(youtubeId => currentYoutubeIds.indexOf(youtubeId) === -1);
+
+							const { songs } = await SongsModule.runJob("GET_SONGS", { youtubeIds: getYoutubeIds });
+
+							const weightRegex = new RegExp(`${weightTagName}\\[(\\d+)\\]`);
+
+							songs.forEach(song => {
+								let weight = 1;
+
+								song.tags.forEach(tag => {
+									const regexResponse = weightRegex.exec(tag);
+									if (regexResponse) weight = Number(regexResponse[1]);
+								});
+
+								if (Number.isNaN(weight)) weight = 1;
+								weight = Math.round(weight);
+								weight = Math.max(1, weight);
+								weight = Math.min(10000, weight);
+
+								weightMap[song.youtubeId] = weight;
+							});
+
+							const adjustedPlaylistSongs = [];
+
+							playlistSongs.forEach(playlistSong => {
+								Array.from({ length: weightMap[playlistSong.youtubeId] }).forEach(() => {
+									adjustedPlaylistSongs.push(playlistSong);
+								});
+							});
+
+							const { array } = await UtilsModule.runJob(
+								"SHUFFLE",
+								{ array: adjustedPlaylistSongs },
+								this
+							);
+
+							playlistSongs = array;
+						}
+
 						playlistSongs.every(song => {
 							if (
 								songsToAdd.length < songsStillNeeded &&
-								currentYoutubeIds.indexOf(song.youtubeId) === -1
+								currentYoutubeIds.indexOf(song.youtubeId) === -1 &&
+								!songsToAdd.find(songToAdd => songToAdd.youtubeId === song.youtubeId)
 							) {
 								lastSongAdded = song;
 								songsToAdd.push(song);
@@ -574,10 +637,10 @@ class _StationsModule extends CoreClass {
 							if (indexOfLastSong !== -1) currentSongIndex = indexOfLastSong;
 						}
 
-						next(null, currentSongs, songsToAdd, currentSongIndex);
+						return { currentSongs, songsToAdd, currentSongIndex };
 					},
 
-					(currentSongs, songsToAdd, currentSongIndex, next) => {
+					({ currentSongs, songsToAdd, currentSongIndex }, next) => {
 						const songs = [];
 						async.eachLimit(
 							songsToAdd.map(song => song.youtubeId),
@@ -830,7 +893,11 @@ class _StationsModule extends CoreClass {
 							err => {
 								if (err) return next(err);
 
-								if (!station.paused && users.length <= skipVotes) shouldSkip = true;
+								if (
+									!station.paused &&
+									Math.min(skipVotes, users.length) / users.length >= station.skipVoteThreshold / 100
+								)
+									shouldSkip = true;
 								return next(null, shouldSkip);
 							}
 						);
@@ -931,11 +998,46 @@ class _StationsModule extends CoreClass {
 							});
 					},
 
-					(song, station, next) => {
+					async (song, station) => {
 						const $set = {};
 
 						if (song === null) $set.currentSong = null;
 						else {
+							// Block for experiment: queue_autofill_skip_last_x_played
+							if (config.has(`experimental.queue_autofill_skip_last_x_played.${payload.stationId}`)) {
+								const redisList = `experimental:queue_autofill_skip_last_x_played:${payload.stationId}`;
+								const maxListLength = Number(
+									config.get(`experimental.queue_autofill_skip_last_x_played.${payload.stationId}`)
+								);
+
+								// Add youtubeId 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
+									},
+									this
+								);
+
+								const currentListLength = await CacheModule.runJob("LLEN", { key: redisList }, this);
+
+								// Removes oldest youtubeId from list for this station in Redis list
+								if (currentListLength > maxListLength) {
+									const amount = currentListLength - maxListLength;
+									const promises = Array.from({ length: amount }).map(() =>
+										CacheModule.runJob(
+											"RPOP",
+											{
+												key: `experimental:queue_autofill_skip_last_x_played:${payload.stationId}`
+											},
+											this
+										)
+									);
+									await Promise.all(promises);
+								}
+							}
+
 							$set.currentSong = {
 								_id: song._id,
 								youtubeId: song.youtubeId,
@@ -953,10 +1055,10 @@ class _StationsModule extends CoreClass {
 						$set.startedAt = Date.now();
 						$set.timePaused = 0;
 						if (station.paused) $set.pausedAt = Date.now();
-						next(null, $set, station);
+						return { $set, station };
 					},
 
-					($set, station, next) => {
+					({ $set, station }, next) => {
 						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
 							if (err) return next(err);
 
@@ -1804,14 +1906,14 @@ class _StationsModule extends CoreClass {
 					(song, station, next) => {
 						song.requestedBy = requestUser;
 						song.requestedAt = Date.now();
-						if (station.queue.length === 0) return next(null, song);
+						if (station.queue.length === 0) return next(null, song, station);
 						if (
 							requestUser &&
 							station.queue.filter(queueSong => queueSong.requestedBy === song.requestedBy).length >=
 								station.requests.limit
 						)
 							return next(`The max amount of songs per user is ${station.requests.limit}.`);
-						return next(null, song);
+						return next(null, song, station);
 					},
 
 					// (song, station, next) => {
@@ -1861,7 +1963,39 @@ class _StationsModule extends CoreClass {
 					// 	return next(null, song);
 					// },
 
-					(song, next) => {
+					(song, station, next) => {
+						if (config.has(`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;
+								}
+
+								StationsModule.stationModel.updateOne(
+									{ _id: stationId },
+									{
+										$push: {
+											queue: {
+												$each: [song],
+												$position: position
+											}
+										}
+									},
+									{ runValidators: true },
+									next
+								);
+
+								return;
+							}
+						}
+
 						StationsModule.stationModel.updateOne(
 							{ _id: stationId },
 							{ $push: { queue: song } },

+ 24 - 24
backend/logic/youtube.js

@@ -216,6 +216,7 @@ class _YouTubeModule extends CoreClass {
 				})
 				.catch(err => {
 					YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
+					if (err.message === "Searching with YouTube is disabled.") return reject(err);
 					return reject(new Error("An error has occured. Please try again later."));
 				});
 		});
@@ -498,9 +499,9 @@ class _YouTubeModule extends CoreClass {
 				})
 				.catch(err => {
 					YouTubeModule.log("ERROR", "GET_CHANNEL_UPLOADS_PLAYLIST_ID", `${err.message}`);
-					if (err.message === "Request failed with status code 404") {
+					if (err.message === "Request failed with status code 404")
 						return reject(new Error("Channel not found. Is the channel public/unlisted?"));
-					}
+					if (err.message === "Searching with YouTube is disabled.") return reject(err);
 					return reject(new Error("An error has occured. Please try again later."));
 				});
 		});
@@ -588,9 +589,9 @@ class _YouTubeModule extends CoreClass {
 				(err, channelId) => {
 					if (err) {
 						YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message || err}`);
-						if (err.message === "Request failed with status code 404") {
+						if (err.message === "Request failed with status code 404")
 							return reject(new Error("Channel not found. Is the channel public/unlisted?"));
-						}
+						if (err.message === "Searching with YouTube is disabled.") return reject(err);
 						return reject(new Error("An error has occured. Please try again later."));
 					}
 
@@ -714,9 +715,8 @@ class _YouTubeModule extends CoreClass {
 				})
 				.catch(err => {
 					YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
-					if (err.message === "Request failed with status code 404") {
+					if (err.message === "Request failed with status code 404")
 						return reject(new Error("Playlist not found. Is the playlist public/unlisted?"));
-					}
 					return reject(new Error("An error has occured. Please try again later."));
 				});
 		});
@@ -1014,6 +1014,14 @@ 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")
+			) {
+				reject(new Error("Searching with YouTube is disabled."));
+				return;
+			}
+
 			YouTubeModule.runJob(
 				"API_CALL",
 				{
@@ -1061,15 +1069,11 @@ class _YouTubeModule extends CoreClass {
 				youtubeApiRequest.save();
 
 				const { ...keylessParams } = payload.params;
-				CacheModule.runJob(
-					"HSET",
-					{
-						table: "youtubeApiRequestParams",
-						key: youtubeApiRequest._id.toString(),
-						value: JSON.stringify(keylessParams)
-					},
-					this
-				).then();
+				CacheModule.runJob("HSET", {
+					table: "youtubeApiRequestParams",
+					key: youtubeApiRequest._id.toString(),
+					value: JSON.stringify(keylessParams)
+				}).then();
 
 				YouTubeModule.apiCalls.push({ date: youtubeApiRequest.date, quotaCost });
 
@@ -1082,15 +1086,11 @@ class _YouTubeModule extends CoreClass {
 						if (response.data.error) {
 							reject(new Error(response.data.error));
 						} else {
-							CacheModule.runJob(
-								"HSET",
-								{
-									table: "youtubeApiRequestResults",
-									key: youtubeApiRequest._id.toString(),
-									value: JSON.stringify(response.data)
-								},
-								this
-							).then();
+							CacheModule.runJob("HSET", {
+								table: "youtubeApiRequestResults",
+								key: youtubeApiRequest._id.toString(),
+								value: JSON.stringify(response.data)
+							}).then();
 
 							resolve({ response });
 						}

+ 264 - 219
backend/package-lock.json

@@ -1,16 +1,16 @@
 {
   "name": "musare-backend",
-  "version": "3.8.0",
+  "version": "3.9.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "musare-backend",
-      "version": "3.8.0",
+      "version": "3.9.0",
       "license": "GPL-3.0",
       "dependencies": {
         "async": "^3.2.4",
-        "axios": "^1.1.2",
+        "axios": "^1.1.3",
         "bcrypt": "^5.1.0",
         "bluebird": "^3.7.2",
         "body-parser": "^1.20.1",
@@ -22,27 +22,27 @@
         "mongoose": "^6.6.5",
         "nodemailer": "^6.8.0",
         "oauth": "^0.10.0",
-        "redis": "^4.3.1",
+        "redis": "^4.5.1",
         "retry-axios": "^3.0.0",
         "sha256": "^0.2.0",
         "socks": "^2.7.1",
         "underscore": "^1.13.6",
-        "ws": "^8.9.0"
+        "ws": "^8.11.0"
       },
       "devDependencies": {
-        "@typescript-eslint/eslint-plugin": "^5.40.0",
-        "@typescript-eslint/parser": "^5.40.0",
-        "eslint": "^8.25.0",
+        "@typescript-eslint/eslint-plugin": "^5.45.0",
+        "@typescript-eslint/parser": "^5.45.0",
+        "eslint": "^8.28.0",
         "eslint-config-airbnb-base": "^15.0.0",
         "eslint-config-prettier": "^8.5.0",
         "eslint-plugin-import": "^2.26.0",
-        "eslint-plugin-jsdoc": "^39.3.6",
+        "eslint-plugin-jsdoc": "^39.6.4",
         "eslint-plugin-prettier": "^4.2.1",
         "nodemon": "^2.0.20",
-        "prettier": "2.7.1",
+        "prettier": "2.8.0",
         "trace-unhandled": "^2.0.1",
         "ts-node": "^10.9.1",
-        "typescript": "^4.8.4"
+        "typescript": "^4.9.3"
       }
     },
     "node_modules/@cspotcode/source-map-support": {
@@ -58,9 +58,9 @@
       }
     },
     "node_modules/@es-joy/jsdoccomment": {
-      "version": "0.31.0",
-      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz",
-      "integrity": "sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ==",
+      "version": "0.36.1",
+      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz",
+      "integrity": "sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg==",
       "dev": true,
       "dependencies": {
         "comment-parser": "1.3.1",
@@ -68,7 +68,7 @@
         "jsdoc-type-pratt-parser": "~3.1.0"
       },
       "engines": {
-        "node": "^14 || ^16 || ^17 || ^18"
+        "node": "^14 || ^16 || ^17 || ^18 || ^19"
       }
     },
     "node_modules/@eslint/eslintrc": {
@@ -118,14 +118,14 @@
       "dev": true
     },
     "node_modules/@humanwhocodes/config-array": {
-      "version": "0.10.7",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz",
-      "integrity": "sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w==",
+      "version": "0.11.7",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz",
+      "integrity": "sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==",
       "dev": true,
       "dependencies": {
         "@humanwhocodes/object-schema": "^1.2.1",
         "debug": "^4.1.1",
-        "minimatch": "^3.0.4"
+        "minimatch": "^3.0.5"
       },
       "engines": {
         "node": ">=10.10.0"
@@ -253,20 +253,20 @@
       }
     },
     "node_modules/@redis/bloom": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.0.2.tgz",
-      "integrity": "sha512-EBw7Ag1hPgFzdznK2PBblc1kdlj5B5Cw3XwI9/oG7tSn85/HKy3X9xHy/8tm/eNXJYHLXHJL/pkwBpFMVVefkw==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.1.0.tgz",
+      "integrity": "sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==",
       "peerDependencies": {
         "@redis/client": "^1.0.0"
       }
     },
     "node_modules/@redis/client": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.3.0.tgz",
-      "integrity": "sha512-XCFV60nloXAefDsPnYMjHGtvbtHR8fV5Om8cQ0JYqTNbWcQo/4AryzJ2luRj4blveWazRK/j40gES8M7Cp6cfQ==",
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz",
+      "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==",
       "dependencies": {
-        "cluster-key-slot": "1.1.0",
-        "generic-pool": "3.8.2",
+        "cluster-key-slot": "1.1.1",
+        "generic-pool": "3.9.0",
         "yallist": "4.0.0"
       },
       "engines": {
@@ -274,9 +274,9 @@
       }
     },
     "node_modules/@redis/graph": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.0.1.tgz",
-      "integrity": "sha512-oDE4myMCJOCVKYMygEMWuriBgqlS5FqdWerikMoJxzmmTUErnTRRgmIDa2VcgytACZMFqpAOWDzops4DOlnkfQ==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz",
+      "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==",
       "peerDependencies": {
         "@redis/client": "^1.0.0"
       }
@@ -298,9 +298,9 @@
       }
     },
     "node_modules/@redis/time-series": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.3.tgz",
-      "integrity": "sha512-OFp0q4SGrTH0Mruf6oFsHGea58u8vS/iI5+NpYdicaM+7BgqBZH8FFvNZ8rYYLrUO/QRqMq72NpXmxLVNcdmjA==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz",
+      "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==",
       "peerDependencies": {
         "@redis/client": "^1.0.0"
       }
@@ -346,6 +346,12 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.13.tgz",
       "integrity": "sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw=="
     },
+    "node_modules/@types/semver": {
+      "version": "7.3.13",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
+      "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
+      "dev": true
+    },
     "node_modules/@types/webidl-conversions": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -361,16 +367,17 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.40.0.tgz",
-      "integrity": "sha512-FIBZgS3DVJgqPwJzvZTuH4HNsZhHMa9SjxTKAZTlMsPw/UzpEjcf9f4dfgDJEHjK+HboUJo123Eshl6niwEm/Q==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.45.0.tgz",
+      "integrity": "sha512-CXXHNlf0oL+Yg021cxgOdMHNTXD17rHkq7iW6RFHoybdFgQBjU3yIXhhcPpGwr1CjZlo6ET8C6tzX5juQoXeGA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.40.0",
-        "@typescript-eslint/type-utils": "5.40.0",
-        "@typescript-eslint/utils": "5.40.0",
+        "@typescript-eslint/scope-manager": "5.45.0",
+        "@typescript-eslint/type-utils": "5.45.0",
+        "@typescript-eslint/utils": "5.45.0",
         "debug": "^4.3.4",
         "ignore": "^5.2.0",
+        "natural-compare-lite": "^1.4.0",
         "regexpp": "^3.2.0",
         "semver": "^7.3.7",
         "tsutils": "^3.21.0"
@@ -416,14 +423,14 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.40.0.tgz",
-      "integrity": "sha512-Ah5gqyX2ySkiuYeOIDg7ap51/b63QgWZA7w6AHtFrag7aH0lRQPbLzUjk0c9o5/KZ6JRkTTDKShL4AUrQa6/hw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.45.0.tgz",
+      "integrity": "sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.40.0",
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/typescript-estree": "5.40.0",
+        "@typescript-eslint/scope-manager": "5.45.0",
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/typescript-estree": "5.45.0",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -466,13 +473,13 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.40.0.tgz",
-      "integrity": "sha512-d3nPmjUeZtEWRvyReMI4I1MwPGC63E8pDoHy0BnrYjnJgilBD3hv7XOiETKLY/zTwI7kCnBDf2vWTRUVpYw0Uw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.45.0.tgz",
+      "integrity": "sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/visitor-keys": "5.40.0"
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/visitor-keys": "5.45.0"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -483,13 +490,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.40.0.tgz",
-      "integrity": "sha512-nfuSdKEZY2TpnPz5covjJqav+g5qeBqwSHKBvz7Vm1SAfy93SwKk/JeSTymruDGItTwNijSsno5LhOHRS1pcfw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.45.0.tgz",
+      "integrity": "sha512-DY7BXVFSIGRGFZ574hTEyLPRiQIvI/9oGcN8t1A7f6zIs6ftbrU0nhyV26ZW//6f85avkwrLag424n+fkuoJ1Q==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "5.40.0",
-        "@typescript-eslint/utils": "5.40.0",
+        "@typescript-eslint/typescript-estree": "5.45.0",
+        "@typescript-eslint/utils": "5.45.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -533,9 +540,9 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.40.0.tgz",
-      "integrity": "sha512-V1KdQRTXsYpf1Y1fXCeZ+uhjW48Niiw0VGt4V8yzuaDTU8Z1Xl7yQDyQNqyAFcVhpYXIVCEuxSIWTsLDpHgTbw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.45.0.tgz",
+      "integrity": "sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -546,13 +553,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.40.0.tgz",
-      "integrity": "sha512-b0GYlDj8TLTOqwX7EGbw2gL5EXS2CPEWhF9nGJiGmEcmlpNBjyHsTwbqpyIEPVpl6br4UcBOYlcI2FJVtJkYhg==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.45.0.tgz",
+      "integrity": "sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/visitor-keys": "5.40.0",
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/visitor-keys": "5.45.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -596,15 +603,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.40.0.tgz",
-      "integrity": "sha512-MO0y3T5BQ5+tkkuYZJBjePewsY+cQnfkYeRqS6tPh28niiIwPnQ1t59CSRcs1ZwJJNOdWw7rv9pF8aP58IMihA==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.45.0.tgz",
+      "integrity": "sha512-OUg2JvsVI1oIee/SwiejTot2OxwU8a7UfTFMOdlhD2y+Hl6memUSL4s98bpUTo8EpVEr0lmwlU7JSu/p2QpSvA==",
       "dev": true,
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.40.0",
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/typescript-estree": "5.40.0",
+        "@types/semver": "^7.3.12",
+        "@typescript-eslint/scope-manager": "5.45.0",
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/typescript-estree": "5.45.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0",
         "semver": "^7.3.7"
@@ -643,12 +651,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.40.0.tgz",
-      "integrity": "sha512-ijJ+6yig+x9XplEpG2K6FUdJeQGGj/15U3S56W9IqXKJqleuD7zJ2AX/miLezwxpd7ZxDAqO87zWufKg+RPZyQ==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.45.0.tgz",
+      "integrity": "sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.40.0",
+        "@typescript-eslint/types": "5.45.0",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -881,9 +889,9 @@
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
     },
     "node_modules/axios": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.2.tgz",
-      "integrity": "sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz",
+      "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==",
       "dependencies": {
         "follow-redirects": "^1.15.0",
         "form-data": "^4.0.0",
@@ -1112,9 +1120,9 @@
       }
     },
     "node_modules/cluster-key-slot": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
-      "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
+      "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -1486,14 +1494,15 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.25.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.25.0.tgz",
-      "integrity": "sha512-DVlJOZ4Pn50zcKW5bYH7GQK/9MsoQG2d5eDH0ebEkE8PbgzTTmtt/VTH9GGJ4BfeZCpBLqFfvsjX35UacUL83A==",
+      "version": "8.28.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.28.0.tgz",
+      "integrity": "sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==",
       "dev": true,
       "dependencies": {
         "@eslint/eslintrc": "^1.3.3",
-        "@humanwhocodes/config-array": "^0.10.5",
+        "@humanwhocodes/config-array": "^0.11.6",
         "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
         "ajv": "^6.10.0",
         "chalk": "^4.0.0",
         "cross-spawn": "^7.0.2",
@@ -1509,14 +1518,14 @@
         "fast-deep-equal": "^3.1.3",
         "file-entry-cache": "^6.0.1",
         "find-up": "^5.0.0",
-        "glob-parent": "^6.0.1",
+        "glob-parent": "^6.0.2",
         "globals": "^13.15.0",
-        "globby": "^11.1.0",
         "grapheme-splitter": "^1.0.4",
         "ignore": "^5.2.0",
         "import-fresh": "^3.0.0",
         "imurmurhash": "^0.1.4",
         "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
         "js-sdsl": "^4.1.4",
         "js-yaml": "^4.1.0",
         "json-stable-stringify-without-jsonify": "^1.0.1",
@@ -1673,21 +1682,21 @@
       }
     },
     "node_modules/eslint-plugin-jsdoc": {
-      "version": "39.3.6",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz",
-      "integrity": "sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g==",
+      "version": "39.6.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.6.4.tgz",
+      "integrity": "sha512-fskvdLCfwmPjHb6e+xNGDtGgbF8X7cDwMtVLAP2WwSf9Htrx68OAx31BESBM1FAwsN2HTQyYQq7m4aW4Q4Nlag==",
       "dev": true,
       "dependencies": {
-        "@es-joy/jsdoccomment": "~0.31.0",
+        "@es-joy/jsdoccomment": "~0.36.1",
         "comment-parser": "1.3.1",
         "debug": "^4.3.4",
         "escape-string-regexp": "^4.0.0",
         "esquery": "^1.4.0",
-        "semver": "^7.3.7",
+        "semver": "^7.3.8",
         "spdx-expression-parse": "^3.0.1"
       },
       "engines": {
-        "node": "^14 || ^16 || ^17 || ^18"
+        "node": "^14 || ^16 || ^17 || ^18 || ^19"
       },
       "peerDependencies": {
         "eslint": "^7.0.0 || ^8.0.0"
@@ -2008,9 +2017,9 @@
       "dev": true
     },
     "node_modules/fast-glob": {
-      "version": "3.2.11",
-      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
-      "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
+      "version": "3.2.12",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
+      "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
       "dev": true,
       "dependencies": {
         "@nodelib/fs.stat": "^2.0.2",
@@ -2271,9 +2280,9 @@
       }
     },
     "node_modules/generic-pool": {
-      "version": "3.8.2",
-      "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz",
-      "integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg==",
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+      "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
       "engines": {
         "node": ">= 4"
       }
@@ -2767,6 +2776,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/is-regex": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
@@ -3219,6 +3237,12 @@
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
+    "node_modules/natural-compare-lite": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
+      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
+      "dev": true
+    },
     "node_modules/negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -3621,9 +3645,9 @@
       }
     },
     "node_modules/prettier": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
-      "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
+      "integrity": "sha512-9Lmg8hTFZKG0Asr/kW9Bp8tJjRVluO8EJQVfY2T7FMw9T5jy4I/Uvx0Rca/XWf50QQ1/SS48+6IJWnrb+2yemA==",
       "dev": true,
       "bin": {
         "prettier": "bin-prettier.js"
@@ -3760,16 +3784,16 @@
       }
     },
     "node_modules/redis": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/redis/-/redis-4.3.1.tgz",
-      "integrity": "sha512-cM7yFU5CA6zyCF7N/+SSTcSJQSRMEKN0k0Whhu6J7n9mmXRoXugfWDBo5iOzGwABmsWKSwGPTU5J4Bxbl+0mrA==",
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-4.5.1.tgz",
+      "integrity": "sha512-oxXSoIqMJCQVBTfxP6BNTCtDMyh9G6Vi5wjdPdV/sRKkufyZslDqCScSGcOr6XGR/reAWZefz7E4leM31RgdBA==",
       "dependencies": {
-        "@redis/bloom": "1.0.2",
-        "@redis/client": "1.3.0",
-        "@redis/graph": "1.0.1",
+        "@redis/bloom": "1.1.0",
+        "@redis/client": "1.4.2",
+        "@redis/graph": "1.1.0",
         "@redis/json": "1.0.4",
         "@redis/search": "1.1.0",
-        "@redis/time-series": "1.0.3"
+        "@redis/time-series": "1.0.4"
       }
     },
     "node_modules/regexp.prototype.flags": {
@@ -3922,9 +3946,9 @@
       }
     },
     "node_modules/semver": {
-      "version": "7.3.7",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
-      "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+      "version": "7.3.8",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+      "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
       "dependencies": {
         "lru-cache": "^6.0.0"
       },
@@ -4473,9 +4497,9 @@
       }
     },
     "node_modules/typescript": {
-      "version": "4.8.4",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
-      "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+      "version": "4.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
+      "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
       "dev": true,
       "bin": {
         "tsc": "bin/tsc",
@@ -4629,9 +4653,9 @@
       "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
     },
     "node_modules/ws": {
-      "version": "8.9.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz",
-      "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==",
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+      "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
       "engines": {
         "node": ">=10.0.0"
       },
@@ -4686,9 +4710,9 @@
       }
     },
     "@es-joy/jsdoccomment": {
-      "version": "0.31.0",
-      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz",
-      "integrity": "sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ==",
+      "version": "0.36.1",
+      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz",
+      "integrity": "sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg==",
       "dev": true,
       "requires": {
         "comment-parser": "1.3.1",
@@ -4731,14 +4755,14 @@
       }
     },
     "@humanwhocodes/config-array": {
-      "version": "0.10.7",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz",
-      "integrity": "sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w==",
+      "version": "0.11.7",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz",
+      "integrity": "sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==",
       "dev": true,
       "requires": {
         "@humanwhocodes/object-schema": "^1.2.1",
         "debug": "^4.1.1",
-        "minimatch": "^3.0.4"
+        "minimatch": "^3.0.5"
       },
       "dependencies": {
         "debug": {
@@ -4835,25 +4859,25 @@
       }
     },
     "@redis/bloom": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.0.2.tgz",
-      "integrity": "sha512-EBw7Ag1hPgFzdznK2PBblc1kdlj5B5Cw3XwI9/oG7tSn85/HKy3X9xHy/8tm/eNXJYHLXHJL/pkwBpFMVVefkw==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.1.0.tgz",
+      "integrity": "sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==",
       "requires": {}
     },
     "@redis/client": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.3.0.tgz",
-      "integrity": "sha512-XCFV60nloXAefDsPnYMjHGtvbtHR8fV5Om8cQ0JYqTNbWcQo/4AryzJ2luRj4blveWazRK/j40gES8M7Cp6cfQ==",
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz",
+      "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==",
       "requires": {
-        "cluster-key-slot": "1.1.0",
-        "generic-pool": "3.8.2",
+        "cluster-key-slot": "1.1.1",
+        "generic-pool": "3.9.0",
         "yallist": "4.0.0"
       }
     },
     "@redis/graph": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.0.1.tgz",
-      "integrity": "sha512-oDE4myMCJOCVKYMygEMWuriBgqlS5FqdWerikMoJxzmmTUErnTRRgmIDa2VcgytACZMFqpAOWDzops4DOlnkfQ==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz",
+      "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==",
       "requires": {}
     },
     "@redis/json": {
@@ -4869,9 +4893,9 @@
       "requires": {}
     },
     "@redis/time-series": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.3.tgz",
-      "integrity": "sha512-OFp0q4SGrTH0Mruf6oFsHGea58u8vS/iI5+NpYdicaM+7BgqBZH8FFvNZ8rYYLrUO/QRqMq72NpXmxLVNcdmjA==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz",
+      "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==",
       "requires": {}
     },
     "@tsconfig/node10": {
@@ -4915,6 +4939,12 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.13.tgz",
       "integrity": "sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw=="
     },
+    "@types/semver": {
+      "version": "7.3.13",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
+      "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
+      "dev": true
+    },
     "@types/webidl-conversions": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -4930,16 +4960,17 @@
       }
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.40.0.tgz",
-      "integrity": "sha512-FIBZgS3DVJgqPwJzvZTuH4HNsZhHMa9SjxTKAZTlMsPw/UzpEjcf9f4dfgDJEHjK+HboUJo123Eshl6niwEm/Q==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.45.0.tgz",
+      "integrity": "sha512-CXXHNlf0oL+Yg021cxgOdMHNTXD17rHkq7iW6RFHoybdFgQBjU3yIXhhcPpGwr1CjZlo6ET8C6tzX5juQoXeGA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.40.0",
-        "@typescript-eslint/type-utils": "5.40.0",
-        "@typescript-eslint/utils": "5.40.0",
+        "@typescript-eslint/scope-manager": "5.45.0",
+        "@typescript-eslint/type-utils": "5.45.0",
+        "@typescript-eslint/utils": "5.45.0",
         "debug": "^4.3.4",
         "ignore": "^5.2.0",
+        "natural-compare-lite": "^1.4.0",
         "regexpp": "^3.2.0",
         "semver": "^7.3.7",
         "tsutils": "^3.21.0"
@@ -4963,14 +4994,14 @@
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.40.0.tgz",
-      "integrity": "sha512-Ah5gqyX2ySkiuYeOIDg7ap51/b63QgWZA7w6AHtFrag7aH0lRQPbLzUjk0c9o5/KZ6JRkTTDKShL4AUrQa6/hw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.45.0.tgz",
+      "integrity": "sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.40.0",
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/typescript-estree": "5.40.0",
+        "@typescript-eslint/scope-manager": "5.45.0",
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/typescript-estree": "5.45.0",
         "debug": "^4.3.4"
       },
       "dependencies": {
@@ -4992,23 +5023,23 @@
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.40.0.tgz",
-      "integrity": "sha512-d3nPmjUeZtEWRvyReMI4I1MwPGC63E8pDoHy0BnrYjnJgilBD3hv7XOiETKLY/zTwI7kCnBDf2vWTRUVpYw0Uw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.45.0.tgz",
+      "integrity": "sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/visitor-keys": "5.40.0"
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/visitor-keys": "5.45.0"
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.40.0.tgz",
-      "integrity": "sha512-nfuSdKEZY2TpnPz5covjJqav+g5qeBqwSHKBvz7Vm1SAfy93SwKk/JeSTymruDGItTwNijSsno5LhOHRS1pcfw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.45.0.tgz",
+      "integrity": "sha512-DY7BXVFSIGRGFZ574hTEyLPRiQIvI/9oGcN8t1A7f6zIs6ftbrU0nhyV26ZW//6f85avkwrLag424n+fkuoJ1Q==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/typescript-estree": "5.40.0",
-        "@typescript-eslint/utils": "5.40.0",
+        "@typescript-eslint/typescript-estree": "5.45.0",
+        "@typescript-eslint/utils": "5.45.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -5031,19 +5062,19 @@
       }
     },
     "@typescript-eslint/types": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.40.0.tgz",
-      "integrity": "sha512-V1KdQRTXsYpf1Y1fXCeZ+uhjW48Niiw0VGt4V8yzuaDTU8Z1Xl7yQDyQNqyAFcVhpYXIVCEuxSIWTsLDpHgTbw==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.45.0.tgz",
+      "integrity": "sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.40.0.tgz",
-      "integrity": "sha512-b0GYlDj8TLTOqwX7EGbw2gL5EXS2CPEWhF9nGJiGmEcmlpNBjyHsTwbqpyIEPVpl6br4UcBOYlcI2FJVtJkYhg==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.45.0.tgz",
+      "integrity": "sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/visitor-keys": "5.40.0",
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/visitor-keys": "5.45.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -5069,15 +5100,16 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.40.0.tgz",
-      "integrity": "sha512-MO0y3T5BQ5+tkkuYZJBjePewsY+cQnfkYeRqS6tPh28niiIwPnQ1t59CSRcs1ZwJJNOdWw7rv9pF8aP58IMihA==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.45.0.tgz",
+      "integrity": "sha512-OUg2JvsVI1oIee/SwiejTot2OxwU8a7UfTFMOdlhD2y+Hl6memUSL4s98bpUTo8EpVEr0lmwlU7JSu/p2QpSvA==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.40.0",
-        "@typescript-eslint/types": "5.40.0",
-        "@typescript-eslint/typescript-estree": "5.40.0",
+        "@types/semver": "^7.3.12",
+        "@typescript-eslint/scope-manager": "5.45.0",
+        "@typescript-eslint/types": "5.45.0",
+        "@typescript-eslint/typescript-estree": "5.45.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0",
         "semver": "^7.3.7"
@@ -5102,12 +5134,12 @@
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.40.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.40.0.tgz",
-      "integrity": "sha512-ijJ+6yig+x9XplEpG2K6FUdJeQGGj/15U3S56W9IqXKJqleuD7zJ2AX/miLezwxpd7ZxDAqO87zWufKg+RPZyQ==",
+      "version": "5.45.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.45.0.tgz",
+      "integrity": "sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.40.0",
+        "@typescript-eslint/types": "5.45.0",
         "eslint-visitor-keys": "^3.3.0"
       }
     },
@@ -5276,9 +5308,9 @@
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
     },
     "axios": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.2.tgz",
-      "integrity": "sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz",
+      "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==",
       "requires": {
         "follow-redirects": "^1.15.0",
         "form-data": "^4.0.0",
@@ -5432,9 +5464,9 @@
       "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
     },
     "cluster-key-slot": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
-      "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw=="
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
+      "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="
     },
     "color-convert": {
       "version": "2.0.1",
@@ -5718,14 +5750,15 @@
       "dev": true
     },
     "eslint": {
-      "version": "8.25.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.25.0.tgz",
-      "integrity": "sha512-DVlJOZ4Pn50zcKW5bYH7GQK/9MsoQG2d5eDH0ebEkE8PbgzTTmtt/VTH9GGJ4BfeZCpBLqFfvsjX35UacUL83A==",
+      "version": "8.28.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.28.0.tgz",
+      "integrity": "sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==",
       "dev": true,
       "requires": {
         "@eslint/eslintrc": "^1.3.3",
-        "@humanwhocodes/config-array": "^0.10.5",
+        "@humanwhocodes/config-array": "^0.11.6",
         "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
         "ajv": "^6.10.0",
         "chalk": "^4.0.0",
         "cross-spawn": "^7.0.2",
@@ -5741,14 +5774,14 @@
         "fast-deep-equal": "^3.1.3",
         "file-entry-cache": "^6.0.1",
         "find-up": "^5.0.0",
-        "glob-parent": "^6.0.1",
+        "glob-parent": "^6.0.2",
         "globals": "^13.15.0",
-        "globby": "^11.1.0",
         "grapheme-splitter": "^1.0.4",
         "ignore": "^5.2.0",
         "import-fresh": "^3.0.0",
         "imurmurhash": "^0.1.4",
         "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
         "js-sdsl": "^4.1.4",
         "js-yaml": "^4.1.0",
         "json-stable-stringify-without-jsonify": "^1.0.1",
@@ -5937,17 +5970,17 @@
       }
     },
     "eslint-plugin-jsdoc": {
-      "version": "39.3.6",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz",
-      "integrity": "sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g==",
+      "version": "39.6.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.6.4.tgz",
+      "integrity": "sha512-fskvdLCfwmPjHb6e+xNGDtGgbF8X7cDwMtVLAP2WwSf9Htrx68OAx31BESBM1FAwsN2HTQyYQq7m4aW4Q4Nlag==",
       "dev": true,
       "requires": {
-        "@es-joy/jsdoccomment": "~0.31.0",
+        "@es-joy/jsdoccomment": "~0.36.1",
         "comment-parser": "1.3.1",
         "debug": "^4.3.4",
         "escape-string-regexp": "^4.0.0",
         "esquery": "^1.4.0",
-        "semver": "^7.3.7",
+        "semver": "^7.3.8",
         "spdx-expression-parse": "^3.0.1"
       },
       "dependencies": {
@@ -6114,9 +6147,9 @@
       "dev": true
     },
     "fast-glob": {
-      "version": "3.2.11",
-      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
-      "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
+      "version": "3.2.12",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
+      "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
       "dev": true,
       "requires": {
         "@nodelib/fs.stat": "^2.0.2",
@@ -6310,9 +6343,9 @@
       }
     },
     "generic-pool": {
-      "version": "3.8.2",
-      "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz",
-      "integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg=="
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+      "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="
     },
     "get-intrinsic": {
       "version": "1.1.1",
@@ -6648,6 +6681,12 @@
         "has-tostringtag": "^1.0.0"
       }
     },
+    "is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true
+    },
     "is-regex": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
@@ -6981,6 +7020,12 @@
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
+    "natural-compare-lite": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
+      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
+      "dev": true
+    },
     "negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -7276,9 +7321,9 @@
       "dev": true
     },
     "prettier": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
-      "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
+      "integrity": "sha512-9Lmg8hTFZKG0Asr/kW9Bp8tJjRVluO8EJQVfY2T7FMw9T5jy4I/Uvx0Rca/XWf50QQ1/SS48+6IJWnrb+2yemA==",
       "dev": true
     },
     "prettier-linter-helpers": {
@@ -7365,16 +7410,16 @@
       }
     },
     "redis": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/redis/-/redis-4.3.1.tgz",
-      "integrity": "sha512-cM7yFU5CA6zyCF7N/+SSTcSJQSRMEKN0k0Whhu6J7n9mmXRoXugfWDBo5iOzGwABmsWKSwGPTU5J4Bxbl+0mrA==",
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-4.5.1.tgz",
+      "integrity": "sha512-oxXSoIqMJCQVBTfxP6BNTCtDMyh9G6Vi5wjdPdV/sRKkufyZslDqCScSGcOr6XGR/reAWZefz7E4leM31RgdBA==",
       "requires": {
-        "@redis/bloom": "1.0.2",
-        "@redis/client": "1.3.0",
-        "@redis/graph": "1.0.1",
+        "@redis/bloom": "1.1.0",
+        "@redis/client": "1.4.2",
+        "@redis/graph": "1.1.0",
         "@redis/json": "1.0.4",
         "@redis/search": "1.1.0",
-        "@redis/time-series": "1.0.3"
+        "@redis/time-series": "1.0.4"
       }
     },
     "regexp.prototype.flags": {
@@ -7460,9 +7505,9 @@
       }
     },
     "semver": {
-      "version": "7.3.7",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
-      "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+      "version": "7.3.8",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+      "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
       "requires": {
         "lru-cache": "^6.0.0"
       }
@@ -7875,9 +7920,9 @@
       }
     },
     "typescript": {
-      "version": "4.8.4",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
-      "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+      "version": "4.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
+      "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
       "dev": true
     },
     "unbox-primitive": {
@@ -7994,9 +8039,9 @@
       "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
     },
     "ws": {
-      "version": "8.9.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz",
-      "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==",
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+      "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
       "requires": {}
     },
     "yallist": {

+ 10 - 10
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.8.0",
+  "version": "3.9.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -17,7 +17,7 @@
   },
   "dependencies": {
     "async": "^3.2.4",
-    "axios": "^1.1.2",
+    "axios": "^1.1.3",
     "bcrypt": "^5.1.0",
     "bluebird": "^3.7.2",
     "body-parser": "^1.20.1",
@@ -29,26 +29,26 @@
     "mongoose": "^6.6.5",
     "nodemailer": "^6.8.0",
     "oauth": "^0.10.0",
-    "redis": "^4.3.1",
+    "redis": "^4.5.1",
     "retry-axios": "^3.0.0",
     "sha256": "^0.2.0",
     "socks": "^2.7.1",
     "underscore": "^1.13.6",
-    "ws": "^8.9.0"
+    "ws": "^8.11.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.40.0",
-    "@typescript-eslint/parser": "^5.40.0",
-    "eslint": "^8.25.0",
+    "@typescript-eslint/eslint-plugin": "^5.45.0",
+    "@typescript-eslint/parser": "^5.45.0",
+    "eslint": "^8.28.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jsdoc": "^39.3.6",
+    "eslint-plugin-jsdoc": "^39.6.4",
     "eslint-plugin-prettier": "^4.2.1",
     "nodemon": "^2.0.20",
-    "prettier": "2.7.1",
+    "prettier": "2.8.0",
     "trace-unhandled": "^2.0.1",
     "ts-node": "^10.9.1",
-    "typescript": "^4.8.4"
+    "typescript": "^4.9.3"
   }
 }

+ 8 - 2
frontend/dist/config/template.json

@@ -26,7 +26,6 @@
 		"footerLinks": {
 			"GitHub": "https://github.com/Musare/Musare"
 		},
-		"mediasession": false,
 		"christmas": false,
 		"registrationDisabled": false,
 		"githubAuthentication": false
@@ -46,5 +45,12 @@
 		"version": true
 	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 13
+	"configVersion": 13,
+	"experimental": {
+		"changable_listen_mode": [
+			"STATION_ID"
+		],
+		"disable_youtube_search": true,
+		"media_session": false
+	}
 }

File diff suppressed because it is too large
+ 279 - 243
frontend/package-lock.json


+ 23 - 23
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.8.0",
+  "version": "3.9.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -22,48 +22,48 @@
   "devDependencies": {
     "@pinia/testing": "^0.0.14",
     "@types/can-autoplay": "^3.0.1",
-    "@types/dompurify": "^2.3.4",
+    "@types/dompurify": "^2.4.0",
     "@types/marked": "^4.0.7",
-    "@typescript-eslint/eslint-plugin": "^5.40.0",
-    "@typescript-eslint/parser": "^5.40.0",
-    "@vitest/coverage-c8": "^0.24.1",
-    "@vue/test-utils": "^2.1.0",
-    "eslint": "^8.25.0",
+    "@typescript-eslint/eslint-plugin": "^5.45.0",
+    "@typescript-eslint/parser": "^5.45.0",
+    "@vitest/coverage-c8": "^0.24.5",
+    "@vue/test-utils": "^2.2.4",
+    "eslint": "^8.28.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.2.1",
-    "eslint-plugin-vue": "^9.6.0",
-    "jsdom": "^20.0.1",
+    "eslint-plugin-vue": "^9.8.0",
+    "jsdom": "^20.0.3",
     "less": "^4.1.3",
-    "prettier": "^2.7.1",
-    "vite-plugin-dynamic-import": "^1.1.1",
-    "vitest": "^0.24.1",
+    "prettier": "^2.8.0",
+    "vite-plugin-dynamic-import": "^1.2.4",
+    "vitest": "^0.24.5",
     "vue-eslint-parser": "^9.1.0",
-    "vue-tsc": "^1.0.6"
+    "vue-tsc": "^1.0.10"
   },
   "dependencies": {
     "@intlify/vite-plugin-vue-i18n": "^6.0.3",
-    "@vitejs/plugin-vue": "^3.1.2",
+    "@vitejs/plugin-vue": "^3.2.0",
     "can-autoplay": "^3.0.2",
     "chart.js": "^3.9.1",
     "config": "^3.3.8",
     "date-fns": "^2.29.3",
-    "dompurify": "^2.4.0",
+    "dompurify": "^2.4.1",
     "eslint-config-airbnb-base": "^15.0.0",
     "lofig": "^1.3.4",
-    "marked": "^4.1.1",
+    "marked": "^4.2.3",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.0.23",
+    "pinia": "^2.0.27",
     "toasters": "^2.3.1",
-    "typescript": "^4.8.4",
-    "vite": "^3.1.7",
-    "vue": "^3.2.40",
+    "typescript": "^4.9.3",
+    "vite": "^3.2.4",
+    "vue": "^3.2.45",
     "vue-chartjs": "^4.1.2",
     "vue-content-loader": "^2.0.1",
-    "vue-draggable-list": "^0.1.1",
+    "vue-draggable-list": "^0.1.3",
     "vue-i18n": "^9.2.2",
-    "vue-json-pretty": "^2.2.2",
-    "vue-router": "^4.1.5",
+    "vue-json-pretty": "^2.2.3",
+    "vue-router": "^4.1.6",
     "vue-tippy": "^6.0.0-alpha.65"
   }
 }

+ 40 - 36
frontend/src/components/AutoSuggest.vue

@@ -72,7 +72,7 @@ const blurAutosuggestItem = event => {
 </script>
 
 <template>
-	<div>
+	<div class="autosuggest-container-wrapper">
 		<input
 			v-model="value"
 			class="input"
@@ -110,7 +110,7 @@ const blurAutosuggestItem = event => {
 </template>
 
 <style lang="less" scoped>
-.night-mode .autosuggest-container {
+.night-mode .autosuggest-container-wrapper .autosuggest-container {
 	background-color: var(--dark-grey) !important;
 
 	.autosuggest-item {
@@ -125,40 +125,44 @@ const blurAutosuggestItem = event => {
 	}
 }
 
-.autosuggest-container {
-	position: absolute;
-	background: var(--white);
-	width: calc(100% + 1px);
-	top: 35px;
-	z-index: 200;
-	overflow: auto;
-	max-height: 98px;
-	clear: both;
-
-	.autosuggest-item {
-		padding: 8px;
-		display: block;
-		border: 1px solid var(--light-grey-2);
-		margin-top: -1px;
-		line-height: 16px;
-		cursor: pointer;
-		-webkit-user-select: none;
-		-ms-user-select: none;
-		-moz-user-select: none;
-		user-select: none;
-	}
-
-	.autosuggest-item:hover,
-	.autosuggest-item:focus {
-		background-color: var(--light-grey);
-	}
-
-	.autosuggest-item:first-child {
-		border-top: none;
-	}
-
-	.autosuggest-item:last-child {
-		border-radius: 0 0 @border-radius @border-radius;
+.autosuggest-container-wrapper {
+	position: relative;
+
+	.autosuggest-container {
+		position: absolute;
+		background: var(--white);
+		width: calc(100% + 1px);
+		top: 35px;
+		z-index: 200;
+		overflow: auto;
+		max-height: 98px;
+		clear: both;
+
+		.autosuggest-item {
+			padding: 8px;
+			display: block;
+			border: 1px solid var(--light-grey-2);
+			margin-top: -1px;
+			line-height: 16px;
+			cursor: pointer;
+			-webkit-user-select: none;
+			-ms-user-select: none;
+			-moz-user-select: none;
+			user-select: none;
+		}
+
+		.autosuggest-item:hover,
+		.autosuggest-item:focus {
+			background-color: var(--light-grey);
+		}
+
+		.autosuggest-item:first-child {
+			border-top: none;
+		}
+
+		.autosuggest-item:last-child {
+			border-radius: 0 0 @border-radius @border-radius;
+		}
 	}
 }
 </style>

+ 2 - 2
frontend/src/components/ChristmasLights.vue

@@ -59,7 +59,7 @@ const { loggedIn } = storeToRefs(userAuthStore);
 			border-top-right-radius: 50%;
 			border-bottom-left-radius: 50%;
 			border-bottom-right-radius: 50%;
-			z-index: 2;
+			z-index: 11;
 			animation: christmas-lights 30s ease infinite;
 
 			&::before {
@@ -152,7 +152,7 @@ const { loggedIn } = storeToRefs(userAuthStore);
 		.christmas-wire {
 			flex: 1;
 			margin-bottom: 15px;
-			z-index: 1;
+			z-index: 10;
 
 			border-top: 2px solid var(--primary-color);
 			border-radius: 50%;

+ 1 - 1
frontend/src/components/MainFooter.vue

@@ -46,7 +46,7 @@ onMounted(async () => {
 		<div class="container">
 			<div class="footer-content">
 				<div id="footer-copyright">
-					<p>© Copyright Musare 2015 - 2022</p>
+					<p>© Copyright Musare 2015 - 2023</p>
 				</div>
 				<router-link id="footer-logo" to="/">
 					<img

+ 3 - 2
frontend/src/components/MainHeader.vue

@@ -193,7 +193,7 @@ onMounted(async () => {
 	position: relative;
 	background-color: var(--primary-color);
 	height: 64px;
-	z-index: 3;
+	z-index: 100;
 
 	&.transparent {
 		background-color: transparent !important;
@@ -243,7 +243,7 @@ onMounted(async () => {
 		}
 
 		span {
-			background-color: var(--white);
+			background-color: var(--white) !important;
 			display: block;
 			height: 1px;
 			left: 50%;
@@ -331,6 +331,7 @@ onMounted(async () => {
 			top: 100%;
 			position: absolute;
 			background: var(--white);
+			z-index: 100;
 		}
 
 		.nav-menu.is-active {

+ 3 - 1
frontend/src/components/Modal.vue

@@ -41,7 +41,9 @@ onMounted(async () => {
 				<span class="delete material-icons" @click="closeCurrentModal()"
 					>highlight_off</span
 				>
-				<christmas-lights v-if="christmas" small :lights="5" />
+				<Transition>
+					<christmas-lights v-if="christmas" small :lights="5" />
+				</Transition>
 			</header>
 			<section class="modal-card-body">
 				<slot name="body" />

+ 1 - 1
frontend/src/components/ProfilePicture.vue

@@ -67,7 +67,7 @@ onMounted(async () => {
 		}
 
 		&.blue {
-			background-color: var(--primary-color);
+			background-color: var(--blue);
 			color: var(--white);
 		}
 		&.orange {

+ 2 - 1
frontend/src/components/Queue.vue

@@ -141,7 +141,7 @@ onUpdated(() => {
 		>
 			<draggable-list
 				v-model:list="queue"
-				item-key="_id"
+				item-key="youtubeId"
 				@start="drag = true"
 				@end="drag = false"
 				@update="repositionSongInQueue"
@@ -153,6 +153,7 @@ onUpdated(() => {
 						:requested-by="true"
 						:disabled-actions="[]"
 						:ref="el => (songItems[`song-item-${index}`] = el)"
+						:key="`queue-song-item-${element.youtubeId}`"
 					>
 						<template
 							v-if="hasPermission('stations.queue.reposition')"

+ 41 - 1
frontend/src/components/Request.vue

@@ -6,6 +6,7 @@ import { useStationStore } from "@/stores/station";
 import { useManageStationStore } from "@/stores/manageStation";
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import { useSearchMusare } from "@/composables/useSearchMusare";
+import { useYoutubeDirect } from "@/composables/useYoutubeDirect";
 
 const SongItem = defineAsyncComponent(
 	() => import("@/components/SongItem.vue")
@@ -25,6 +26,7 @@ const props = defineProps({
 
 const { youtubeSearch, searchForSongs, loadMoreSongs } = useSearchYoutube();
 const { musareSearch, searchForMusareSongs } = useSearchMusare();
+const { youtubeDirect, addToQueue } = useYoutubeDirect();
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
@@ -35,6 +37,7 @@ const manageStationStore = useManageStationStore({
 const tab = ref("songs");
 const sitename = ref("Musare");
 const tabs = ref({});
+const experimentalDisableYoutubeSearch = ref(false);
 
 const station = computed({
 	get() {
@@ -116,6 +119,16 @@ const addSongToQueue = (youtubeId: string, index?: number) => {
 onMounted(async () => {
 	sitename.value = await lofig.get("siteSettings.sitename");
 
+	lofig.get("experimental").then(experimental => {
+		if (
+			experimental &&
+			Object.hasOwn(experimental, "disable_youtube_search") &&
+			experimental.disable_youtube_search
+		) {
+			experimentalDisableYoutubeSearch.value = true;
+		}
+	});
+
 	showTab("songs");
 });
 </script>
@@ -221,7 +234,34 @@ onMounted(async () => {
 					</div>
 				</div>
 
-				<div class="youtube-search">
+				<div class="youtube-direct">
+					<label class="label"> Add a YouTube song from a URL </label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your YouTube song URL here..."
+								v-model="youtubeDirect"
+								@keyup.enter="addToQueue(station._id)"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click="addToQueue(station._id)"
+								><i class="material-icons icon-with-button"
+									>add</i
+								>Add</a
+							>
+						</p>
+					</div>
+				</div>
+
+				<div
+					class="youtube-search"
+					v-if="!experimentalDisableYoutubeSearch"
+				>
 					<label class="label"> Search for a song on YouTube </label>
 					<div class="control is-grouped input-with-button">
 						<p class="control is-expanded">

+ 1 - 1
frontend/src/components/RunJobDropdown.vue

@@ -75,7 +75,7 @@ const runJob = job => {
 			<div class="nav-dropdown-items" v-if="jobs.length > 0">
 				<quick-confirm
 					v-for="(job, index) in jobs"
-					:key="`job-${index}`"
+					:key="`job-${index}-${job.name}-${job.id}`"
 					placement="top"
 					@confirm="runJob(job)"
 				>

+ 1 - 1
frontend/src/components/SongItem.vue

@@ -172,7 +172,7 @@ onUnmounted(() => {
 					<strong>
 						<user-link
 							v-if="song.requestedBy"
-							:key="song._id"
+							:key="song.youtubeId"
 							:user-id="song.requestedBy"
 						/>
 						<span v-else>station</span>

+ 3 - 1
frontend/src/components/__snapshots__/Modal.spec.ts.snap

@@ -8,7 +8,9 @@ exports[`Modal component > renders slots 1`] = `
     <header class=\\"modal-card-head\\">
       <div>Toggle Mobile Sidebar Slot</div>
       <h2 class=\\"modal-card-title is-marginless\\">Modal</h2><span class=\\"delete material-icons\\">highlight_off</span>
-      <!--v-if-->
+      <transition-stub appear=\\"false\\" persisted=\\"false\\" css=\\"true\\">
+        <!--v-if-->
+      </transition-stub>
     </header>
     <section class=\\"modal-card-body\\">
       <div>Body Slot</div>

+ 33 - 1
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -3,6 +3,7 @@ import { defineAsyncComponent, ref, watch, onMounted } from "vue";
 import { storeToRefs } from "pinia";
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import { useSearchMusare } from "@/composables/useSearchMusare";
+import { useYoutubeDirect } from "@/composables/useYoutubeDirect";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
 
 const SongItem = defineAsyncComponent(
@@ -20,6 +21,7 @@ const editPlaylistStore = useEditPlaylistStore({ modalUuid: props.modalUuid });
 const { playlist } = storeToRefs(editPlaylistStore);
 
 const sitename = ref("Musare");
+const experimentalDisableYoutubeSearch = ref(false);
 
 const {
 	youtubeSearch,
@@ -36,6 +38,8 @@ const {
 	addMusareSongToPlaylist
 } = useSearchMusare();
 
+const { youtubeDirect, addToPlaylist } = useYoutubeDirect();
+
 watch(
 	() => youtubeSearch.value.songs.results,
 	songs => {
@@ -89,6 +93,16 @@ watch(
 
 onMounted(async () => {
 	sitename.value = await lofig.get("siteSettings.sitename");
+
+	lofig.get("experimental").then(experimental => {
+		if (
+			experimental &&
+			Object.hasOwn(experimental, "disable_youtube_search") &&
+			experimental.disable_youtube_search
+		) {
+			experimentalDisableYoutubeSearch.value = true;
+		}
+	});
 });
 </script>
 
@@ -164,7 +178,25 @@ onMounted(async () => {
 
 		<br v-if="musareSearch.results.length > 0" />
 
-		<div>
+		<label class="label"> Add a YouTube song from a URL </label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					placeholder="Enter your YouTube song URL here..."
+					v-model="youtubeDirect"
+					@keyup.enter="addToPlaylist(playlist._id)"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click="addToPlaylist(playlist._id)"
+					><i class="material-icons icon-with-button">add</i>Add</a
+				>
+			</p>
+		</div>
+
+		<div v-if="!experimentalDisableYoutubeSearch">
 			<label class="label"> Search for a song from YouTube </label>
 			<div class="control is-grouped input-with-button">
 				<p class="control is-expanded">

+ 2 - 1
frontend/src/components/modals/EditPlaylist/index.vue

@@ -404,7 +404,7 @@ onBeforeUnmount(() => {
 						<draggable-list
 							v-if="playlistSongs.length > 0"
 							v-model:list="playlistSongs"
-							item-key="_id"
+							item-key="youtubeId"
 							@start="drag = true"
 							@end="drag = false"
 							@update="repositionSong"
@@ -420,6 +420,7 @@ onBeforeUnmount(() => {
 											(songItems[`song-item-${index}`] =
 												el)
 									"
+									:key="`playlist-song-${element.youtubeId}`"
 								>
 									<template #tippyActions>
 										<i

+ 83 - 43
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
 import { storeToRefs } from "pinia";
+import { onMounted, ref } from "vue";
 
 import { useEditSongStore } from "@/stores/editSong";
 
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
+import { useYoutubeDirect } from "@/composables/useYoutubeDirect";
 
 import SearchQueryItem from "../../../SearchQueryItem.vue";
 
@@ -19,74 +21,112 @@ const { form, newSong } = storeToRefs(editSongStore);
 const { updateYoutubeId } = editSongStore;
 
 const { youtubeSearch, searchForSongs, loadMoreSongs } = useSearchYoutube();
+const { youtubeDirect, getYoutubeVideoId } = useYoutubeDirect();
 
-const selectSong = result => {
-	updateYoutubeId(result.id);
+const experimentalDisableYoutubeSearch = ref(false);
 
-	if (newSong)
+const selectSong = (youtubeId, result = null) => {
+	updateYoutubeId(youtubeId);
+
+	if (newSong && result)
 		form.value.setValue({
 			title: result.title,
 			thumbnail: result.thumbnail
 		});
 };
+
+onMounted(() => {
+	lofig.get("experimental").then(experimental => {
+		if (
+			experimental &&
+			Object.hasOwn(experimental, "disable_youtube_search") &&
+			experimental.disable_youtube_search
+		) {
+			experimentalDisableYoutubeSearch.value = true;
+		}
+	});
+});
 </script>
 
 <template>
 	<div class="youtube-tab">
-		<label class="label"> Search for a song from YouTube </label>
+		<label class="label"> Add a YouTube song from a URL </label>
 		<div class="control is-grouped input-with-button">
 			<p class="control is-expanded">
 				<input
 					class="input"
 					type="text"
-					placeholder="Enter your YouTube query here..."
-					v-model="youtubeSearch.songs.query"
-					autofocus
-					@keyup.enter="searchForSongs()"
+					placeholder="Enter your YouTube song URL here..."
+					v-model="youtubeDirect"
+					@keyup.enter="selectSong(getYoutubeVideoId())"
 				/>
 			</p>
 			<p class="control">
-				<button
+				<a
 					class="button is-info"
-					@click.prevent="searchForSongs()"
+					@click="selectSong(getYoutubeVideoId())"
+					><i class="material-icons icon-with-button">add</i>Add</a
 				>
-					<i class="material-icons icon-with-button">search</i>Search
-				</button>
 			</p>
 		</div>
 
-		<div
-			v-if="youtubeSearch.songs.results.length > 0"
-			id="song-query-results"
-		>
-			<search-query-item
-				v-for="result in youtubeSearch.songs.results"
-				:key="result.id"
-				:result="result"
+		<div v-if="!experimentalDisableYoutubeSearch">
+			<label class="label"> Search for a song from YouTube </label>
+			<div class="control is-grouped input-with-button">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="Enter your YouTube query here..."
+						v-model="youtubeSearch.songs.query"
+						autofocus
+						@keyup.enter="searchForSongs()"
+					/>
+				</p>
+				<p class="control">
+					<button
+						class="button is-info"
+						@click.prevent="searchForSongs()"
+					>
+						<i class="material-icons icon-with-button">search</i
+						>Search
+					</button>
+				</p>
+			</div>
+
+			<div
+				v-if="youtubeSearch.songs.results.length > 0"
+				id="song-query-results"
 			>
-				<template #actions>
-					<i
-						class="material-icons icon-selected"
-						v-if="result.id === form.inputs.youtubeId.value"
-						key="selected"
-						>radio_button_checked
-					</i>
-					<i
-						class="material-icons icon-not-selected"
-						v-else
-						@click.prevent="selectSong(result)"
-						key="not-selected"
-						>radio_button_unchecked
-					</i>
-				</template>
-			</search-query-item>
-
-			<button
-				class="button is-primary load-more-button"
-				@click.prevent="loadMoreSongs()"
-			>
-				Load more...
-			</button>
+				<search-query-item
+					v-for="result in youtubeSearch.songs.results"
+					:key="result.id"
+					:result="result"
+				>
+					<template #actions>
+						<i
+							class="material-icons icon-selected"
+							v-if="result.id === form.inputs.youtubeId.value"
+							key="selected"
+							>radio_button_checked
+						</i>
+						<i
+							class="material-icons icon-not-selected"
+							v-else
+							@click.prevent="selectSong(result.id, result)"
+							key="not-selected"
+							>radio_button_unchecked
+						</i>
+					</template>
+				</search-query-item>
+
+				<button
+					class="button is-primary load-more-button"
+					@click.prevent="loadMoreSongs()"
+				>
+					Load more...
+				</button>
+			</div>
 		</div>
 	</div>
 </template>

+ 4 - 4
frontend/src/components/modals/ImportAlbum.vue

@@ -628,12 +628,12 @@ onBeforeUnmount(() => {
 					<draggable-list
 						v-if="playlistSongs.length > 0"
 						v-model:list="playlistSongs"
-						item-key="_id"
+						item-key="youtubeId"
 						:group="`import-album-${modalUuid}-songs`"
 					>
 						<template #item="{ element }">
 							<song-item
-								:key="`playlist-song-${element._id}`"
+								:key="`playlist-song-${element.youtubeId}`"
 								:song="element"
 							>
 							</song-item>
@@ -657,12 +657,12 @@ onBeforeUnmount(() => {
 						<div class="track-box-songs-drag-area">
 							<draggable-list
 								v-model:list="trackSongs[index]"
-								item-key="_id"
+								item-key="youtubeId"
 								:group="`import-album-${modalUuid}-songs`"
 							>
 								<template #item="{ element }">
 									<song-item
-										:key="`track-song-${element._id}`"
+										:key="`track-song-${element.youtubeId}`"
 										:song="element"
 									>
 									</song-item>

+ 122 - 0
frontend/src/components/modals/ManageStation/Settings.vue

@@ -60,6 +60,7 @@ const { inputs, save, setOriginalValue } = useForm(
 		},
 		theme: station.value.theme,
 		privacy: station.value.privacy,
+		skipVoteThreshold: station.value.skipVoteThreshold,
 		requestsEnabled: station.value.requests.enabled,
 		requestsAccess: station.value.requests.access,
 		requestsLimit: station.value.requests.limit,
@@ -77,6 +78,7 @@ const { inputs, save, setOriginalValue } = useForm(
 				description: values.description,
 				theme: values.theme,
 				privacy: values.privacy,
+				skipVoteThreshold: values.skipVoteThreshold,
 				requests: {
 					...oldStation.requests,
 					enabled: values.requestsEnabled,
@@ -121,6 +123,7 @@ watch(station, value => {
 		description: value.description,
 		theme: value.theme,
 		privacy: value.privacy,
+		skipVoteThreshold: value.skipVoteThreshold,
 		requestsEnabled: value.requests.enabled,
 		requestsAccess: value.requests.access,
 		requestsLimit: value.requests.limit,
@@ -181,6 +184,24 @@ watch(station, value => {
 				</div>
 			</div>
 
+			<div class="small-section">
+				<label class="label">
+					Skip Vote Threshold
+					<info-icon
+						tooltip="The % of logged-in station users required to vote to skip a song"
+					/>
+				</label>
+				<div class="control is-expanded input-slider">
+					<input
+						v-model="inputs['skipVoteThreshold'].value"
+						type="range"
+						min="0"
+						max="100"
+					/>
+					<span>{{ inputs["skipVoteThreshold"].value }}%</span>
+				</div>
+			</div>
+
 			<div
 				class="requests-settings"
 				:class="{ enabled: inputs['requestsEnabled'].value }"
@@ -342,6 +363,107 @@ watch(station, value => {
 		}
 	}
 
+	.input-slider {
+		display: flex;
+
+		input[type="range"] {
+			-webkit-appearance: none;
+			margin: 0;
+			padding: 0;
+			width: 100%;
+			min-width: 100px;
+			background: transparent;
+		}
+
+		input[type="range"]:focus {
+			outline: none;
+		}
+
+		input[type="range"]::-webkit-slider-runnable-track {
+			width: 100%;
+			height: 5.2px;
+			cursor: pointer;
+			box-shadow: 0;
+			background: var(--light-grey-3);
+			border-radius: @border-radius;
+			border: 0;
+		}
+
+		input[type="range"]::-webkit-slider-thumb {
+			box-shadow: 0;
+			border: 0;
+			height: 19px;
+			width: 19px;
+			border-radius: 100%;
+			background: var(--primary-color);
+			cursor: pointer;
+			-webkit-appearance: none;
+			margin-top: -6.5px;
+		}
+
+		input[type="range"]::-moz-range-track {
+			width: 100%;
+			height: 5.2px;
+			cursor: pointer;
+			box-shadow: 0;
+			background: var(--light-grey-3);
+			border-radius: @border-radius;
+			border: 0;
+		}
+
+		input[type="range"]::-moz-range-thumb {
+			box-shadow: 0;
+			border: 0;
+			height: 19px;
+			width: 19px;
+			border-radius: 100%;
+			background: var(--primary-color);
+			cursor: pointer;
+			-webkit-appearance: none;
+			margin-top: -6.5px;
+		}
+		input[type="range"]::-ms-track {
+			width: 100%;
+			height: 5.2px;
+			cursor: pointer;
+			box-shadow: 0;
+			background: var(--light-grey-3);
+			border-radius: @border-radius;
+		}
+
+		input[type="range"]::-ms-fill-lower {
+			background: var(--light-grey-3);
+			border: 0;
+			border-radius: 0;
+			box-shadow: 0;
+		}
+
+		input[type="range"]::-ms-fill-upper {
+			background: var(--light-grey-3);
+			border: 0;
+			border-radius: 0;
+			box-shadow: 0;
+		}
+
+		input[type="range"]::-ms-thumb {
+			box-shadow: 0;
+			border: 0;
+			height: 15px;
+			width: 15px;
+			border-radius: 100%;
+			background: var(--primary-color);
+			cursor: pointer;
+			-webkit-appearance: none;
+			margin-top: 1.5px;
+		}
+
+		& > span {
+			min-width: 40px;
+			margin-left: 10px;
+			text-align: center;
+		}
+	}
+
 	.requests-settings,
 	.autofill-settings {
 		display: flex;

+ 1 - 1
frontend/src/composables/useForm.ts

@@ -96,7 +96,7 @@ export const useForm = (
 				name,
 				{
 					...input,
-					originalValue: input.value,
+					originalValue: JSON.parse(JSON.stringify(input.value)),
 					sourceChanged: false
 				}
 			])

+ 90 - 0
frontend/src/composables/useYoutubeDirect.ts

@@ -0,0 +1,90 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { AddSongToPlaylistResponse } from "@musare_types/actions/PlaylistsActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+const youtubeVideoUrlRegex =
+	/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?([\w-]{11})$/;
+const youtubeVideoIdRegex = /^([\w-]{11})$/;
+
+export const useYoutubeDirect = () => {
+	const youtubeDirect = ref("");
+
+	const { socket } = useWebsocketsStore();
+
+	const getYoutubeVideoId = () => {
+		const youtubeVideoUrlParts = youtubeVideoUrlRegex.exec(
+			youtubeDirect.value.trim()
+		);
+		if (youtubeVideoUrlParts) {
+			// eslint-disable-next-line prefer-destructuring
+			return youtubeVideoUrlParts[5];
+		}
+
+		const youtubeVideoIdParts = youtubeVideoIdRegex.exec(
+			youtubeDirect.value.trim()
+		);
+		if (youtubeVideoIdParts) {
+			// eslint-disable-next-line prefer-destructuring
+			return youtubeVideoIdParts[1];
+		}
+
+		return null;
+	};
+
+	const addToPlaylist = (playlistId: string) => {
+		const youtubeVideoId = getYoutubeVideoId();
+
+		if (!youtubeVideoId)
+			new Toast(
+				`Could not determine the YouTube video id from the provided URL.`
+			);
+		else {
+			socket.dispatch(
+				"playlists.addSongToPlaylist",
+				false,
+				youtubeVideoId,
+				playlistId,
+				(res: AddSongToPlaylistResponse) => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else {
+						new Toast(res.message);
+						youtubeDirect.value = "";
+					}
+				}
+			);
+		}
+	};
+
+	const addToQueue = (stationId: string) => {
+		const youtubeVideoId = getYoutubeVideoId();
+
+		if (!youtubeVideoId)
+			new Toast(
+				`Could not determine the YouTube video id from the provided URL.`
+			);
+		else {
+			socket.dispatch(
+				"stations.addToQueue",
+				stationId,
+				youtubeVideoId,
+				res => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else {
+						new Toast(res.message);
+						youtubeDirect.value = "";
+					}
+				}
+			);
+		}
+	};
+
+	return {
+		youtubeDirect,
+		addToPlaylist,
+		addToQueue,
+		getYoutubeVideoId
+	};
+};

+ 1 - 1
frontend/src/index.html

@@ -19,7 +19,7 @@
 		/>
 		<meta
 			name="copyright"
-			content="© Copyright Musare 2015-2022 All Right Reserved"
+			content="© Copyright Musare 2015-2023 All Right Reserved"
 		/>
 
 		<link

+ 8 - 1
frontend/src/main.ts

@@ -408,7 +408,14 @@ createSocket().then(async socket => {
 		});
 	});
 
-	if (await lofig.get("siteSettings.mediasession")) ms.init();
+	lofig.get("experimental").then(experimental => {
+		if (
+			experimental &&
+			Object.hasOwn(experimental, "media_session") &&
+			experimental.media_session
+		)
+			ms.init();
+	});
 
 	app.mount("#root");
 });

+ 326 - 17
frontend/src/pages/Station/index.vue

@@ -86,9 +86,13 @@ const beforeMediaModalLocalPausedLock = ref(false);
 const beforeMediaModalLocalPaused = ref(null);
 const persistentToastCheckerInterval = ref(null);
 const persistentToasts = ref([]);
-const mediasession = ref(false);
 const christmas = ref(false);
 const sitename = ref("Musare");
+// Experimental options
+const experimentalChangableListenModeEnabled = ref(false);
+const experimentalChangableListenMode = ref("listen_and_participate"); // Can be either listen_and_participate or participate
+const experimentalMediaSession = ref(false);
+// End experimental options
 // NEW
 const videoLoading = ref();
 const startedAt = ref();
@@ -285,6 +289,7 @@ const resizeSeekerbar = () => {
 		(getTimeElapsed() / 1000 / currentSong.value.duration) * 100;
 };
 const calculateTimeElapsed = () => {
+	if (experimentalChangableListenMode.value === "participate") return;
 	if (
 		playerReady.value &&
 		!noSong.value &&
@@ -403,6 +408,7 @@ const toggleSkipVote = (message?) => {
 	});
 };
 const youtubeReady = () => {
+	if (experimentalChangableListenMode.value === "participate") return;
 	if (!player.value) {
 		ms.setYTReady(false);
 		player.value = new window.YT.Player("stationPlayer", {
@@ -575,7 +581,7 @@ const setCurrentSong = data => {
 
 	clearTimeout(window.stationNextSongTimeout);
 
-	if (mediasession.value) updateMediaSessionData(_currentSong);
+	if (experimentalMediaSession.value) updateMediaSessionData(_currentSong);
 
 	startedAt.value = _startedAt;
 	updateStationPaused(_paused);
@@ -697,7 +703,8 @@ const changeVolume = () => {
 	}
 };
 const resumeLocalPlayer = () => {
-	if (mediasession.value) updateMediaSessionData(currentSong.value);
+	if (experimentalMediaSession.value)
+		updateMediaSessionData(currentSong.value);
 	if (!noSong.value) {
 		if (playerReady.value) {
 			player.value.seekTo(
@@ -708,7 +715,8 @@ const resumeLocalPlayer = () => {
 	}
 };
 const pauseLocalPlayer = () => {
-	if (mediasession.value) updateMediaSessionData(currentSong.value);
+	if (experimentalMediaSession.value)
+		updateMediaSessionData(currentSong.value);
 	if (!noSong.value) {
 		timeBeforePause.value = getTimeElapsed();
 		if (playerReady.value) player.value.pauseVideo();
@@ -809,9 +817,11 @@ const resetKeyboardShortcutsHelper = () => {
 const sendActivityWatchVideoData = () => {
 	if (
 		!stationPaused.value &&
-		!localPaused.value &&
+		(!localPaused.value ||
+			experimentalChangableListenMode.value === "participate") &&
 		!noSong.value &&
-		player.value.getPlayerState() === window.YT.PlayerState.PLAYING
+		(experimentalChangableListenMode.value === "participate" ||
+			player.value.getPlayerState() === window.YT.PlayerState.PLAYING)
 	) {
 		if (activityWatchVideoLastStatus.value !== "playing") {
 			activityWatchVideoLastStatus.value = "playing";
@@ -845,11 +855,17 @@ const sendActivityWatchVideoData = () => {
 					  ),
 			source: `station#${station.value.name}`,
 			hostname: window.location.hostname,
-			playerState: Object.keys(window.YT.PlayerState).find(
-				key =>
-					window.YT.PlayerState[key] === player.value.getPlayerState()
-			),
-			playbackRate: playbackRate.value
+			playerState:
+				experimentalChangableListenMode.value === "participate"
+					? "none"
+					: Object.keys(window.YT.PlayerState).find(
+							key =>
+								window.YT.PlayerState[key] ===
+								player.value.getPlayerState()
+					  ),
+			playbackRate: playbackRate.value,
+			experimentalChangableListenMode:
+				experimentalChangableListenMode.value
 		};
 
 		aw.sendVideoData(videoData);
@@ -858,6 +874,26 @@ const sendActivityWatchVideoData = () => {
 	}
 };
 
+const experimentalChangableListenModeChange = newMode => {
+	experimentalChangableListenMode.value = newMode;
+	localStorage.setItem(
+		`experimental_changeable_listen_mode_${station.value._id}`,
+		newMode
+	);
+
+	if (newMode === "participate") {
+		// Destroy the YouTube player
+		if (player.value) {
+			player.value.destroy();
+			player.value = null;
+			playerReady.value = false;
+		}
+	} else {
+		// Recreate the YouTube player
+		youtubeReady();
+	}
+};
+
 watch(
 	() => autoRequest.value.length,
 	() => {
@@ -897,6 +933,8 @@ onMounted(async () => {
 		);
 	}, 1000);
 
+	const experimental = await lofig.get("experimental");
+
 	socket.onConnect(() => {
 		clearTimeout(window.stationNextSongTimeout);
 
@@ -922,6 +960,28 @@ onMounted(async () => {
 					djs
 				} = res.data;
 
+				if (experimental && experimental.changable_listen_mode) {
+					if (experimental.changable_listen_mode === true)
+						experimentalChangableListenModeEnabled.value = true;
+					else if (
+						Array.isArray(experimental.changable_listen_mode) &&
+						experimental.changable_listen_mode.indexOf(_id) !== -1
+					)
+						experimentalChangableListenModeEnabled.value = true;
+				}
+				if (experimentalChangableListenModeEnabled.value) {
+					console.log(
+						`Experimental changeable listen mode is enabled`
+					);
+					const experimentalChangeableListenModeLS =
+						localStorage.getItem(
+							`experimental_changeable_listen_mode_${_id}`
+						);
+					if (experimentalChangeableListenModeLS)
+						experimentalChangableListenMode.value =
+							experimentalChangeableListenModeLS;
+				}
+
 				// change url to use station name instead of station id
 				if (name !== stationIdentifier.value) {
 					// eslint-disable-next-line no-restricted-globals
@@ -1406,9 +1466,16 @@ onMounted(async () => {
 	});
 
 	frontendDevMode.value = await lofig.get("mode");
-	mediasession.value = await lofig.get("siteSettings.mediasession");
 	christmas.value = await lofig.get("siteSettings.christmas");
 	sitename.value = await lofig.get("siteSettings.sitename");
+	lofig.get("experimental").then(experimental => {
+		if (
+			experimental &&
+			Object.hasOwn(experimental, "media_session") &&
+			experimental.media_session
+		)
+			experimentalMediaSession.value = true;
+	});
 
 	ms.setListeners(0, {
 		play: () => {
@@ -1441,7 +1508,7 @@ onMounted(async () => {
 onBeforeUnmount(() => {
 	document.getElementsByTagName("html")[0].style.cssText = "";
 
-	if (mediasession.value) {
+	if (experimentalMediaSession.value) {
 		ms.removeListeners(0);
 		ms.removeMediaSessionData(0);
 	}
@@ -1564,7 +1631,184 @@ onBeforeUnmount(() => {
 						</div>
 					</div>
 					<div id="station-right-column" class="column">
-						<div class="player-container quadrant" v-show="!noSong">
+						<div
+							class="experimental-listen-mode-container quadrant"
+							v-if="
+								experimentalChangableListenModeEnabled &&
+								!noSong
+							"
+							v-show="
+								experimentalChangableListenMode ===
+								'participate'
+							"
+						>
+							<button
+								class="button is-primary"
+								@click="
+									experimentalChangableListenModeChange(
+										'listen_and_participate'
+									)
+								"
+							>
+								<i class="material-icons icon-with-button"
+									>music_note</i
+								>
+								<span>Listen to music</span>
+							</button>
+							<button
+								v-if="!skipVotesLoaded"
+								class="button is-primary disabled"
+								content="Skip votes have not been loaded yet"
+								v-tippy
+							>
+								<i class="material-icons icon-with-button"
+									>skip_next</i
+								>
+								Vote to skip the current song
+							</button>
+							<button
+								v-else-if="loggedIn"
+								:class="[
+									'button',
+									'is-primary',
+									{ voted: currentSong.voted }
+								]"
+								@click="toggleSkipVote()"
+								:content="`${
+									currentSong.voted ? 'Remove vote' : 'Vote'
+								} to Skip Song`"
+								v-tippy
+							>
+								<i class="material-icons icon-with-button"
+									>skip_next</i
+								>
+								Vote to skip the current song -
+								{{ currentSong.skipVotes }} votes
+							</button>
+							<button
+								v-else
+								class="button is-primary disabled"
+								content="Log in to vote to skip songs"
+								v-tippy="{ theme: 'info' }"
+							>
+								<i class="material-icons icon-with-button"
+									>skip_next</i
+								>
+								Vote to skip the current song -
+								{{ currentSong.skipVotes }} votes
+							</button>
+							<div class="row">
+								<!-- Ratings -->
+								<div
+									class="ratings"
+									v-if="ratingsLoaded && ownRatingsLoaded"
+									:class="{
+										liked: currentSong.liked,
+										disliked: currentSong.disliked
+									}"
+								>
+									<!-- Like Song Button -->
+									<button
+										class="button is-success like-song"
+										@click="toggleLike()"
+										content="Like Song"
+										v-tippy
+									>
+										<i
+											class="material-icons icon-with-button"
+											:class="{
+												liked: currentSong.liked
+											}"
+											>thumb_up_alt</i
+										>{{ currentSong.likes }}
+									</button>
+
+									<!-- Dislike Song Button -->
+									<button
+										class="button is-danger dislike-song"
+										@click="toggleDislike()"
+										content="Dislike Song"
+										v-tippy
+									>
+										<i
+											class="material-icons icon-with-button"
+											:class="{
+												disliked: currentSong.disliked
+											}"
+											>thumb_down_alt</i
+										>{{ currentSong.dislikes }}
+									</button>
+								</div>
+								<div id="ratings" class="disabled" v-else>
+									<!-- Like Song Button -->
+									<button
+										class="button is-success like-song disabled"
+										content="Ratings have not been loaded yet"
+										v-tippy
+									>
+										<i
+											class="material-icons icon-with-button"
+											>thumb_up_alt</i
+										>
+									</button>
+
+									<!-- Dislike Song Button -->
+									<button
+										class="button is-danger dislike-song disabled"
+										content="Ratings have not been loaded yet"
+										v-tippy
+									>
+										<i
+											class="material-icons icon-with-button"
+											>thumb_down_alt</i
+										>
+									</button>
+								</div>
+								<add-to-playlist-dropdown
+									:song="currentSong"
+									placement="top-end"
+								>
+									<template #button>
+										<div
+											id="add-song-to-playlist"
+											content="Add Song to Playlist"
+											v-tippy
+										>
+											<div class="control has-addons">
+												<button
+													class="button is-primary"
+												>
+													<i class="material-icons">
+														playlist_add
+													</i>
+												</button>
+												<button
+													class="button"
+													id="dropdown-toggle"
+												>
+													<i class="material-icons">
+														{{
+															showPlaylistDropdown
+																? "expand_more"
+																: "expand_less"
+														}}
+													</i>
+												</button>
+											</div>
+										</div>
+									</template>
+								</add-to-playlist-dropdown>
+							</div>
+						</div>
+						<div
+							class="player-container quadrant"
+							v-show="
+								!noSong &&
+								(!experimentalChangableListenModeEnabled ||
+									experimentalChangableListenMode ===
+										'listen_and_participate')
+							"
+						>
 							<div id="video-container">
 								<div
 									id="stationPlayer"
@@ -1786,6 +2030,26 @@ onBeforeUnmount(() => {
 										>
 										{{ currentSong.skipVotes }}
 									</button>
+
+									<!-- Close player window -->
+									<button
+										v-if="
+											experimentalChangableListenModeEnabled
+										"
+										class="button is-primary"
+										content="Close this player window"
+										@click="
+											experimentalChangableListenModeChange(
+												'participate'
+											)
+										"
+										v-tippy
+									>
+										<i
+											class="material-icons icon-with-button"
+											>cancel_presentation</i
+										>
+									</button>
 								</div>
 								<div id="duration">
 									<p>
@@ -2022,7 +2286,7 @@ onBeforeUnmount(() => {
 								:class="{ 'no-currently-playing': noSong }"
 							>
 								<song-item
-									:key="`songItem-currentSong-${currentSong._id}`"
+									:key="`songItem-currentSong-${currentSong.youtubeId}`"
 									:song="currentSong"
 									:duration="false"
 									:requested-by="true"
@@ -2035,7 +2299,7 @@ onBeforeUnmount(() => {
 								class="quadrant"
 							>
 								<song-item
-									:key="`songItem-nextSong-${nextSong._id}`"
+									:key="`songItem-nextSong-${nextSong.youtubeId}`"
 									:song="nextSong"
 									:duration="false"
 									:requested-by="true"
@@ -2682,7 +2946,7 @@ onBeforeUnmount(() => {
 		var(--dark-red) 1rem 2rem
 	);
 
-	background-size: 200% 200%;
+	background-size: 200% 100%;
 	animation: christmas 20s linear infinite;
 }
 
@@ -2801,11 +3065,56 @@ onBeforeUnmount(() => {
 	animation-delay: 11s;
 }
 
+.experimental-listen-mode-container {
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	row-gap: 16px;
+	padding: 16px 16px;
+
+	.row {
+		display: flex;
+		flex-direction: row;
+		column-gap: 16px;
+
+		.ratings {
+			flex: 2;
+			display: flex;
+			flex-direction: row;
+			column-gap: 16px;
+
+			button {
+				flex: 1;
+			}
+		}
+
+		.addToPlaylistDropdown {
+			flex: 1;
+
+			.button.is-primary {
+				flex: 1;
+			}
+		}
+	}
+}
+
 /* Tablet view fix */
 @media (max-width: 768px) {
 	.bg-bubbles li:nth-child(10) {
 		display: none;
 	}
+
+	.experimental-listen-mode-container {
+		row-gap: 8px;
+
+		.row {
+			column-gap: 8px;
+
+			.ratings {
+				column-gap: 8px;
+			}
+		}
+	}
 }
 
 @-webkit-keyframes square {

+ 10 - 0
frontend/vite.config.js

@@ -151,6 +151,16 @@ export default {
 				find: "@",
 				replacement: path.resolve(__dirname, "src")
 			}
+		],
+		extensions: [
+			".mjs",
+			".js",
+			".mts",
+			".ts",
+			".jsx",
+			".tsx",
+			".json",
+			".vue"
 		]
 	},
 	define: {

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