فهرست منبع

Merge remote-tracking branch 'origin/kris-song-merging' into polishing

Owen Diffey 4 سال پیش
والد
کامیت
16d03e157d
32فایلهای تغییر یافته به همراه2886 افزوده شده و 925 حذف شده
  1. 80 15
      backend/logic/actions/playlists.js
  2. 405 425
      backend/logic/actions/queueSongs.js
  3. 509 84
      backend/logic/actions/songs.js
  4. 47 27
      backend/logic/actions/stations.js
  5. 5 14
      backend/logic/db/index.js
  6. 2 1
      backend/logic/db/schemas/playlist.js
  7. 13 12
      backend/logic/db/schemas/song.js
  8. 8 5
      backend/logic/db/schemas/station.js
  9. 1 0
      backend/logic/migration/index.js
  10. 99 0
      backend/logic/migration/migrations/migration4.js
  11. 174 0
      backend/logic/migration/migrations/migration5.js
  12. 21 18
      backend/logic/notifications.js
  13. 76 0
      backend/logic/playlists.js
  14. 499 13
      backend/logic/songs.js
  15. 65 19
      backend/logic/stations.js
  16. 5 0
      frontend/src/App.vue
  17. 2 2
      frontend/src/components/modals/AddSongToQueue.vue
  18. 21 7
      frontend/src/components/modals/EditPlaylist/components/PlaylistSongItem.vue
  19. 2 8
      frontend/src/components/modals/EditPlaylist/index.vue
  20. 137 154
      frontend/src/components/modals/EditSong.vue
  21. 5 3
      frontend/src/components/modals/EditStation.vue
  22. 47 20
      frontend/src/pages/Admin/index.vue
  23. 376 0
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  24. 48 0
      frontend/src/pages/Admin/tabs/Playlists.vue
  25. 20 0
      frontend/src/pages/Admin/tabs/Stations.vue
  26. 30 23
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  27. 20 13
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  28. 70 10
      frontend/src/pages/Home.vue
  29. 66 12
      frontend/src/pages/Station/components/CurrentlyPlaying.vue
  30. 25 12
      frontend/src/pages/Station/components/Sidebar/Queue/QueueItem.vue
  31. 8 21
      frontend/src/pages/Station/index.vue
  32. 0 7
      frontend/src/store/modules/station.js

+ 80 - 15
backend/logic/actions/playlists.js

@@ -875,25 +875,20 @@ export default {
 						.catch(next);
 				},
 				(position, next) => {
-					SongsModule.runJob("GET_SONG_FROM_ID", { songId }, this)
-						.then(res => {
-							const { song } = res;
-
+					SongsModule.runJob("ENSURE_SONG_EXISTS_BY_SONG_ID", { songId }, this)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, thumbnail, duration, status } = song;
 							next(null, {
-								_id: song._id,
+								_id,
 								songId,
-								title: song.title,
-								duration: song.duration,
-								thumbnail: song.thumbnail,
-								artists: song.artists,
-								position
+								title,
+								thumbnail,
+								duration,
+								status
 							});
 						})
-						.catch(() => {
-							YouTubeModule.runJob("GET_SONG", { songId }, this)
-								.then(response => next(null, { ...response.song, position }))
-								.catch(next);
-						});
+						.catch(next);
 				},
 				(newSong, next) => {
 					playlistModel.updateOne(
@@ -1541,5 +1536,75 @@ export default {
 				return cb({ status: "success", message: "Success" });
 			}
 		);
+	}),
+
+	/**
+	 * Deletes all orphaned genre playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_GENRE_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
+						`Deleting orphaned genre playlists failed. "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
+					"Deleting orphaned genre playlists successfull."
+				);
+				return cb({ status: "success", message: "Success" });
+			}
+		);
+	}),
+
+	/**
+	 * Requests orpahned playlist songs
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestOrphanedPlaylistSongs: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("REQUEST_ORPHANED_PLAYLIST_SONGS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_ORPHANED_PLAYLIST_SONGS",
+						`Requesting orphaned playlist songs failed. "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"REQUEST_ORPHANED_PLAYLIST_SONGS",
+					"Requesting orphaned playlist songs was successfull."
+				);
+				return cb({ status: "success", message: "Success" });
+			}
+		);
 	})
 };

+ 405 - 425
backend/logic/actions/queueSongs.js

@@ -12,434 +12,414 @@ const WSModule = moduleManager.modules.ws;
 const YouTubeModule = moduleManager.modules.youtube;
 const CacheModule = moduleManager.modules.cache;
 
-CacheModule.runJob("SUB", {
-	channel: "queue.newSong",
-	cb: async songId => {
-		const queueSongModel = await DBModule.runJob("GET_MODEL", {
-			modelName: "queueSong"
-		});
-		queueSongModel.findOne({ _id: songId }, (err, song) => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: "admin.queue",
-				args: ["event:admin.queueSong.added", song]
-			});
-		});
-	}
-});
+// CacheModule.runJob("SUB", {
+// 	channel: "queue.newSong",
+// 	cb: async songId => {
+// 		const queueSongModel = await DBModule.runJob("GET_MODEL", {
+// 			modelName: "queueSong"
+// 		});
+// 		queueSongModel.findOne({ _id: songId }, (err, song) => {
+// 			WSModule.runJob("EMIT_TO_ROOM", {
+// 				room: "admin.queue",
+// 				args: ["event:admin.queueSong.added", song]
+// 			});
+// 		});
+// 	}
+// });
 
-CacheModule.runJob("SUB", {
-	channel: "queue.removedSong",
-	cb: songId => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.queue",
-			args: ["event:admin.queueSong.removed", songId]
-		});
-	}
-});
+// CacheModule.runJob("SUB", {
+// 	channel: "queue.removedSong",
+// 	cb: songId => {
+// 		WSModule.runJob("EMIT_TO_ROOM", {
+// 			room: "admin.queue",
+// 			args: ["event:admin.queueSong.removed", songId]
+// 		});
+// 	}
+// });
 
-CacheModule.runJob("SUB", {
-	channel: "queue.update",
-	cb: async songId => {
-		const queueSongModel = await DBModule.runJob("GET_MODEL", {
-			modelName: "queueSong"
-		});
+// CacheModule.runJob("SUB", {
+// 	channel: "queue.update",
+// 	cb: async songId => {
+// 		const queueSongModel = await DBModule.runJob("GET_MODEL", {
+// 			modelName: "queueSong"
+// 		});
 
-		queueSongModel.findOne({ _id: songId }, (err, song) => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: "admin.queue",
-				args: ["event:admin.queueSong.updated", song]
-			});
-		});
-	}
-});
+// 		queueSongModel.findOne({ _id: songId }, (err, song) => {
+// 			WSModule.runJob("EMIT_TO_ROOM", {
+// 				room: "admin.queue",
+// 				args: ["event:admin.queueSong.updated", song]
+// 			});
+// 		});
+// 	}
+// });
 
 export default {
-	/**
-	 * Returns the length of the queue songs list
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	length: isAdminRequired(async function length(session, cb) {
-		const queueSongModel = await DBModule.runJob("GET_MODEL", { modelName: "queueSong" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					queueSongModel.countDocuments({}, next);
-				}
-			],
-			async (err, count) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "QUEUE_SONGS_LENGTH", `Failed to get length from queue songs. "${err}"`);
-					return cb({ status: "failure", message: err });
-				}
-				this.log("SUCCESS", "QUEUE_SONGS_LENGTH", `Got length from queue songs successfully.`);
-				return cb(count);
-			}
-		);
-	}),
-
-	/**
-	 * Gets a set of queue songs
-	 *
-	 * @param session
-	 * @param set - the set number to return
-	 * @param cb
-	 */
-	getSet: isAdminRequired(async function getSet(session, set, cb) {
-		const queueSongModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "queueSong"
-			},
-			this
-		);
-		async.waterfall(
-			[
-				next => {
-					queueSongModel
-						.find({})
-						.skip(15 * (set - 1))
-						.limit(15)
-						.exec(next);
-				}
-			],
-			async (err, songs) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "QUEUE_SONGS_GET_SET", `Failed to get set from queue songs. "${err}"`);
-					return cb({ status: "failure", message: err });
-				}
-				this.log("SUCCESS", "QUEUE_SONGS_GET_SET", `Got set from queue songs successfully.`);
-				return cb(songs);
-			}
-		);
-	}),
-
-	/**
-	 * Gets a song from the Musare song id
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} songId - the Musare song id
-	 * @param {Function} cb
-	 */
-	getSongFromMusareId: isAdminRequired(async function getSong(session, songId, cb) {
-		const queueSongModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "queueSong"
-			},
-			this
-		);
-
-		async.waterfall(
-			[
-				next => {
-					queueSongModel.findOne({ _id: songId }, next);
-				}
-			],
-			async (err, song) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "QUEUE_SONGS_GET_SONG_FROM_MUSARE_ID", `Failed to get song ${songId}. "${err}"`);
-					return cb({ status: "failure", message: err });
-				}
-				this.log("SUCCESS", "QUEUE_SONGS_GET_SONG_FROM_MUSARE_ID", `Got song ${songId} successfully.`);
-				return cb({ status: "success", data: { song } });
-			}
-		);
-	}),
-
-	/**
-	 * Updates a queuesong
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} songId - the id of the queuesong that gets updated
-	 * @param {object} updatedSong - the object of the updated queueSong
-	 * @param {Function} cb - gets called with the result
-	 */
-	update: isAdminRequired(async function update(session, songId, updatedSong, cb) {
-		const queueSongModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "queueSong"
-			},
-			this
-		);
-		async.waterfall(
-			[
-				next => {
-					queueSongModel.findOne({ _id: songId }, next);
-				},
-
-				(song, next) => {
-					if (!song) return next("Song not found");
-
-					let updated = false;
-
-					const $set = {};
-					Object.keys(updatedSong).forEach(prop => {
-						if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop];
-					});
-
-					updated = true;
-					if (!updated) return next("No properties changed");
-
-					return queueSongModel.updateOne({ _id: songId }, { $set }, { runValidators: true }, next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"QUEUE_UPDATE",
-						`Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`
-					);
-					return cb({ status: "failure", message: err });
-				}
-				CacheModule.runJob("PUB", { channel: "queue.update", value: songId });
-				this.log(
-					"SUCCESS",
-					"QUEUE_UPDATE",
-					`User "${session.userId}" successfully update queuesong "${songId}".`
-				);
-				return cb({
-					status: "success",
-					message: "Successfully updated song."
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Removes a queuesong
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} songId - the id of the queuesong that gets removed
-	 * @param {Function} cb - gets called with the result
-	 */
-	remove: isAdminRequired(async function remove(session, songId, cb) {
-		const queueSongModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "queueSong"
-			},
-			this
-		);
-		async.waterfall(
-			[
-				next => {
-					queueSongModel.deleteOne({ _id: songId }, next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"QUEUE_REMOVE",
-						`Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`
-					);
-					return cb({ status: "failure", message: err });
-				}
-				CacheModule.runJob("PUB", {
-					channel: "queue.removedSong",
-					value: songId
-				});
-				this.log(
-					"SUCCESS",
-					"QUEUE_REMOVE",
-					`User "${session.userId}" successfully removed queuesong "${songId}".`
-				);
-				return cb({
-					status: "success",
-					message: "Successfully updated song."
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Creates a queuesong
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} songId - the id of the song that gets added
-	 * @param {Function} cb - gets called with the result
-	 */
-	add: isLoginRequired(async function add(session, songId, cb) {
-		const requestedAt = Date.now();
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const QueueSongModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "queueSong"
-			},
-			this
-		);
-
-		async.waterfall(
-			[
-				next => {
-					QueueSongModel.findOne({ songId }, next);
-				},
-
-				(song, next) => {
-					if (song) return next("This song is already in the queue.");
-					return songModel.findOne({ songId }, next);
-				},
-
-				// Get YouTube data from id
-				(song, next) => {
-					if (song) return next("This song has already been added.");
-					// TODO Add err object as first param of callback
-					return YouTubeModule.runJob("GET_SONG", { songId }, this)
-						.then(response => {
-							const { song } = response;
-							song.duration = -1;
-							song.artists = [];
-							song.genres = [];
-							song.skipDuration = 0;
-							song.thumbnail = `${config.get("domain")}/assets/notes.png`;
-							song.explicit = false;
-							song.requestedBy = session.userId;
-							song.requestedAt = requestedAt;
-							next(null, song);
-						})
-						.catch(next);
-				},
-				(newSong, next) => {
-					const song = new QueueSongModel(newSong);
-					song.save({ validateBeforeSave: false }, (err, song) => {
-						if (err) return next(err);
-						return next(null, song);
-					});
-				},
-				(newSong, next) => {
-					userModel.findOne({ _id: session.userId }, (err, user) => {
-						if (err) return next(err, newSong);
-
-						user.statistics.songsRequested += 1;
-
-						return user.save(err => {
-							if (err) return next(err, newSong);
-							return next(null, newSong);
-						});
-					});
-				}
-			],
-			async (err, newSong) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"QUEUE_ADD",
-						`Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`
-					);
-					return cb({ status: "failure", message: err });
-				}
-				CacheModule.runJob("PUB", {
-					channel: "queue.newSong",
-					value: newSong._id
-				});
-				this.log("SUCCESS", "QUEUE_ADD", `User "${session.userId}" successfully added queuesong "${songId}".`);
-				return cb({
-					status: "success",
-					message: "Successfully added that song to the queue"
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Adds a set of songs to the queue
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} url - the url of the the YouTube playlist
-	 * @param {boolean} musicOnly - whether to only get music from the playlist
-	 * @param {Function} cb - gets called with the result
-	 */
-	addSetToQueue: isLoginRequired(function addSetToQueue(session, url, musicOnly, cb) {
-		async.waterfall(
-			[
-				next => {
-					YouTubeModule.runJob(
-						"GET_PLAYLIST",
-						{
-							url,
-							musicOnly
-						},
-						this
-					)
-						.then(res => {
-							next(null, res.songs);
-						})
-						.catch(next);
-				},
-				(songIds, next) => {
-					let successful = 0;
-					let failed = 0;
-					let alreadyInQueue = 0;
-					let alreadyAdded = 0;
-
-					if (songIds.length === 0) next();
-
-					async.eachLimit(
-						songIds,
-						1,
-						(songId, next) => {
-							WSModule.runJob(
-								"RUN_ACTION2",
-								{
-									session,
-									namespace: "queueSongs",
-									action: "add",
-									args: [songId]
-								},
-								this
-							)
-								.then(res => {
-									if (res.status === "success") successful += 1;
-									else failed += 1;
-									if (res.message === "This song is already in the queue.") alreadyInQueue += 1;
-									if (res.message === "This song has already been added.") alreadyAdded += 1;
-								})
-								.catch(() => {
-									failed += 1;
-								})
-								.finally(() => {
-									next();
-								});
-						},
-						() => {
-							next(null, { successful, failed, alreadyInQueue, alreadyAdded });
-						}
-					);
-				}
-			],
-			async (err, response) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"QUEUE_IMPORT",
-						`Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`
-					);
-					return cb({ status: "failure", message: err });
-				}
-				this.log(
-					"SUCCESS",
-					"QUEUE_IMPORT",
-					`Successfully imported a YouTube playlist to the queue for user "${session.userId}".`
-				);
-				return cb({
-					status: "success",
-					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInQueue} were already in queue, ${response.alreadyAdded} were already added)`
-				});
-			}
-		);
-	})
+	// /**
+	//  * Returns the length of the queue songs list
+	//  *
+	//  * @param session
+	//  * @param cb
+	//  */
+	// length: isAdminRequired(async function length(session, cb) {
+	// 	const queueSongModel = await DBModule.runJob("GET_MODEL", { modelName: "queueSong" }, this);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				queueSongModel.countDocuments({}, next);
+	// 			}
+	// 		],
+	// 		async (err, count) => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log("ERROR", "QUEUE_SONGS_LENGTH", `Failed to get length from queue songs. "${err}"`);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			this.log("SUCCESS", "QUEUE_SONGS_LENGTH", `Got length from queue songs successfully.`);
+	// 			return cb(count);
+	// 		}
+	// 	);
+	// }),
+	// /**
+	//  * Gets a set of queue songs
+	//  *
+	//  * @param session
+	//  * @param set - the set number to return
+	//  * @param cb
+	//  */
+	// getSet: isAdminRequired(async function getSet(session, set, cb) {
+	// 	const queueSongModel = await DBModule.runJob(
+	// 		"GET_MODEL",
+	// 		{
+	// 			modelName: "queueSong"
+	// 		},
+	// 		this
+	// 	);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				queueSongModel
+	// 					.find({})
+	// 					.skip(15 * (set - 1))
+	// 					.limit(15)
+	// 					.exec(next);
+	// 			}
+	// 		],
+	// 		async (err, songs) => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log("ERROR", "QUEUE_SONGS_GET_SET", `Failed to get set from queue songs. "${err}"`);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			this.log("SUCCESS", "QUEUE_SONGS_GET_SET", `Got set from queue songs successfully.`);
+	// 			return cb(songs);
+	// 		}
+	// 	);
+	// }),
+	// /**
+	//  * Gets a song from the Musare song id
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} songId - the Musare song id
+	//  * @param {Function} cb
+	//  */
+	// getSongFromMusareId: isAdminRequired(async function getSong(session, songId, cb) {
+	// 	const queueSongModel = await DBModule.runJob(
+	// 		"GET_MODEL",
+	// 		{
+	// 			modelName: "queueSong"
+	// 		},
+	// 		this
+	// 	);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				queueSongModel.findOne({ _id: songId }, next);
+	// 			}
+	// 		],
+	// 		async (err, song) => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log("ERROR", "QUEUE_SONGS_GET_SONG_FROM_MUSARE_ID", `Failed to get song ${songId}. "${err}"`);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			this.log("SUCCESS", "QUEUE_SONGS_GET_SONG_FROM_MUSARE_ID", `Got song ${songId} successfully.`);
+	// 			return cb({ status: "success", data: { song } });
+	// 		}
+	// 	);
+	// }),
+	// /**
+	//  * Updates a queuesong
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} songId - the id of the queuesong that gets updated
+	//  * @param {object} updatedSong - the object of the updated queueSong
+	//  * @param {Function} cb - gets called with the result
+	//  */
+	// update: isAdminRequired(async function update(session, songId, updatedSong, cb) {
+	// 	const queueSongModel = await DBModule.runJob(
+	// 		"GET_MODEL",
+	// 		{
+	// 			modelName: "queueSong"
+	// 		},
+	// 		this
+	// 	);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				queueSongModel.findOne({ _id: songId }, next);
+	// 			},
+	// 			(song, next) => {
+	// 				if (!song) return next("Song not found");
+	// 				let updated = false;
+	// 				const $set = {};
+	// 				Object.keys(updatedSong).forEach(prop => {
+	// 					if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop];
+	// 				});
+	// 				updated = true;
+	// 				if (!updated) return next("No properties changed");
+	// 				return queueSongModel.updateOne({ _id: songId }, { $set }, { runValidators: true }, next);
+	// 			}
+	// 		],
+	// 		async err => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"QUEUE_UPDATE",
+	// 					`Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+	// 				);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			CacheModule.runJob("PUB", { channel: "queue.update", value: songId });
+	// 			this.log(
+	// 				"SUCCESS",
+	// 				"QUEUE_UPDATE",
+	// 				`User "${session.userId}" successfully update queuesong "${songId}".`
+	// 			);
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Successfully updated song."
+	// 			});
+	// 		}
+	// 	);
+	// }),
+	// /**
+	//  * Removes a queuesong
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} songId - the id of the queuesong that gets removed
+	//  * @param {Function} cb - gets called with the result
+	//  */
+	// remove: isAdminRequired(async function remove(session, songId, cb) {
+	// 	const queueSongModel = await DBModule.runJob(
+	// 		"GET_MODEL",
+	// 		{
+	// 			modelName: "queueSong"
+	// 		},
+	// 		this
+	// 	);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				queueSongModel.deleteOne({ _id: songId }, next);
+	// 			}
+	// 		],
+	// 		async err => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"QUEUE_REMOVE",
+	// 					`Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+	// 				);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			CacheModule.runJob("PUB", {
+	// 				channel: "queue.removedSong",
+	// 				value: songId
+	// 			});
+	// 			this.log(
+	// 				"SUCCESS",
+	// 				"QUEUE_REMOVE",
+	// 				`User "${session.userId}" successfully removed queuesong "${songId}".`
+	// 			);
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Successfully updated song."
+	// 			});
+	// 		}
+	// 	);
+	// }),
+	// /**
+	//  * Creates a queuesong
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} songId - the id of the song that gets added
+	//  * @param {Function} cb - gets called with the result
+	//  */
+	// add: isLoginRequired(async function add(session, songId, cb) {
+	// 	const requestedAt = Date.now();
+	// 	const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	// 	const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+	// 	const QueueSongModel = await DBModule.runJob(
+	// 		"GET_MODEL",
+	// 		{
+	// 			modelName: "queueSong"
+	// 		},
+	// 		this
+	// 	);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				QueueSongModel.findOne({ songId }, next);
+	// 			},
+	// 			(song, next) => {
+	// 				if (song) return next("This song is already in the queue.");
+	// 				return songModel.findOne({ songId }, next);
+	// 			},
+	// 			// Get YouTube data from id
+	// 			(song, next) => {
+	// 				if (song) return next("This song has already been added.");
+	// 				// TODO Add err object as first param of callback
+	// 				return YouTubeModule.runJob("GET_SONG", { songId }, this)
+	// 					.then(response => {
+	// 						const { song } = response;
+	// 						song.duration = -1;
+	// 						song.artists = [];
+	// 						song.genres = [];
+	// 						song.skipDuration = 0;
+	// 						song.thumbnail = `${config.get("domain")}/assets/notes.png`;
+	// 						song.explicit = false;
+	// 						song.requestedBy = session.userId;
+	// 						song.requestedAt = requestedAt;
+	// 						next(null, song);
+	// 					})
+	// 					.catch(next);
+	// 			},
+	// 			(newSong, next) => {
+	// 				const song = new QueueSongModel(newSong);
+	// 				song.save({ validateBeforeSave: false }, (err, song) => {
+	// 					if (err) return next(err);
+	// 					return next(null, song);
+	// 				});
+	// 			},
+	// 			(newSong, next) => {
+	// 				userModel.findOne({ _id: session.userId }, (err, user) => {
+	// 					if (err) return next(err, newSong);
+	// 					user.statistics.songsRequested += 1;
+	// 					return user.save(err => {
+	// 						if (err) return next(err, newSong);
+	// 						return next(null, newSong);
+	// 					});
+	// 				});
+	// 			}
+	// 		],
+	// 		async (err, newSong) => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"QUEUE_ADD",
+	// 					`Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+	// 				);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			CacheModule.runJob("PUB", {
+	// 				channel: "queue.newSong",
+	// 				value: newSong._id
+	// 			});
+	// 			this.log("SUCCESS", "QUEUE_ADD", `User "${session.userId}" successfully added queuesong "${songId}".`);
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Successfully added that song to the queue"
+	// 			});
+	// 		}
+	// 	);
+	// }),
+	// /**
+	//  * Adds a set of songs to the queue
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} url - the url of the the YouTube playlist
+	//  * @param {boolean} musicOnly - whether to only get music from the playlist
+	//  * @param {Function} cb - gets called with the result
+	//  */
+	// addSetToQueue: isLoginRequired(function addSetToQueue(session, url, musicOnly, cb) {
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				YouTubeModule.runJob(
+	// 					"GET_PLAYLIST",
+	// 					{
+	// 						url,
+	// 						musicOnly
+	// 					},
+	// 					this
+	// 				)
+	// 					.then(res => {
+	// 						next(null, res.songs);
+	// 					})
+	// 					.catch(next);
+	// 			},
+	// 			(songIds, next) => {
+	// 				let successful = 0;
+	// 				let failed = 0;
+	// 				let alreadyInQueue = 0;
+	// 				let alreadyAdded = 0;
+	// 				if (songIds.length === 0) next();
+	// 				async.eachLimit(
+	// 					songIds,
+	// 					1,
+	// 					(songId, next) => {
+	// 						WSModule.runJob(
+	// 							"RUN_ACTION2",
+	// 							{
+	// 								session,
+	// 								namespace: "queueSongs",
+	// 								action: "add",
+	// 								args: [songId]
+	// 							},
+	// 							this
+	// 						)
+	// 							.then(res => {
+	// 								if (res.status === "success") successful += 1;
+	// 								else failed += 1;
+	// 								if (res.message === "This song is already in the queue.") alreadyInQueue += 1;
+	// 								if (res.message === "This song has already been added.") alreadyAdded += 1;
+	// 							})
+	// 							.catch(() => {
+	// 								failed += 1;
+	// 							})
+	// 							.finally(() => {
+	// 								next();
+	// 							});
+	// 					},
+	// 					() => {
+	// 						next(null, { successful, failed, alreadyInQueue, alreadyAdded });
+	// 					}
+	// 				);
+	// 			}
+	// 		],
+	// 		async (err, response) => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"QUEUE_IMPORT",
+	// 					`Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`
+	// 				);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+	// 			this.log(
+	// 				"SUCCESS",
+	// 				"QUEUE_IMPORT",
+	// 				`Successfully imported a YouTube playlist to the queue for user "${session.userId}".`
+	// 			);
+	// 			return cb({
+	// 				status: "success",
+	// 				message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInQueue} were already in queue, ${response.alreadyAdded} were already added)`
+	// 			});
+	// 		}
+	// 	);
+	// })
 };

+ 509 - 84
backend/logic/actions/songs.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 
 import { isAdminRequired, isLoginRequired } from "./hooks";
 
@@ -10,39 +11,122 @@ const WSModule = moduleManager.modules.ws;
 const CacheModule = moduleManager.modules.cache;
 const SongsModule = moduleManager.modules.songs;
 const ActivitiesModule = moduleManager.modules.activities;
+const YouTubeModule = moduleManager.modules.youtube;
 const PlaylistsModule = moduleManager.modules.playlists;
 
 CacheModule.runJob("SUB", {
-	channel: "song.removed",
+	channel: "song.newUnverifiedSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.unverifiedSongs",
+				args: ["event:admin.unverifiedSong.added", song]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.removedUnverifiedSong",
 	cb: songId => {
 		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.songs",
-			args: ["event:admin.song.removed", songId]
+			room: "admin.unverifiedSongs",
+			args: ["event:admin.unverifiedSong.removed", songId]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.updatedUnverifiedSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.unverifiedSongs",
+				args: ["event:admin.unverifiedSong.updated", song]
+			});
 		});
 	}
 });
 
 CacheModule.runJob("SUB", {
-	channel: "song.added",
+	channel: "song.newVerifiedSong",
 	cb: async songId => {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
 		songModel.findOne({ songId }, (err, song) => {
 			WSModule.runJob("EMIT_TO_ROOM", {
 				room: "admin.songs",
-				args: ["event:admin.song.added", song]
+				args: ["event:admin.verifiedSong.added", song]
 			});
 		});
 	}
 });
 
 CacheModule.runJob("SUB", {
-	channel: "song.updated",
+	channel: "song.removedVerifiedSong",
+	cb: songId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.songs",
+			args: ["event:admin.verifiedSong.removed", songId]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.updatedVerifiedSong",
 	cb: async songId => {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
 		songModel.findOne({ songId }, (err, song) => {
 			WSModule.runJob("EMIT_TO_ROOM", {
 				room: "admin.songs",
-				args: ["event:admin.song.updated", song]
+				args: ["event:admin.verifiedSong.updated", song]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.newHiddenSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.hiddenSongs",
+				args: ["event:admin.hiddenSong.added", song]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.removedHiddenSong",
+	cb: songId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.hiddenSongs",
+			args: ["event:admin.hiddenSong.removed", songId]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "song.updatedHiddenSong",
+	cb: async songId => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
+		});
+
+		songModel.findOne({ _id: songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.hiddenSongs",
+				args: ["event:admin.hiddenSong.updated", song]
 			});
 		});
 	}
@@ -160,21 +244,29 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	length: isAdminRequired(async function length(session, cb) {
+	length: isAdminRequired(async function length(session, status, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
 				next => {
-					songModel.countDocuments({}, next);
+					songModel.countDocuments({ status }, next);
 				}
 			],
 			async (err, count) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
+					this.log(
+						"ERROR",
+						"SONGS_LENGTH",
+						`Failed to get length from songs that have the status ${status}. "${err}"`
+					);
 					return cb({ status: "failure", message: err });
 				}
-				this.log("SUCCESS", "SONGS_LENGTH", `Got length from songs successfully.`);
+				this.log(
+					"SUCCESS",
+					"SONGS_LENGTH",
+					`Got length from songs that have the status ${status} successfully.`
+				);
 				return cb(count);
 			}
 		);
@@ -187,13 +279,13 @@ export default {
 	 * @param set - the set number to return
 	 * @param cb
 	 */
-	getSet: isAdminRequired(async function getSet(session, set, cb) {
+	getSet: isAdminRequired(async function getSet(session, set, status, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
 				next => {
 					songModel
-						.find({})
+						.find({ status })
 						.skip(15 * (set - 1))
 						.limit(15)
 						.exec(next);
@@ -202,10 +294,14 @@ export default {
 			async (err, songs) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
+					this.log(
+						"ERROR",
+						"SONGS_GET_SET",
+						`Failed to get set from songs that have the status ${status}. "${err}"`
+					);
 					return cb({ status: "failure", message: err });
 				}
-				this.log("SUCCESS", "SONGS_GET_SET", `Got set from songs successfully.`);
+				this.log("SUCCESS", "SONGS_GET_SET", `Got set from songs that have the status ${status} successfully.`);
 				return cb(songs);
 			}
 		);
@@ -377,10 +473,22 @@ export default {
 
 				this.log("SUCCESS", "SONGS_UPDATE", `Successfully updated song "${songId}".`);
 
-				CacheModule.runJob("PUB", {
-					channel: "song.updated",
-					value: song.songId
-				});
+				if (song.status === "verified") {
+					CacheModule.runJob("PUB", {
+						channel: "song.updatedVerifiedSong",
+						value: song.songId
+					});
+				} else if (song.status === "unverified") {
+					CacheModule.runJob("PUB", {
+						channel: "song.updatedUnverifiedSong",
+						value: song.songId
+					});
+				} else if (song.status === "hidden") {
+					CacheModule.runJob("PUB", {
+						channel: "song.updatedHiddenSong",
+						value: song.songId
+					});
+				}
 
 				return cb({
 					status: "success",
@@ -391,139 +499,456 @@ export default {
 		);
 	}),
 
+	// /**
+	//  * Removes a song
+	//  *
+	//  * @param session
+	//  * @param songId - the song id
+	//  * @param cb
+	//  */
+	// remove: isAdminRequired(async function remove(session, songId, cb) {
+	// 	const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	// 	let song = null;
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				songModel.findOne({ _id: songId }, next);
+	// 			},
+
+	// 			(_song, next) => {
+	// 				song = _song;
+	// 				songModel.deleteOne({ _id: songId }, next);
+	// 			},
+
+	// 			(res, next) => {
+	// 				// TODO Check if res gets returned from above
+	// 				CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
+	// 					.then(() => {
+	// 						next();
+	// 					})
+	// 					.catch(next)
+	// 					.finally(() => {
+	// 						song.genres.forEach(genre => {
+	// 							PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+	// 								.then(() => {})
+	// 								.catch(() => {});
+	// 						});
+	// 					});
+	// 			}
+	// 		],
+	// 		async err => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+	// 				this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
+
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+
+	// 			this.log("SUCCESS", "SONGS_REMOVE", `Successfully remove song "${songId}".`);
+
+	// 			if (song.verified) {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedVerifiedSong",
+	// 					value: songId
+	// 				});
+	// 			} else {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedUnverifiedSong",
+	// 					value: songId
+	// 				});
+	// 			}
+
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Song has been successfully removed"
+	// 			});
+	// 		}
+	// 	);
+	// }),
+
+	/**
+	 * Requests a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the id of the song that gets requested
+	 * @param {Function} cb - gets called with the result
+	 */
+	request: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("REQUEST_SONG", { songId, userId: session.userId }, this)
+			.then(() => {
+				this.log(
+					"SUCCESS",
+					"SONGS_REQUEST",
+					`User "${session.userId}" successfully requested song "${songId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully requested that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"SONGS_REQUEST",
+					`Requesting song "${songId}" failed for user ${session.userId}. "${err}"`
+				);
+				return cb({ status: "failure", message: err });
+			});
+	}),
+
 	/**
-	 * Removes a song
+	 * Hides a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the Musare id of the song that gets hidden
+	 * @param {Function} cb - gets called with the result
+	 */
+	hide: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("HIDE_SONG", { songId }, this)
+			.then(() => {
+				this.log("SUCCESS", "SONGS_HIDE", `User "${session.userId}" successfully hid song "${songId}".`);
+				return cb({
+					status: "success",
+					message: "Successfully hid that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SONGS_HIDE", `Hiding song "${songId}" failed for user ${session.userId}. "${err}"`);
+				return cb({ status: "failure", message: err });
+			});
+	}),
+
+	/**
+	 * Unhides a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the Musare id of the song that gets hidden
+	 * @param {Function} cb - gets called with the result
+	 */
+	unhide: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("UNHIDE_SONG", { songId }, this)
+			.then(() => {
+				this.log("SUCCESS", "SONGS_UNHIDE", `User "${session.userId}" successfully unhid song "${songId}".`);
+				return cb({
+					status: "success",
+					message: "Successfully unhid that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"SONGS_UNHIDE",
+					`Unhiding song "${songId}" failed for user ${session.userId}. "${err}"`
+				);
+				return cb({ status: "failure", message: err });
+			});
+	}),
+
+	/**
+	 * Verifies a song
 	 *
 	 * @param session
-	 * @param songId - the song id
+	 * @param song - the song object
 	 * @param cb
 	 */
-	remove: isAdminRequired(async function remove(session, songId, cb) {
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-		let song = null;
+	verify: isAdminRequired(async function add(session, songId, cb) {
+		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
 				next => {
-					songModel.findOne({ _id: songId }, next);
+					SongModel.findOne({ songId }, next);
 				},
 
-				(_song, next) => {
-					song = _song;
-					songModel.deleteOne({ _id: songId }, next);
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
 				},
 
-				(res, next) => {
-					// TODO Check if res gets returned from above
-					CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
-						.then(() => {
-							next();
-						})
-						.catch(next)
-						.finally(() => {
-							song.genres.forEach(genre => {
-								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
-									.then(() => {})
-									.catch(() => {});
-							});
-						});
+				(song, next) => {
+					song.acceptedBy = session.userId;
+					song.acceptedAt = Date.now();
+					song.status = "verified";
+					song.save(err => {
+						next(err, song);
+					});
+				},
+
+				(song, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+					next(null, song);
 				}
 			],
-			async err => {
+			async (err, song) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
-					this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
+					this.log("ERROR", "SONGS_VERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
 
 					return cb({ status: "failure", message: err });
 				}
 
-				this.log("SUCCESS", "SONGS_REMOVE", `Successfully remove song "${songId}".`);
+				this.log("SUCCESS", "SONGS_VERIFY", `User "${session.userId}" successfully verified song "${songId}".`);
 
-				CacheModule.runJob("PUB", { channel: "song.removed", value: songId });
+				CacheModule.runJob("PUB", {
+					channel: "song.newVerifiedSong",
+					value: song._id
+				});
 
 				return cb({
 					status: "success",
-					message: "Song has been successfully removed"
+					message: "Song has been verified successfully."
 				});
 			}
 		);
+		// TODO Check if video is in queue and Add the song to the appropriate stations
 	}),
 
 	/**
-	 * Adds a song
+	 * Un-verifies a song
 	 *
 	 * @param session
-	 * @param song - the song object
+	 * @param songId - the song id
 	 * @param cb
 	 */
-	add: isAdminRequired(async function add(session, song, cb) {
+	unverify: isAdminRequired(async function add(session, songId, cb) {
 		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
 				next => {
-					SongModel.findOne({ songId: song.songId }, next);
+					SongModel.findOne({ _id: songId }, next);
 				},
 
-				(existingSong, next) => {
-					if (existingSong) return next("Song is already in rotation.");
-					return next();
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
 				},
 
-				next => {
-					const newSong = new SongModel(song);
-					newSong.acceptedBy = session.userId;
-					newSong.acceptedAt = Date.now();
-					newSong.save(next);
+				(song, next) => {
+					song.status = "unverified";
+					song.save(err => {
+						next(err, song);
+					});
 				},
 
-				(res, next) => {
-					this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "queueSongs",
-								action: "remove",
-								args: [song._id]
-							},
-							this
-						)
-						.finally(() => {
-							song.genres.forEach(genre => {
-								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
-									.then(() => {})
-									.catch(() => {});
-							});
+				(song, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
 
-							next();
-						});
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+					next(null);
 				}
 			],
 			async err => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
-					this.log("ERROR", "SONGS_ADD", `User "${session.userId}" failed to add song. "${err}"`);
+					this.log("ERROR", "SONGS_UNVERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
 
 					return cb({ status: "failure", message: err });
 				}
 
-				this.log("SUCCESS", "SONGS_ADD", `User "${session.userId}" successfully added song "${song.songId}".`);
+				this.log(
+					"SUCCESS",
+					"SONGS_UNVERIFY",
+					`User "${session.userId}" successfully unverified song "${songId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "song.newUnverifiedSong",
+					value: songId
+				});
 
 				CacheModule.runJob("PUB", {
-					channel: "song.added",
-					value: song.songId
+					channel: "song.removedVerifiedSong",
+					value: songId
 				});
 
 				return cb({
 					status: "success",
-					message: "Song has been moved from the queue successfully."
+					message: "Song has been unverified successfully."
 				});
 			}
 		);
 		// TODO Check if video is in queue and Add the song to the appropriate stations
 	}),
 
+	/**
+	 * Requests a set of songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the YouTube playlist
+	 * @param {boolean} musicOnly - whether to only get music from the playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, cb) {
+		async.waterfall(
+			[
+				next => {
+					YouTubeModule.runJob(
+						"GET_PLAYLIST",
+						{
+							url,
+							musicOnly
+						},
+						this
+					)
+						.then(res => {
+							next(null, res.songs);
+						})
+						.catch(next);
+				},
+				(songIds, next) => {
+					let successful = 0;
+					let failed = 0;
+					let alreadyInDatabase = 0;
+
+					if (songIds.length === 0) next();
+
+					async.eachLimit(
+						songIds,
+						1,
+						(songId, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "songs",
+									action: "request",
+									args: [songId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "success") successful += 1;
+									else failed += 1;
+									if (res.message === "This song is already in the database.") alreadyInDatabase += 1;
+								})
+								.catch(() => {
+									failed += 1;
+								})
+								.finally(() => {
+									next();
+								});
+						},
+						() => {
+							next(null, { successful, failed, alreadyInDatabase });
+						}
+					);
+				}
+			],
+			async (err, response) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_SET",
+						`Importing a YouTube playlist to be requested failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"REQUEST_SET",
+					`Successfully imported a YouTube playlist to be requested for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
+				});
+			}
+		);
+	}),
+
+	// /**
+	//  * Adds a song
+	//  *
+	//  * @param session
+	//  * @param song - the song object
+	//  * @param cb
+	//  */
+	// add: isAdminRequired(async function add(session, song, cb) {
+	// 	const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				SongModel.findOne({ songId: song.songId }, next);
+	// 			},
+
+	// 			(existingSong, next) => {
+	// 				if (existingSong) return next("Song is already in rotation.");
+	// 				return next();
+	// 			},
+
+	// 			next => {
+	// 				const newSong = new SongModel(song);
+	// 				newSong.acceptedBy = session.userId;
+	// 				newSong.acceptedAt = Date.now();
+	// 				newSong.save(next);
+	// 			},
+
+	// 			(res, next) => {
+	// 				this.module
+	// 					.runJob(
+	// 						"RUN_ACTION2",
+	// 						{
+	// 							session,
+	// 							namespace: "queueSongs",
+	// 							action: "remove",
+	// 							args: [song._id]
+	// 						},
+	// 						this
+	// 					)
+	// 					.finally(() => {
+	// 						song.genres.forEach(genre => {
+	// 							PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+	// 								.then(() => {})
+	// 								.catch(() => {});
+	// 						});
+
+	// 						next();
+	// 					});
+	// 			}
+	// 		],
+	// 		async err => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+	// 				this.log("ERROR", "SONGS_ADD", `User "${session.userId}" failed to add song. "${err}"`);
+
+	// 				return cb({ status: "failure", message: err });
+	// 			}
+
+	// 			this.log("SUCCESS", "SONGS_ADD", `User "${session.userId}" successfully added song "${song.songId}".`);
+
+	// 			CacheModule.runJob("PUB", {
+	// 				channel: "song.added",
+	// 				value: song.songId
+	// 			});
+
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Song has been moved from the queue successfully."
+	// 			});
+	// 		}
+	// 	);
+	// 	// TODO Check if video is in queue and Add the song to the appropriate stations
+	// }),
+
 	/**
 	 * Likes a song
 	 *

+ 47 - 27
backend/logic/actions/stations.js

@@ -1,7 +1,7 @@
 import async from "async";
 import mongoose from "mongoose";
 
-import { isLoginRequired, isOwnerRequired } from "./hooks";
+import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
 
 import moduleManager from "../../index";
 
@@ -14,7 +14,6 @@ const CacheModule = moduleManager.modules.cache;
 const NotificationsModule = moduleManager.modules.notifications;
 const StationsModule = moduleManager.modules.stations;
 const ActivitiesModule = moduleManager.modules.activities;
-const YouTubeModule = moduleManager.modules.youtube;
 
 CacheModule.runJob("SUB", {
 	channel: "station.updateUsers",
@@ -420,8 +419,9 @@ export default {
 				(items, next) => {
 					const filteredStations = [];
 
-					async.each(
+					async.eachLimit(
 						items,
+						1,
 						(station, nextStation) => {
 							async.waterfall(
 								[
@@ -2816,36 +2816,29 @@ export default {
 				},
 
 				(station, next) => {
-					SongsModule.runJob("GET_SONG_FROM_ID", { songId }, this)
-						.then(res => {
-							if (res.song) return next(null, res.song, station);
-
-							return YouTubeModule.runJob("GET_SONG", { songId }, this)
-								.then(response => {
-									const { song } = response;
-									song.artists = [];
-									song.skipDuration = 0;
-									song.likes = -1;
-									song.dislikes = -1;
-									song.thumbnail = "empty";
-									song.explicit = false;
-
-									return next(null, song, station);
-								})
-								.catch(err => {
-									next(err);
-								});
+					SongsModule.runJob("ENSURE_SONG_EXISTS_BY_SONG_ID", { songId }, this)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, thumbnail, duration, status } = song;
+							next(
+								null,
+								{
+									_id,
+									songId,
+									title,
+									thumbnail,
+									duration,
+									status
+								},
+								station
+							);
 						})
-						.catch(err => {
-							next(err);
-						});
+						.catch(next);
 				},
 
 				(song, station, next) => {
 					song.requestedBy = session.userId;
 					song.requestedAt = Date.now();
-					song._id = null;
-
 					let totalDuration = 0;
 					station.queue.forEach(song => {
 						totalDuration += song.duration;
@@ -3336,5 +3329,32 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Clears every station queue
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearEveryStationQueue: isAdminRequired(async function clearEveryStationQueue(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("CLEAR_EVERY_STATION_QUEUE", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
+					return cb({ status: "failure", message: err });
+				}
+				this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successfull.");
+				return cb({ status: "success", message: "Success" });
+			}
+		);
 	})
 };

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

@@ -8,12 +8,12 @@ import CoreClass from "../../core";
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 1,
 	news: 1,
-	playlist: 1,
+	playlist: 2,
 	punishment: 1,
 	queueSong: 1,
 	report: 1,
-	song: 1,
-	station: 3,
+	song: 3,
+	station: 4,
 	user: 1
 };
 
@@ -207,30 +207,22 @@ class _DBModule extends CoreClass {
 					// Song
 					const songTitle = title => isLength(title, 1, 100);
 					this.schemas.song.path("title").validate(songTitle, "Invalid title.");
-					this.schemas.queueSong.path("title").validate(songTitle, "Invalid title.");
 
-					this.schemas.song
-						.path("artists")
-						.validate(artists => !(artists.length < 1 || artists.length > 10), "Invalid artists.");
-					this.schemas.queueSong
-						.path("artists")
-						.validate(artists => !(artists.length < 0 || artists.length > 10), "Invalid artists.");
+					this.schemas.song.path("artists").validate(artists => artists.length <= 10, "Invalid artists.");
 
 					const songArtists = artists =>
 						artists.filter(artist => isLength(artist, 1, 64) && artist !== "NONE").length ===
 						artists.length;
 					this.schemas.song.path("artists").validate(songArtists, "Invalid artists.");
-					this.schemas.queueSong.path("artists").validate(songArtists, "Invalid artists.");
 
 					const songGenres = genres => {
-						if (genres.length < 1 || genres.length > 16) return false;
+						if (genres.length > 16) return false;
 						return (
 							genres.filter(genre => isLength(genre, 1, 32) && regex.ascii.test(genre)).length ===
 							genres.length
 						);
 					};
 					this.schemas.song.path("genres").validate(songGenres, "Invalid genres.");
-					this.schemas.queueSong.path("genres").validate(songGenres, "Invalid genres.");
 
 					const songThumbnail = thumbnail => {
 						if (!isLength(thumbnail, 1, 256)) return false;
@@ -238,7 +230,6 @@ class _DBModule extends CoreClass {
 						return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
 					};
 					this.schemas.song.path("thumbnail").validate(songThumbnail, "Invalid thumbnail.");
-					this.schemas.queueSong.path("thumbnail").validate(songThumbnail, "Invalid thumbnail.");
 
 					// Playlist
 					this.schemas.playlist

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

@@ -11,6 +11,7 @@ export default {
 			duration: { type: Number },
 			thumbnail: { type: String, required: false },
 			artists: { type: Array, required: false },
+			status: { type: String },
 			position: { type: Number }
 		}
 	],
@@ -19,5 +20,5 @@ export default {
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
 	type: { type: String, enum: ["user", "genre", "station"], required: true },
-	documentVersion: { type: Number, default: 1, required: true }
+	documentVersion: { type: Number, default: 2, required: true }
 };

+ 13 - 12
backend/logic/db/schemas/song.js

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

+ 8 - 5
backend/logic/db/schemas/station.js

@@ -17,7 +17,9 @@ export default {
 		likes: { type: Number, default: -1 },
 		dislikes: { type: Number, default: -1 },
 		skipVotes: [{ type: String }],
-		requestedAt: { type: Date }
+		requestedBy: { type: String },
+		requestedAt: { type: Date },
+		status: { type: String }
 	},
 	currentSongIndex: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
@@ -34,10 +36,11 @@ export default {
 			duration: { type: Number },
 			skipDuration: { type: Number },
 			thumbnail: { type: String },
-			likes: { type: Number, default: -1 },
-			dislikes: { type: Number, default: -1 },
+			likes: { type: Number },
+			dislikes: { type: Number },
 			requestedBy: { type: String },
-			requestedAt: { type: Date }
+			requestedAt: { type: Date },
+			status: { type: String }
 		}
 	],
 	owner: { type: String },
@@ -46,5 +49,5 @@ export default {
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange"], default: "blue" },
 	includedPlaylists: [{ type: String }],
 	excludedPlaylists: [{ type: String }],
-	documentVersion: { type: Number, default: 3, required: true }
+	documentVersion: { type: Number, default: 4, required: true }
 };

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

@@ -92,6 +92,7 @@ class _MigrationModule extends CoreClass {
 						},
 						err => {
 							if (err) console.log("Migration error", err);
+							else console.log("Migration completed");
 						}
 					);
 

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

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

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

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

+ 21 - 18
backend/logic/notifications.js

@@ -169,24 +169,27 @@ class _NotificationsModule extends CoreClass {
 	SCHEDULE(payload) {
 		return new Promise((resolve, reject) => {
 			const time = Math.round(payload.time);
-			NotificationsModule.log(
-				"STATION_ISSUE",
-				`SCHEDULE - Time: ${time}; Name: ${payload.name}; Key: ${crypto
-					.createHash("md5")
-					.update(`_notification:${payload.name}_`)
-					.digest("hex")}; StationId: ${payload.station._id}; StationName: ${payload.station.name}`
-			);
-			NotificationsModule.pub.set(
-				crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
-				"",
-				"PX",
-				time,
-				"NX",
-				err => {
-					if (err) reject(err);
-					else resolve();
-				}
-			);
+			if (time <= 0) reject(new Error("Time has to be higher than 0"));
+			else {
+				NotificationsModule.log(
+					"STATION_ISSUE",
+					`SCHEDULE - Time: ${time}; Name: ${payload.name}; Key: ${crypto
+						.createHash("md5")
+						.update(`_notification:${payload.name}_`)
+						.digest("hex")}; StationId: ${payload.station._id}; StationName: ${payload.station.name}`
+				);
+				NotificationsModule.pub.set(
+					crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+					"",
+					"PX",
+					time,
+					"NX",
+					err => {
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
 		});
 	}
 

+ 76 - 0
backend/logic/playlists.js

@@ -502,6 +502,82 @@ class _PlaylistsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets orphaned genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "genre" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: playlist.createdFor }, this)
+								.then(response => {
+									if (response.songs.length === 0) {
+										StationsModule.runJob(
+											"GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST",
+											{ playlistId: playlist._id },
+											this
+										)
+											.then(response => {
+												if (response.stationIds.length === 0) orphanedPlaylists.push(playlist);
+												next();
+											})
+											.catch(next);
+									} else next();
+								})
+								.catch(next);
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_GENRE_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned genre playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
 	/**
 	 * Gets a orphaned station playlists
 	 *

+ 499 - 13
backend/logic/songs.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 import mongoose from "mongoose";
 import CoreClass from "../core";
 
@@ -6,6 +7,9 @@ let SongsModule;
 let CacheModule;
 let DBModule;
 let UtilsModule;
+let YouTubeModule;
+let StationsModule;
+let PlaylistsModule;
 
 class _SongsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -26,9 +30,12 @@ class _SongsModule extends CoreClass {
 		CacheModule = this.moduleManager.modules.cache;
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
+		YouTubeModule = this.moduleManager.modules.youtube;
+		StationsModule = this.moduleManager.modules.stations;
+		PlaylistsModule = this.moduleManager.modules.playlists;
 
-		this.songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
-		this.songSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" });
+		this.SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
+		this.SongSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" });
 
 		this.setStage(2);
 
@@ -54,7 +61,7 @@ class _SongsModule extends CoreClass {
 						return async.each(
 							songIds,
 							(songId, next) => {
-								SongsModule.songModel.findOne({ songId }, (err, song) => {
+								SongsModule.SongModel.findOne({ songId }, (err, song) => {
 									if (err) next(err);
 									else if (!song)
 										CacheModule.runJob("HDEL", {
@@ -72,7 +79,7 @@ class _SongsModule extends CoreClass {
 
 					next => {
 						this.setStage(4);
-						SongsModule.songModel.find({}, next);
+						SongsModule.SongModel.find({}, next);
 					},
 
 					(songs, next) => {
@@ -83,7 +90,7 @@ class _SongsModule extends CoreClass {
 								CacheModule.runJob("HSET", {
 									table: "songs",
 									key: song.songId,
-									value: SongsModule.songSchemaCache(song)
+									value: SongsModule.SongSchemaCache(song)
 								})
 									.then(() => next())
 									.catch(next);
@@ -122,7 +129,7 @@ class _SongsModule extends CoreClass {
 
 					(song, next) => {
 						if (song) return next(true, song);
-						return SongsModule.songModel.findOne({ _id: payload.id }, next);
+						return SongsModule.SongModel.findOne({ _id: payload.id }, next);
 					},
 
 					(song, next) => {
@@ -147,6 +154,66 @@ class _SongsModule extends CoreClass {
 		);
 	}
 
+	/**
+	 * Makes sure that if a song is not currently in the songs db, to add it
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.songId - the youtube song id of the song we are trying to ensure is in the songs db
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	ENSURE_SONG_EXISTS_BY_SONG_ID(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ songId: payload.songId }, next);
+					},
+
+					(song, next) => {
+						if (song && song.duration > 0) next(true, song);
+						else {
+							YouTubeModule.runJob("GET_SONG", { songId: payload.songId }, this)
+								.then(response => {
+									next(null, song, response.song);
+								})
+								.catch(next);
+						}
+
+						// else if (song && song.duration <= 0) {
+						// 	YouTubeModule.runJob("GET_SONG", { songId: payload.songId }, this)
+						// 		.then(response => next(null, { ...response.song }, false))
+						// 		.catch(next);
+						// } else {
+						// 	YouTubeModule.runJob("GET_SONG", { songId: payload.songId }, this)
+						// 		.then(response => next(null, { ...response.song }, false))
+						// 		.catch(next);
+						// }
+					},
+
+					(song, youtubeSong, next) => {
+						if (song && song.duration <= 0) {
+							song.duration = youtubeSong.duration;
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) return next(err, song);
+								return next(null, song);
+							});
+						} else {
+							const song = new SongsModule.SongModel({ ...youtubeSong });
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) return next(err, song);
+								return next(null, song);
+							});
+						}
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
+
 	/**
 	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
 	 *
@@ -159,7 +226,7 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.songModel.findOne({ songId: payload.songId }, next);
+						SongsModule.SongModel.findOne({ songId: payload.songId }, next);
 					}
 				],
 				(err, song) => {
@@ -182,7 +249,7 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.songModel.findOne({ _id: payload.songId }, next);
+						SongsModule.SongModel.findOne({ _id: payload.songId }, next);
 					},
 
 					(song, next) => {
@@ -207,6 +274,79 @@ class _SongsModule extends CoreClass {
 								next(null, song);
 							})
 							.catch(next);
+					},
+
+					(song, next) => {
+						next(null, song);
+						const { _id, songId, title, artists, thumbnail, duration, status } = song;
+						const trimmedSong = {
+							_id,
+							songId,
+							title,
+							artists,
+							thumbnail,
+							duration,
+							status
+						};
+						this.log("INFO", `Going to update playlists and stations now for song ${_id}`);
+						DBModule.runJob("GET_MODEL", { modelName: "playlist" }).then(playlistModel => {
+							playlistModel.updateMany(
+								{ "songs._id": song._id },
+								{ $set: { "songs.$": trimmedSong } },
+								err => {
+									if (err) this.log("ERROR", err);
+									else
+										playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
+											playlists.forEach(playlist => {
+												PlaylistsModule.runJob("UPDATE_PLAYLIST", {
+													playlistId: playlist._id
+												});
+											});
+										});
+								}
+							);
+						});
+						DBModule.runJob("GET_MODEL", { modelName: "station" }).then(stationModel => {
+							stationModel.updateMany(
+								{ "queue._id": song._id },
+								{
+									$set: {
+										"queue.$.songId": songId,
+										"queue.$.title": title,
+										"queue.$.artists": artists,
+										"queue.$.thumbnail": thumbnail,
+										"queue.$.duration": duration,
+										"queue.$.status": status
+									}
+								},
+								err => {
+									if (err) this.log("ERROR", err);
+									else
+										stationModel.find({ "queue._id": song._id }, (err, stations) => {
+											stations.forEach(station => {
+												StationsModule.runJob("UPDATE_STATION", { stationId: station._id });
+											});
+										});
+								}
+							);
+						});
+					},
+
+					(song, next) => {
+						async.eachLimit(
+							song.genres,
+							1,
+							(genre, next) => {
+								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => next(err));
+							},
+							err => {
+								next(err, song);
+							}
+						);
 					}
 				],
 				(err, song) => {
@@ -229,7 +369,7 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.songModel.deleteOne({ songId: payload.songId }, next);
+						SongsModule.SongModel.deleteOne({ songId: payload.songId }, next);
 					},
 
 					next => {
@@ -288,7 +428,7 @@ class _SongsModule extends CoreClass {
 					},
 
 					({ likes, dislikes }, next) => {
-						SongsModule.songModel.updateOne(
+						SongsModule.SongModel.updateOne(
 							{ _id: payload.songId },
 							{
 								$set: {
@@ -318,7 +458,7 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.songModel.find({}, { genres: 1, _id: false }, next);
+						SongsModule.SongModel.find({ status: "verified" }, { genres: 1, _id: false }, next);
 					},
 
 					(songs, next) => {
@@ -355,8 +495,11 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.songModel.find(
-							{ genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") } },
+						SongsModule.SongModel.find(
+							{
+								status: "verified",
+								genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") }
+							},
 							next
 						);
 					}
@@ -368,6 +511,349 @@ class _SongsModule extends CoreClass {
 			)
 		);
 	}
+
+	// runjob songs GET_ORPHANED_PLAYLIST_SONGS {}
+
+	/**
+	 * Gets a orphaned playlist songs
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_PLAYLIST_SONGS() {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this).then(playlistModel => {
+				playlistModel.find({}, (err, playlists) => {
+					if (err) reject(new Error(err));
+					else {
+						SongsModule.SongModel.find({}, { _id: true, songId: true }, (err, songs) => {
+							if (err) reject(new Error(err));
+							else {
+								const musareSongIds = songs.map(song => song._id.toString());
+								const orphanedSongIds = new Set();
+								async.eachLimit(
+									playlists,
+									1,
+									(playlist, next) => {
+										playlist.songs.forEach(song => {
+											if (
+												(!song._id || musareSongIds.indexOf(song._id.toString() === -1)) &&
+												!orphanedSongIds.has(song.songId)
+											) {
+												orphanedSongIds.add(song.songId);
+											}
+										});
+										next();
+									},
+									() => {
+										resolve({ songIds: Array.from(orphanedSongIds) });
+									}
+								);
+							}
+						});
+					}
+				});
+			});
+		});
+	}
+
+	/**
+	 * Requests a song, adding it to the DB
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The YouTube song id of the song
+	 * @param {string} payload.userId - The user id of the person requesting the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REQUEST_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId, userId } = payload;
+			const requestedAt = Date.now();
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ songId }, next);
+					},
+
+					// Get YouTube data from id
+					(song, next) => {
+						if (song) return next("This song is already in the database.");
+						// TODO Add err object as first param of callback
+						return YouTubeModule.runJob("GET_SONG", { songId }, this)
+							.then(response => {
+								const { song } = response;
+								song.artists = [];
+								song.genres = [];
+								song.skipDuration = 0;
+								song.explicit = false;
+								song.requestedBy = userId;
+								song.requestedAt = requestedAt;
+								song.status = "unverified";
+								next(null, song);
+							})
+							.catch(next);
+					},
+					(newSong, next) => {
+						const song = new SongsModule.SongModel(newSong);
+						song.save({ validateBeforeSave: false }, err => {
+							if (err) return next(err, song);
+							return next(null, song);
+						});
+					},
+					(song, next) => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+							.then(UserModel => {
+								UserModel.findOne({ _id: userId }, (err, user) => {
+									if (err) return next(err);
+									if (!user) return next(null, song);
+
+									user.statistics.songsRequested += 1;
+
+									return user.save(err => {
+										if (err) return next(err);
+										return next(null, song);
+									});
+								});
+							})
+							.catch(next);
+					}
+				],
+				async (err, song) => {
+					if (err) reject(err);
+
+					SongsModule.runJob("UPDATE_SONG", { songId });
+
+					CacheModule.runJob("PUB", {
+						channel: "song.newUnverifiedSong",
+						value: song._id
+					});
+
+					resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Hides a song
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The Musare song id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	HIDE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: songId }, next);
+					},
+
+					// Get YouTube data from id
+					(song, next) => {
+						if (!song) return next("This song does not exist.");
+						if (song.status === "hidden") return next("This song is already hidden.");
+						if (song.status === "verified") return next("Verified songs cannot be hidden.");
+						// TODO Add err object as first param of callback
+						return next();
+					},
+
+					next => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, next);
+					},
+
+					(res, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId });
+						next();
+					}
+				],
+				async err => {
+					if (err) reject(err);
+
+					CacheModule.runJob("PUB", {
+						channel: "song.newHiddenSong",
+						value: songId
+					});
+
+					CacheModule.runJob("PUB", {
+						channel: "song.removedUnverifiedSong",
+						value: songId
+					});
+
+					resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Unhides a song
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The Musare song id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	UNHIDE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: songId }, next);
+					},
+
+					// Get YouTube data from id
+					(song, next) => {
+						if (!song) return next("This song does not exist.");
+						if (song.status !== "hidden") return next("This song is not hidden.");
+						// TODO Add err object as first param of callback
+						return next();
+					},
+
+					next => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "unverified" }, next);
+					},
+
+					(res, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId });
+						next();
+					}
+				],
+				async err => {
+					if (err) reject(err);
+
+					CacheModule.runJob("PUB", {
+						channel: "song.newUnverifiedSong",
+						value: songId
+					});
+
+					CacheModule.runJob("PUB", {
+						channel: "song.removedHiddenSong",
+						value: songId
+					});
+
+					resolve();
+				}
+			);
+		});
+	}
+
+	// runjob songs REQUEST_ORPHANED_PLAYLIST_SONGS {}
+
+	/**
+	 * Requests all orphaned playlist songs, adding them to the database
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REQUEST_ORPHANED_PLAYLIST_SONGS() {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob("GET_MODEL", { modelName: "playlist" })
+				.then(playlistModel => {
+					SongsModule.runJob("GET_ORPHANED_PLAYLIST_SONGS", {}, this).then(response => {
+						const { songIds } = response;
+						const playlistsToUpdate = new Set();
+
+						async.eachLimit(
+							songIds,
+							1,
+							(songId, next) => {
+								async.waterfall(
+									[
+										next => {
+											console.log(
+												songId,
+												`this is song ${songIds.indexOf(songId) + 1}/${songIds.length}`
+											);
+											setTimeout(next, 150);
+										},
+
+										next => {
+											SongsModule.runJob("ENSURE_SONG_EXISTS_BY_SONG_ID", { songId }, this)
+												.then(() => next())
+												.catch(next);
+											// SongsModule.runJob("REQUEST_SONG", { songId, userId: null }, this)
+											// 	.then(() => {
+											// 		next();
+											// 	})
+											// 	.catch(next);
+										},
+
+										next => {
+											console.log(444, songId);
+
+											SongsModule.SongModel.findOne({ songId }, next);
+										},
+
+										(song, next) => {
+											const { _id, title, artists, thumbnail, duration, status } = song;
+											const trimmedSong = {
+												_id,
+												songId,
+												title,
+												artists,
+												thumbnail,
+												duration,
+												status
+											};
+											playlistModel.updateMany(
+												{ "songs.songId": song.songId },
+												{ $set: { "songs.$": trimmedSong } },
+												(err, res) => {
+													next(err, song);
+												}
+											);
+										},
+
+										(song, next) => {
+											playlistModel.find({ "songs._id": song._id }, next);
+										},
+
+										(playlists, next) => {
+											playlists.forEach(playlist => {
+												playlistsToUpdate.add(playlist._id.toString());
+											});
+
+											next();
+										}
+									],
+									next
+								);
+							},
+							err => {
+								if (err) reject(err);
+								else {
+									async.eachLimit(
+										Array.from(playlistsToUpdate),
+										1,
+										(playlistId, next) => {
+											PlaylistsModule.runJob(
+												"UPDATE_PLAYLIST",
+												{
+													playlistId
+												},
+												this
+											)
+												.then(() => {
+													next();
+												})
+												.catch(next);
+										},
+										err => {
+											if (err) reject(err);
+											else resolve();
+										}
+									);
+								}
+							}
+						);
+					});
+				})
+				.catch(reject);
+		});
+	}
 }
 
 export default new _SongsModule();

+ 65 - 19
backend/logic/stations.js

@@ -41,7 +41,8 @@ class _StationsModule extends CoreClass {
 			skipDuration: 0,
 			likes: -1,
 			dislikes: -1,
-			requestedAt: Date.now()
+			requestedAt: Date.now(),
+			status: "unverified"
 		};
 
 		this.userList = {};
@@ -601,10 +602,10 @@ class _StationsModule extends CoreClass {
 						}
 					},
 
-					(song, next) => {
-						if (!song._id) next(null, song);
+					(queueSong, next) => {
+						if (!queueSong._id) next(null, queueSong);
 						else
-							SongsModule.runJob("GET_SONG", { id: song._id }, this)
+							SongsModule.runJob("GET_SONG", { id: queueSong._id }, this)
 								.then(response => {
 									const { song } = response;
 
@@ -615,10 +616,13 @@ class _StationsModule extends CoreClass {
 											title: song.title,
 											artists: song.artists,
 											duration: song.duration,
+											skipDuration: song.skipDuration,
 											thumbnail: song.thumbnail,
-											requestedAt: song.requestedAt,
+											requestedAt: queueSong.requestedAt,
+											requestedBy: queueSong.requestedBy,
 											likes: song.likes,
-											dislikes: song.dislikes
+											dislikes: song.dislikes,
+											status: song.status
 										};
 
 										return next(null, newSong);
@@ -887,17 +891,7 @@ class _StationsModule extends CoreClass {
 						const $set = {};
 
 						if (song === null) $set.currentSong = null;
-						else if (song.likes === -1 && song.dislikes === -1) {
-							$set.currentSong = {
-								songId: song.songId,
-								title: song.title,
-								duration: song.duration,
-								skipDuration: 0,
-								likes: -1,
-								dislikes: -1,
-								requestedAt: song.requestedAt
-							};
-						} else {
+						else {
 							$set.currentSong = {
 								_id: song._id,
 								songId: song.songId,
@@ -908,7 +902,9 @@ class _StationsModule extends CoreClass {
 								dislikes: song.dislikes,
 								skipDuration: song.skipDuration,
 								thumbnail: song.thumbnail,
-								requestedAt: song.requestedAt
+								requestedAt: song.requestedAt,
+								requestedBy: song.requestedBy,
+								status: song.status
 							};
 						}
 
@@ -1579,7 +1575,9 @@ class _StationsModule extends CoreClass {
 				this
 			).then(stationModel => {
 				stationModel.find(
-					{ $or: [{ includedPlaylists: payload.playlistId }, { excludedPlaylists: payload.playlistId }] },
+					{
+						$or: [{ includedPlaylists: payload.playlistId }, { excludedPlaylists: payload.playlistId }]
+					},
 					(err, stations) => {
 						if (err) reject(err);
 						else resolve({ stationIds: stations.map(station => station._id) });
@@ -1588,6 +1586,54 @@ class _StationsModule extends CoreClass {
 			});
 		});
 	}
+
+	/**
+	 * Clears every queue
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CLEAR_EVERY_STATION_QUEUE() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.updateMany({}, { $set: { queue: [] } }, err => {
+							if (err) next(err);
+							else {
+								StationsModule.stationModel.find({}, (err, stations) => {
+									if (err) next(err);
+									else {
+										async.eachLimit(
+											stations,
+											1,
+											(station, next) => {
+												StationsModule.runJob("UPDATE_STATION", {
+													stationId: station._id
+												})
+													.then(() => next())
+													.catch(next);
+												CacheModule.runJob("PUB", {
+													channel: "station.queueUpdate",
+													value: station._id
+												})
+													.then()
+													.catch();
+											},
+											next
+										);
+									}
+								});
+							}
+						});
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
 }
 
 export default new _StationsModule();

+ 5 - 0
frontend/src/App.vue

@@ -555,6 +555,11 @@ button.delete:focus {
 	font-size: 18px;
 }
 
+.verified-song {
+	font-size: 17px;
+	color: var(--primary-color);
+}
+
 .section-title,
 h4.section-title {
 	font-size: 26px;

+ 2 - 2
frontend/src/components/modals/AddSongToQueue.vue

@@ -280,7 +280,7 @@ export default {
 					}
 				);
 			} else {
-				this.socket.dispatch("queueSongs.add", songId, data => {
+				this.socket.dispatch("songs.request", songId, data => {
 					if (data.status !== "success")
 						new Toast({
 							content: `Error: ${data.message}`,
@@ -319,7 +319,7 @@ export default {
 			}, 750);
 
 			return this.socket.dispatch(
-				"queueSongs.addSetToQueue",
+				"songs.requestSet",
 				this.search.playlist.query,
 				this.search.playlist.isImportingOnlyMusic,
 				res => {

+ 21 - 7
frontend/src/components/modals/EditPlaylist/components/PlaylistSongItem.vue

@@ -2,19 +2,33 @@
 	<div class="universal-item playlist-song-item">
 		<div id="thumbnail-and-info">
 			<img
-				v-if="song.thumbnail"
 				class="item-thumbnail"
-				:src="song.thumbnail"
+				:src="
+					song.songId &&
+					(!song.thumbnail ||
+						(song.thumbnail &&
+							(song.thumbnail.lastIndexOf('notes-transparent') !==
+								-1 ||
+								song.thumbnail.lastIndexOf(
+									'/assets/notes.png'
+								) !== -1)) ||
+						song.thumbnail === 'empty' ||
+						song.thumbnail == null)
+						? `https://img.youtube.com/vi/${song.songId}/mqdefault.jpg`
+						: song.thumbnail
+				"
 				onerror="this.src='/assets/notes-transparent.png'"
 			/>
-			<img
-				v-else
-				class="item-thumbnail"
-				src="/assets/notes-transparent.png"
-			/>
 			<div id="song-info">
 				<h4 class="item-title" :title="song.title">
 					{{ song.title }}
+					<i
+						v-if="song.status === 'verified'"
+						class="material-icons verified-song"
+						title="Verified Song"
+					>
+						check_circle
+					</i>
 				</h4>
 				<h5
 					class="item-description"

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

@@ -282,10 +282,7 @@
 											>
 
 											<i
-												v-if="
-													!song.simpleSong &&
-														userRole === 'admin'
-												"
+												v-if="userRole === 'admin'"
 												class="material-icons report-icon"
 												@click="
 													reportSongInPlaylist(song)
@@ -295,10 +292,7 @@
 											</i>
 
 											<i
-												v-if="
-													!song.simpleSong &&
-														userRole === 'admin'
-												"
+												v-if="userRole === 'admin'"
 												class="material-icons edit-icon"
 												@click="
 													editSongInPlaylist(song)

+ 137 - 154
frontend/src/components/modals/EditSong.vue

@@ -519,7 +519,7 @@ export default {
 	components: { Modal, FloatingBox, SaveButton },
 	props: {
 		songId: { type: String, default: null },
-		songType: { type: String, default: null },
+		// songType: { type: String, default: null },
 		sector: { type: String, default: "admin" }
 	},
 	data() {
@@ -614,151 +614,139 @@ export default {
 
 		this.useHTTPS = await lofig.get("cookie.secure");
 
-		this.socket.dispatch(
-			`${this.songType}.getSongFromMusareId`,
-			this.songId,
-			res => {
-				if (res.status === "success") {
-					const { song } = res.data;
-					// this.song = { ...song };
-					// if (this.song.discogs === undefined)
-					// 	this.song.discogs = null;
-					this.editSong(song);
-
-					this.songDataLoaded = true;
-
-					// this.edit(res.data.song);
-
-					this.discogsQuery = this.song.title;
-
-					this.interval = setInterval(() => {
-						if (
-							this.song.duration !== -1 &&
-							this.video.paused === false &&
-							this.playerReady &&
-							this.video.player.getCurrentTime() -
-								this.song.skipDuration >
-								this.song.duration
-						) {
-							this.video.paused = false;
-							this.video.player.stopVideo();
-							this.drawCanvas();
-						}
-						if (this.playerReady) {
-							this.youtubeVideoCurrentTime = this.video.player
-								.getCurrentTime()
+		this.socket.dispatch(`songs.getSongFromMusareId`, this.songId, res => {
+			if (res.status === "success") {
+				const { song } = res.data;
+				// this.song = { ...song };
+				// if (this.song.discogs === undefined)
+				// 	this.song.discogs = null;
+				this.editSong(song);
+
+				this.songDataLoaded = true;
+
+				// this.edit(res.data.song);
+
+				this.discogsQuery = this.song.title;
+
+				this.interval = setInterval(() => {
+					if (
+						this.song.duration !== -1 &&
+						this.video.paused === false &&
+						this.playerReady &&
+						this.video.player.getCurrentTime() -
+							this.song.skipDuration >
+							this.song.duration
+					) {
+						this.video.paused = false;
+						this.video.player.stopVideo();
+						this.drawCanvas();
+					}
+					if (this.playerReady) {
+						this.youtubeVideoCurrentTime = this.video.player
+							.getCurrentTime()
+							.toFixed(3);
+					}
+
+					if (this.video.paused === false) this.drawCanvas();
+				}, 200);
+
+				this.video.player = new window.YT.Player("editSongPlayer", {
+					height: 298,
+					width: 530,
+					videoId: this.song.songId,
+					host: "https://www.youtube-nocookie.com",
+					playerVars: {
+						controls: 0,
+						iv_load_policy: 3,
+						rel: 0,
+						showinfo: 0,
+						autoplay: 1
+					},
+					startSeconds: this.song.skipDuration,
+					events: {
+						onReady: () => {
+							let volume = parseInt(
+								localStorage.getItem("volume")
+							);
+							volume = typeof volume === "number" ? volume : 20;
+							console.log(`Seekto: ${this.song.skipDuration}`);
+							this.video.player.seekTo(this.song.skipDuration);
+							this.video.player.setVolume(volume);
+							if (volume > 0) this.video.player.unMute();
+							this.youtubeVideoDuration = this.video.player
+								.getDuration()
 								.toFixed(3);
-						}
+							this.youtubeVideoNote = "(~)";
+							this.playerReady = true;
 
-						if (this.video.paused === false) this.drawCanvas();
-					}, 200);
-
-					this.video.player = new window.YT.Player("editSongPlayer", {
-						height: 298,
-						width: 530,
-						videoId: this.song.songId,
-						host: "https://www.youtube-nocookie.com",
-						playerVars: {
-							controls: 0,
-							iv_load_policy: 3,
-							rel: 0,
-							showinfo: 0,
-							autoplay: 1
+							this.drawCanvas();
 						},
-						startSeconds: this.song.skipDuration,
-						events: {
-							onReady: () => {
-								let volume = parseInt(
-									localStorage.getItem("volume")
-								);
-								volume =
-									typeof volume === "number" ? volume : 20;
-								console.log(
-									`Seekto: ${this.song.skipDuration}`
-								);
-								this.video.player.seekTo(
-									this.song.skipDuration
+						onStateChange: event => {
+							this.drawCanvas();
+
+							if (event.data === 1) {
+								if (!this.video.autoPlayed) {
+									this.video.autoPlayed = true;
+									return this.video.player.stopVideo();
+								}
+
+								this.video.paused = false;
+								let youtubeDuration = this.video.player.getDuration();
+								this.youtubeVideoDuration = youtubeDuration.toFixed(
+									3
 								);
-								this.video.player.setVolume(volume);
-								if (volume > 0) this.video.player.unMute();
-								this.youtubeVideoDuration = this.video.player
-									.getDuration()
-									.toFixed(3);
-								this.youtubeVideoNote = "(~)";
-								this.playerReady = true;
-
-								this.drawCanvas();
-							},
-							onStateChange: event => {
-								this.drawCanvas();
-
-								if (event.data === 1) {
-									if (!this.video.autoPlayed) {
-										this.video.autoPlayed = true;
-										return this.video.player.stopVideo();
-									}
-
-									this.video.paused = false;
-									let youtubeDuration = this.video.player.getDuration();
-									this.youtubeVideoDuration = youtubeDuration.toFixed(
-										3
-									);
-									this.youtubeVideoNote = "";
-
-									if (this.song.duration === -1)
-										this.song.duration = youtubeDuration;
-
-									youtubeDuration -= this.song.skipDuration;
-									if (
-										this.song.duration >
-										youtubeDuration + 1
-									) {
-										this.video.player.stopVideo();
-										this.video.paused = true;
-										return new Toast({
-											content:
-												"Video can't play. Specified duration is bigger than the YouTube song duration.",
-											timeout: 4000
-										});
-									}
-									if (this.song.duration <= 0) {
-										this.video.player.stopVideo();
-										this.video.paused = true;
-										return new Toast({
-											content:
-												"Video can't play. Specified duration has to be more than 0 seconds.",
-											timeout: 4000
-										});
-									}
-
-									if (
-										this.video.player.getCurrentTime() <
-										this.song.skipDuration
-									) {
-										return this.video.player.seekTo(
-											this.song.skipDuration
-										);
-									}
-								} else if (event.data === 2) {
+								this.youtubeVideoNote = "";
+
+								if (this.song.duration === -1)
+									this.song.duration = youtubeDuration;
+
+								youtubeDuration -= this.song.skipDuration;
+								if (this.song.duration > youtubeDuration + 1) {
+									this.video.player.stopVideo();
 									this.video.paused = true;
+									return new Toast({
+										content:
+											"Video can't play. Specified duration is bigger than the YouTube song duration.",
+										timeout: 4000
+									});
+								}
+								if (this.song.duration <= 0) {
+									this.video.player.stopVideo();
+									this.video.paused = true;
+									return new Toast({
+										content:
+											"Video can't play. Specified duration has to be more than 0 seconds.",
+										timeout: 4000
+									});
 								}
 
-								return false;
+								if (
+									this.video.player.getCurrentTime() <
+									this.song.skipDuration
+								) {
+									return this.video.player.seekTo(
+										this.song.skipDuration
+									);
+								}
+							} else if (event.data === 2) {
+								this.video.paused = true;
 							}
+
+							return false;
 						}
-					});
-				} else {
-					new Toast({
-						content: "Song with that ID not found",
-						timeout: 3000
-					});
-					this.closeModal({
-						sector: this.sector,
-						modal: "editSong"
-					});
-				}
+					}
+				});
+			} else {
+				new Toast({
+					content: "Song with that ID not found",
+					timeout: 3000
+				});
+				this.closeModal({
+					sector: this.sector,
+					modal: "editSong"
+				});
 			}
-		);
+		});
 
 		let volume = parseFloat(localStorage.getItem("volume"));
 		volume =
@@ -1083,24 +1071,19 @@ export default {
 
 			saveButtonRef.status = "disabled";
 
-			return this.socket.dispatch(
-				`${this.songType}.update`,
-				song._id,
-				song,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-
-					if (res.status === "success")
-						saveButtonRef.handleSuccessfulSave();
-					else saveButtonRef.handleFailedSave();
-
-					if (close)
-						this.closeModal({
-							sector: this.sector,
-							modal: "editSong"
-						});
-				}
-			);
+			return this.socket.dispatch(`songs.update`, song._id, song, res => {
+				new Toast({ content: res.message, timeout: 4000 });
+
+				if (res.status === "success")
+					saveButtonRef.handleSuccessfulSave();
+				else saveButtonRef.handleFailedSave();
+
+				if (close)
+					this.closeModal({
+						sector: this.sector,
+						modal: "editSong"
+					});
+			});
 		},
 		toggleAPIResult(index) {
 			const apiResult = this.discogs.apiResults[index];

+ 5 - 3
frontend/src/components/modals/EditStation.vue

@@ -292,9 +292,11 @@
 							<button
 								class="blue"
 								@click="
-									station.playMode === 'random'
-										? updatePlayModeLocal('sequential')
-										: updatePlayModeLocal('random')
+									(station.type === 'official' &&
+										station.playMode === 'random') ||
+									station.playMode === 'sequential'
+										? updatePlayModeLocal('random')
+										: updatePlayModeLocal('sequential')
 								"
 							>
 								<i class="material-icons">{{

+ 47 - 20
frontend/src/pages/Admin/index.vue

@@ -4,21 +4,39 @@
 		<div class="tabs is-centered">
 			<ul>
 				<li
-					:class="{ 'is-active': currentTab == 'queueSongs' }"
-					@click="showTab('queueSongs')"
+					:class="{ 'is-active': currentTab == 'unverifiedsongs' }"
+					@click="showTab('unverifiedsongs')"
 				>
-					<router-link class="tab queueSongs" to="/admin/queuesongs">
-						<i class="material-icons">queue_music</i>
-						<span>&nbsp;Queue Songs</span>
+					<router-link
+						class="tab unverifiedsongs"
+						to="/admin/unverifiedsongs"
+					>
+						<i class="material-icons">music_note</i>
+						<span>&nbsp;Unverified Songs</span>
+					</router-link>
+				</li>
+				<li
+					:class="{ 'is-active': currentTab == 'verifiedsongs' }"
+					@click="showTab('verifiedsongs')"
+				>
+					<router-link
+						class="tab verifiedsongs"
+						to="/admin/verifiedsongs"
+					>
+						<i class="material-icons">music_note</i>
+						<span>&nbsp;Verified Songs</span>
 					</router-link>
 				</li>
 				<li
-					:class="{ 'is-active': currentTab == 'songs' }"
-					@click="showTab('songs')"
+					:class="{ 'is-active': currentTab == 'hiddensongs' }"
+					@click="showTab('hiddensongs')"
 				>
-					<router-link class="tab songs" to="/admin/songs">
+					<router-link
+						class="tab hiddensongs"
+						to="/admin/hiddensongs"
+					>
 						<i class="material-icons">music_note</i>
-						<span>&nbsp;Songs</span>
+						<span>&nbsp;Hidden Songs</span>
 					</router-link>
 				</li>
 				<li
@@ -102,8 +120,9 @@
 			</ul>
 		</div>
 
-		<queue-songs v-if="currentTab == 'queueSongs'" />
-		<songs v-if="currentTab == 'songs'" />
+		<unverified-songs v-if="currentTab == 'unverifiedsongs'" />
+		<verified-songs v-if="currentTab == 'verifiedsongs'" />
+		<hidden-songs v-if="currentTab == 'hiddensongs'" />
 		<stations v-if="currentTab == 'stations'" />
 		<playlists v-if="currentTab == 'playlists'" />
 		<reports v-if="currentTab == 'reports'" />
@@ -121,8 +140,9 @@ import MainHeader from "../../components/layout/MainHeader.vue";
 export default {
 	components: {
 		MainHeader,
-		QueueSongs: () => import("./tabs/QueueSongs.vue"),
-		Songs: () => import("./tabs/Songs.vue"),
+		UnverifiedSongs: () => import("./tabs/UnverifiedSongs.vue"),
+		VerifiedSongs: () => import("./tabs/VerifiedSongs.vue"),
+		HiddenSongs: () => import("./tabs/HiddenSongs.vue"),
 		Stations: () => import("./tabs/Stations.vue"),
 		Playlists: () => import("./tabs/Playlists.vue"),
 		Reports: () => import("./tabs/Reports.vue"),
@@ -148,11 +168,14 @@ export default {
 	methods: {
 		changeTab(path) {
 			switch (path) {
-				case "/admin/queuesongs":
-					this.currentTab = "queueSongs";
+				case "/admin/unverifiedsongs":
+					this.currentTab = "unverifiedsongs";
 					break;
-				case "/admin/songs":
-					this.currentTab = "songs";
+				case "/admin/verifiedsongs":
+					this.currentTab = "verifiedsongs";
+					break;
+				case "/admin/hiddensongs":
+					this.currentTab = "hiddensongs";
 					break;
 				case "/admin/stations":
 					this.currentTab = "stations";
@@ -179,7 +202,7 @@ export default {
 					this.currentTab = "punishments";
 					break;
 				default:
-					this.currentTab = "queueSongs";
+					this.currentTab = "verifiedsongs";
 			}
 		},
 		showTab(tab) {
@@ -209,11 +232,15 @@ export default {
 	padding-top: 10px;
 	margin-top: -10px;
 	background-color: var(--white);
-	.queueSongs {
+	.unverifiedsongs {
+		color: var(--teal);
+		border-color: var(--teal);
+	}
+	.verifiedsongs {
 		color: var(--teal);
 		border-color: var(--teal);
 	}
-	.songs {
+	.hiddensongs {
 		color: var(--primary-color);
 		border-color: var(--primary-color);
 	}

+ 376 - 0
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -0,0 +1,376 @@
+<template>
+	<div @scroll="handleScroll">
+		<metadata title="Admin | Hidden songs" />
+		<div class="container">
+			<p>
+				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
+				<br />
+				<span>Loaded songs: {{ this.songs.length }}</span>
+			</p>
+			<input
+				v-model="searchQuery"
+				type="text"
+				class="input"
+				placeholder="Search for Songs"
+			/>
+			<button
+				v-if="!loadAllSongs"
+				class="button is-primary"
+				@click="loadAll()"
+			>
+				Load all
+			</button>
+			<button
+				class="button is-primary"
+				@click="toggleKeyboardShortcutsHelper"
+				@dblclick="resetKeyboardShortcutsHelper"
+			>
+				Keyboard shortcuts helper
+			</button>
+			<br />
+			<br />
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>Artists</td>
+						<td>Genres</td>
+						<td>ID / YouTube ID</td>
+						<td>Requested By</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr
+						v-for="(song, index) in filteredSongs"
+						:key="index"
+						tabindex="0"
+						@keydown.up.prevent
+						@keydown.down.prevent
+						@keyup.up="selectPrevious($event)"
+						@keyup.down="selectNext($event)"
+						@keyup.e="edit(song, index)"
+						@keyup.a="add(song)"
+						@keyup.x="remove(song._id, index)"
+					>
+						<td>
+							<img
+								class="song-thumbnail"
+								:src="song.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</td>
+						<td>
+							<strong>{{ song.title }}</strong>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>
+							{{ song._id }}
+							<br />
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>
+							<user-id-to-username
+								:user-id="song.requestedBy"
+								:link="true"
+							/>
+						</td>
+						<td class="optionsColumn">
+							<button
+								class="button is-primary"
+								@click="edit(song, index)"
+							>
+								<i class="material-icons">edit</i>
+							</button>
+							<button
+								class="button is-success"
+								@click="unhide(song)"
+							>
+								<i class="material-icons">add</i>
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-song v-if="modals.editSong" :song-id="editingSongId" />
+		<floating-box
+			id="keyboardShortcutsHelper"
+			ref="keyboardShortcutsHelper"
+		>
+			<template #body>
+				<div>
+					<div>
+						<span class="biggest"><b>Hidden songs page</b></span>
+						<span
+							><b>Arrow keys up/down</b> - Moves between
+							songs</span
+						>
+						<span><b>E</b> - Edit selected song</span>
+						<span><b>A</b> - Add selected song</span>
+						<span><b>X</b> - Delete selected song</span>
+					</div>
+					<hr />
+					<div>
+						<span class="biggest"><b>Edit song modal</b></span>
+						<span class="bigger"><b>Navigation</b></span>
+						<span><b>Home</b> - Edit</span>
+						<span><b>End</b> - Edit</span>
+						<hr />
+						<span class="bigger"><b>Player controls</b></span>
+						<span><b>Numpad up/down</b> - Volume up/down 10%</span>
+						<span
+							><b>Ctrl + Numpad up/down</b> - Volume up/down
+							1%</span
+						>
+						<span><b>Numpad center</b> - Pause/resume</span>
+						<span><b>Ctrl + Numpad center</b> - Stop</span>
+						<span
+							><b>Numpad Right</b> - Skip to last 10 seconds</span
+						>
+						<hr />
+						<span class="bigger"><b>Form control</b></span>
+						<span
+							><b>Ctrl + D</b> - Executes purple button in that
+							input</span
+						>
+						<span
+							><b>Ctrl + Alt + D</b> - Fill in all Discogs
+							fields</span
+						>
+						<span
+							><b>Ctrl + R</b> - Executes red button in that
+							input</span
+						>
+						<span
+							><b>Ctrl + Alt + R</b> - Reset duration field</span
+						>
+						<hr />
+						<span class="bigger"><b>Modal control</b></span>
+						<span><b>Ctrl + S</b> - Save</span>
+						<span><b>Ctrl + X</b> - Exit</span>
+					</div>
+				</div>
+			</template>
+		</floating-box>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import UserIdToUsername from "../../../components/common/UserIdToUsername.vue";
+
+import FloatingBox from "../../../components/ui/FloatingBox.vue";
+
+import ScrollAndFetchHandler from "../../../mixins/ScrollAndFetchHandler.vue";
+
+import ws from "../../../ws";
+
+export default {
+	components: {
+		EditSong: () => import("../../../components/modals/EditSong.vue"),
+		UserIdToUsername,
+		FloatingBox
+	},
+	mixins: [ScrollAndFetchHandler],
+	data() {
+		return {
+			editingSongId: "",
+			searchQuery: "",
+			songs: []
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
+		},
+		...mapState("modalVisibility", {
+			modals: state => state.modals.admin
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	watch: {
+		// eslint-disable-next-line func-names
+		"modals.editSong": function(value) {
+			if (value === false) this.stopVideo();
+		}
+	},
+	mounted() {
+		this.socket.on("event:admin.hiddenSong.added", song => {
+			this.songs.push(song);
+		});
+
+		this.socket.on("event:admin.hiddenSong.removed", songId => {
+			this.songs = this.songs.filter(song => {
+				return song._id !== songId;
+			});
+		});
+
+		this.socket.on("event:admin.hiddenSong.updated", updatedSong => {
+			for (let i = 0; i < this.songs.length; i += 1) {
+				const song = this.songs[i];
+				if (song._id === updatedSong._id) {
+					this.$set(this.songs, i, updatedSong);
+				}
+			}
+		});
+
+		if (this.socket.readyState === 1) this.init();
+		ws.onConnect(() => this.init());
+	},
+	methods: {
+		edit(song) {
+			// const newSong = {};
+			// Object.keys(song).forEach(n => {
+			// 	newSong[n] = song[n];
+			// });
+
+			// this.editSong({ index, song: newSong, type: "queueSongs" });
+			this.editingSongId = song._id;
+			this.openModal({ sector: "admin", modal: "editSong" });
+		},
+		unhide(song) {
+			this.socket.dispatch("songs.unhide", song._id, res => {
+				if (res.status === "success")
+					new Toast({ content: res.message, timeout: 2000 });
+				else new Toast({ content: res.message, timeout: 4000 });
+			});
+		},
+		getSet() {
+			if (this.isGettingSet) return;
+			if (this.position >= this.maxPosition) return;
+			this.isGettingSet = true;
+
+			this.socket.dispatch(
+				"songs.getSet",
+				this.position,
+				"hidden",
+				data => {
+					data.forEach(song => this.songs.push(song));
+
+					this.position += 1;
+					this.isGettingSet = false;
+				}
+			);
+		},
+		selectPrevious(event) {
+			if (event.srcElement.previousElementSibling)
+				event.srcElement.previousElementSibling.focus();
+		},
+		selectNext(event) {
+			if (event.srcElement.nextElementSibling)
+				event.srcElement.nextElementSibling.focus();
+		},
+		toggleKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.toggleBox();
+		},
+		resetKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.resetBox();
+		},
+		init() {
+			if (this.songs.length > 0)
+				this.position = Math.ceil(this.songs.length / 15) + 1;
+
+			this.socket.dispatch("songs.length", "hidden", length => {
+				this.maxPosition = Math.ceil(length / 15) + 1;
+
+				this.getSet();
+			});
+
+			this.socket.dispatch("apis.joinAdminRoom", "hiddenSongs", () => {});
+		},
+		// ...mapActions("admin/songs", ["editSong"]),
+		...mapActions("modals/editSong", ["stopVideo"]),
+		...mapActions("modalVisibility", ["openModal"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode {
+	.table {
+		color: var(--light-grey-2);
+		background-color: var(--dark-grey-3);
+
+		thead tr {
+			background: var(--dark-grey-3);
+			td {
+				color: var(--white);
+			}
+		}
+
+		tbody tr:hover {
+			background-color: var(--dark-grey-4) !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: var(--dark-grey-2);
+		}
+
+		strong {
+			color: var(--light-grey-2);
+		}
+	}
+}
+
+.optionsColumn {
+	width: 140px;
+	button {
+		width: 35px;
+	}
+}
+
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
+
+td {
+	vertical-align: middle;
+}
+
+#keyboardShortcutsHelper {
+	.box-body {
+		b {
+			color: var(--black);
+		}
+
+		.biggest {
+			font-size: 18px;
+		}
+
+		.bigger {
+			font-size: 16px;
+		}
+
+		span {
+			display: block;
+		}
+	}
+}
+
+.is-primary:focus {
+	background-color: var(--primary-color) !important;
+}
+</style>

+ 48 - 0
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -8,6 +8,18 @@
 			>
 				Delete orphaned station playlists
 			</button>
+			<button
+				class="button is-primary"
+				@click="deleteOrphanedGenrePlaylists()"
+			>
+				Delete orphaned genre playlists
+			</button>
+			<button
+				class="button is-primary"
+				@click="requestOrphanedPlaylistSongs()"
+			>
+				Request orphaned playlist songs
+			</button>
 			<br />
 			<br />
 			<table class="table is-striped">
@@ -157,6 +169,42 @@ export default {
 				}
 			);
 		},
+		deleteOrphanedGenrePlaylists() {
+			this.socket.dispatch(
+				"playlists.deleteOrphanedGenrePlaylists",
+				res => {
+					if (res.status === "success") {
+						new Toast({
+							content: `${res.message}`,
+							timeout: 4000
+						});
+					} else {
+						new Toast({
+							content: `Error: ${res.message}`,
+							timeout: 8000
+						});
+					}
+				}
+			);
+		},
+		requestOrphanedPlaylistSongs() {
+			this.socket.dispatch(
+				"playlists.requestOrphanedPlaylistSongs",
+				res => {
+					if (res.status === "success") {
+						new Toast({
+							content: `${res.message}`,
+							timeout: 4000
+						});
+					} else {
+						new Toast({
+							content: `Error: ${res.message}`,
+							timeout: 8000
+						});
+					}
+				}
+			);
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("user/playlists", ["editPlaylist"])
 	}

+ 20 - 0
frontend/src/pages/Admin/tabs/Stations.vue

@@ -2,6 +2,11 @@
 	<div>
 		<metadata title="Admin | Stations" />
 		<div class="container">
+			<button class="button is-primary" @click="clearEveryStationQueue()">
+				Clear every station queue
+			</button>
+			<br />
+			<br />
 			<table class="table is-striped">
 				<thead>
 					<tr>
@@ -339,6 +344,21 @@ export default {
 		removeBlacklistedGenre(index) {
 			this.newStation.blacklistedGenres.splice(index, 1);
 		},
+		clearEveryStationQueue() {
+			this.socket.dispatch("stations.clearEveryStationQueue", res => {
+				if (res.status === "success") {
+					new Toast({
+						content: `${res.message}`,
+						timeout: 4000
+					});
+				} else {
+					new Toast({
+						content: `Error: ${res.message}`,
+						timeout: 8000
+					});
+				}
+			});
+		},
 		init() {
 			this.socket.dispatch("stations.index", data => {
 				this.loadStations(data.stations);

+ 30 - 23
frontend/src/pages/Admin/tabs/QueueSongs.vue → frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -1,6 +1,6 @@
 <template>
 	<div @scroll="handleScroll">
-		<metadata title="Admin | Queue songs" />
+		<metadata title="Admin | Unverified songs" />
 		<div class="container">
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
@@ -100,7 +100,7 @@
 							</button>
 							<button
 								class="button is-danger"
-								@click="remove(song._id, index)"
+								@click="hide(song._id, index)"
 							>
 								<i class="material-icons">cancel</i>
 							</button>
@@ -109,11 +109,7 @@
 				</tbody>
 			</table>
 		</div>
-		<edit-song
-			v-if="modals.editSong"
-			:song-id="editingSongId"
-			song-type="queueSongs"
-		/>
+		<edit-song v-if="modals.editSong" :song-id="editingSongId" />
 		<floating-box
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
@@ -121,7 +117,9 @@
 			<template #body>
 				<div>
 					<div>
-						<span class="biggest"><b>Queue songs page</b></span>
+						<span class="biggest"
+							><b>Unverified songs page</b></span
+						>
 						<span
 							><b>Arrow keys up/down</b> - Moves between
 							songs</span
@@ -226,17 +224,17 @@ export default {
 		}
 	},
 	mounted() {
-		this.socket.on("event:admin.queueSong.added", queueSong => {
-			this.songs.push(queueSong);
+		this.socket.on("event:admin.unverifiedSong.added", song => {
+			this.songs.push(song);
 		});
 
-		this.socket.on("event:admin.queueSong.removed", songId => {
+		this.socket.on("event:admin.unverifiedSong.removed", songId => {
 			this.songs = this.songs.filter(song => {
 				return song._id !== songId;
 			});
 		});
 
-		this.socket.on("event:admin.queueSong.updated", updatedSong => {
+		this.socket.on("event:admin.unverifiedSong.updated", updatedSong => {
 			for (let i = 0; i < this.songs.length; i += 1) {
 				const song = this.songs[i];
 				if (song._id === updatedSong._id) {
@@ -260,19 +258,19 @@ export default {
 			this.openModal({ sector: "admin", modal: "editSong" });
 		},
 		add(song) {
-			this.socket.dispatch("songs.add", song, res => {
+			this.socket.dispatch("songs.verify", song.songId, res => {
 				if (res.status === "success")
 					new Toast({ content: res.message, timeout: 2000 });
 				else new Toast({ content: res.message, timeout: 4000 });
 			});
 		},
-		remove(id) {
+		hide(id) {
 			// eslint-disable-next-line
 			const dialogResult = window.confirm(
-				"Are you sure you want to delete this song?"
+				"Are you sure you want to hide this song?"
 			);
 			if (dialogResult !== true) return;
-			this.socket.dispatch("queueSongs.remove", id, res => {
+			this.socket.dispatch("songs.hide", id, res => {
 				if (res.status === "success")
 					new Toast({ content: res.message, timeout: 2000 });
 				else new Toast({ content: res.message, timeout: 4000 });
@@ -283,12 +281,17 @@ export default {
 			if (this.position >= this.maxPosition) return;
 			this.isGettingSet = true;
 
-			this.socket.dispatch("queueSongs.getSet", this.position, data => {
-				data.forEach(song => this.songs.push(song));
+			this.socket.dispatch(
+				"songs.getSet",
+				this.position,
+				"unverified",
+				data => {
+					data.forEach(song => this.songs.push(song));
 
-				this.position += 1;
-				this.isGettingSet = false;
-			});
+					this.position += 1;
+					this.isGettingSet = false;
+				}
+			);
 		},
 		selectPrevious(event) {
 			if (event.srcElement.previousElementSibling)
@@ -308,13 +311,17 @@ export default {
 			if (this.songs.length > 0)
 				this.position = Math.ceil(this.songs.length / 15) + 1;
 
-			this.socket.dispatch("queueSongs.length", length => {
+			this.socket.dispatch("songs.length", "unverified", length => {
 				this.maxPosition = Math.ceil(length / 15) + 1;
 
 				this.getSet();
 			});
 
-			this.socket.dispatch("apis.joinAdminRoom", "queue", () => {});
+			this.socket.dispatch(
+				"apis.joinAdminRoom",
+				"unverifiedSongs",
+				() => {}
+			);
 		},
 		// ...mapActions("admin/songs", ["editSong"]),
 		...mapActions("modals/editSong", ["stopVideo"]),

+ 20 - 13
frontend/src/pages/Admin/tabs/Songs.vue → frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -313,13 +313,15 @@ export default {
 		}
 	},
 	mounted() {
-		this.socket.on("event:admin.song.added", song => this.addSong(song));
+		this.socket.on("event:admin.verifiedSong.added", song =>
+			this.addSong(song)
+		);
 
-		this.socket.on("event:admin.song.removed", songId =>
+		this.socket.on("event:admin.verifiedSong.removed", songId =>
 			this.removeSong(songId)
 		);
 
-		this.socket.on("event:admin.song.updated", updatedSong =>
+		this.socket.on("event:admin.verifiedSong.updated", updatedSong =>
 			this.updateSong(updatedSong)
 		);
 
@@ -351,10 +353,10 @@ export default {
 		remove(id) {
 			// eslint-disable-next-line
 			const dialogResult = window.confirm(
-				"Are you sure you want to delete this song?"
+				"Are you sure you want to unverify this song?"
 			);
 			if (dialogResult !== true) return;
-			this.socket.dispatch("songs.remove", id, res => {
+			this.socket.dispatch("songs.unverify", id, res => {
 				if (res.status === "success")
 					new Toast({ content: res.message, timeout: 4000 });
 				else new Toast({ content: res.message, timeout: 8000 });
@@ -365,14 +367,19 @@ export default {
 			if (this.position >= this.maxPosition) return;
 			this.isGettingSet = true;
 
-			this.socket.dispatch("songs.getSet", this.position, data => {
-				data.forEach(song => {
-					this.addSong(song);
-				});
+			this.socket.dispatch(
+				"songs.getSet",
+				this.position,
+				"verified",
+				data => {
+					data.forEach(song => {
+						this.addSong(song);
+					});
 
-				this.position += 1;
-				this.isGettingSet = false;
-			});
+					this.position += 1;
+					this.isGettingSet = false;
+				}
+			);
 		},
 		toggleArtistSelected(artist) {
 			if (this.artistFilterSelected.indexOf(artist) === -1)
@@ -402,7 +409,7 @@ export default {
 			if (this.songs.length > 0)
 				this.position = Math.ceil(this.songs.length / 15) + 1;
 
-			this.socket.dispatch("songs.length", length => {
+			this.socket.dispatch("songs.length", "verified", length => {
 				this.maxPosition = Math.ceil(length / 15) + 1;
 
 				this.getSet();

+ 70 - 10
frontend/src/pages/Home.vue

@@ -67,18 +67,52 @@
 					<div class="card-image">
 						<figure class="image is-square">
 							<div
-								v-if="station.currentSong.ytThumbnail"
+								v-if="
+									station.currentSong.songId &&
+										(!station.currentSong.thumbnail ||
+											(station.currentSong.thumbnail &&
+												(station.currentSong.thumbnail.lastIndexOf(
+													'notes-transparent'
+												) !== -1 ||
+													station.currentSong.thumbnail.lastIndexOf(
+														'/assets/notes.png'
+													) !== -1 ||
+													station.currentSong.thumbnail.lastIndexOf(
+														'i.ytimg.com'
+													) !== -1)) ||
+											station.currentSong.thumbnail ==
+												('empty' || null))
+								"
 								class="ytThumbnailBg"
 								:style="{
 									'background-image':
 										'url(' +
-										station.currentSong.ytThumbnail +
+										`https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg` +
 										')'
 								}"
 							></div>
 							<img
-								v-if="station.currentSong.ytThumbnail"
-								:src="station.currentSong.ytThumbnail"
+								v-if="
+									station.currentSong.songId &&
+										(!station.currentSong.thumbnail ||
+											(station.currentSong.thumbnail &&
+												(station.currentSong.thumbnail.lastIndexOf(
+													'notes-transparent'
+												) !== -1 ||
+													station.currentSong.thumbnail.lastIndexOf(
+														'/assets/notes.png'
+													) !== -1 ||
+													station.currentSong.thumbnail.lastIndexOf(
+														'i.ytimg.com'
+													) !== -1)) ||
+											station.currentSong.thumbnail ===
+												'empty' ||
+											station.currentSong.thumbnail ==
+												null)
+								"
+								:src="
+									`https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg`
+								"
 								onerror="this.src='/assets/notes-transparent.png'"
 							/>
 							<img
@@ -286,18 +320,46 @@
 					<div class="card-image">
 						<figure class="image is-square">
 							<div
-								v-if="station.currentSong.ytThumbnail"
+								v-if="
+									station.currentSong.songId &&
+										(!station.currentSong.thumbnail ||
+											(station.currentSong.thumbnail &&
+												(station.currentSong.thumbnail.lastIndexOf(
+													'notes-transparent'
+												) !== -1 ||
+													station.currentSong.thumbnail.lastIndexOf(
+														'/assets/notes.png'
+													) !== -1)) ||
+											station.currentSong.thumbnail ===
+												'empty' ||
+											station.currentSong.thumbnail ==
+												null)
+								"
 								class="ytThumbnailBg"
 								:style="{
 									'background-image':
 										'url(' +
-										station.currentSong.ytThumbnail +
+										`https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg` +
 										')'
 								}"
 							></div>
 							<img
-								v-if="station.currentSong.ytThumbnail"
-								:src="station.currentSong.ytThumbnail"
+								v-if="
+									station.currentSong.songId &&
+										(!station.currentSong.thumbnail ||
+											(station.currentSong.thumbnail &&
+												(station.currentSong.thumbnail.lastIndexOf(
+													'notes-transparent'
+												) !== -1 ||
+													station.currentSong.thumbnail.lastIndexOf(
+														'/assets/notes.png'
+													) !== -1)) ||
+											station.currentSong.thumbnail ==
+												('empty' || null))
+								"
+								:src="
+									`https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg`
+								"
 								onerror="this.src='/assets/notes-transparent.png'"
 							/>
 							<img
@@ -601,8 +663,6 @@ export default {
 						newSong = {
 							thumbnail: "/assets/notes-transparent.png"
 						};
-					if (newSong && !newSong.thumbnail)
-						newSong.ytThumbnail = `https://img.youtube.com/vi/${newSong.songId}/mqdefault.jpg`;
 					station.currentSong = newSong;
 				}
 			});

+ 66 - 12
frontend/src/pages/Station/components/CurrentlyPlaying.vue

@@ -2,15 +2,48 @@
 	<div class="currently-playing">
 		<figure class="thumbnail">
 			<div
-				v-if="song.ytThumbnail"
+				v-if="
+					song.songId &&
+						(!song.thumbnail ||
+							(song.thumbnail &&
+								(song.thumbnail.lastIndexOf(
+									'notes-transparent'
+								) !== -1 ||
+									song.thumbnail.lastIndexOf(
+										'/assets/notes.png'
+									) !== -1 ||
+									song.thumbnail.lastIndexOf(
+										'i.ytimg.com'
+									) !== -1)) ||
+							song.thumbnail === 'empty' ||
+							song.thumbnail == null)
+				"
 				id="yt-thumbnail-bg"
 				:style="{
-					'background-image': 'url(' + song.ytThumbnail + ')'
+					'background-image':
+						'url(' +
+						`https://img.youtube.com/vi/${song.songId}/mqdefault.jpg` +
+						')'
 				}"
 			></div>
 			<img
-				v-if="song.ytThumbnail"
-				:src="song.ytThumbnail"
+				v-if="
+					song.songId &&
+						(!song.thumbnail ||
+							(song.thumbnail &&
+								(song.thumbnail.lastIndexOf(
+									'notes-transparent'
+								) !== -1 ||
+									song.thumbnail.lastIndexOf(
+										'/assets/notes.png'
+									) !== -1 ||
+									song.thumbnail.lastIndexOf(
+										'i.ytimg.com'
+									) !== -1)) ||
+							song.thumbnail === 'empty' ||
+							song.thumbnail == null)
+				"
+				:src="`https://img.youtube.com/vi/${song.songId}/mqdefault.jpg`"
 				onerror="this.src='/assets/notes-transparent.png'"
 			/>
 			<img
@@ -29,6 +62,13 @@
 					:title="song.title"
 				>
 					{{ song.title }}
+					<i
+						v-if="song.status === 'verified'"
+						class="material-icons verified-song"
+						title="Verified Song"
+					>
+						check_circle
+					</i>
 				</h4>
 				<h5
 					id="song-artists"
@@ -44,19 +84,30 @@
 							station.partyMode === true
 					"
 				>
-					Requested
-					<strong>{{
-						formatDistance(parseISO(song.requestedAt), Date.now(), {
-							addSuffix: true
-						})
-					}}</strong>
+					Requested by
+					<strong>
+						<user-id-to-username
+							:user-id="song.requestedBy"
+							:link="true"
+						/>
+						{{
+							formatDistance(
+								parseISO(song.requestedAt),
+								new Date(),
+								{
+									includeSeconds: true
+								}
+							)
+						}}
+						ago
+					</strong>
 				</p>
 			</div>
 			<div id="song-actions">
 				<button
 					class="button"
 					id="report-icon"
-					v-if="loggedIn && !song.simpleSong"
+					v-if="loggedIn"
 					@click="report(song)"
 				>
 					<i class="material-icons icon-with-button">flag</i>
@@ -72,7 +123,7 @@
 				<button
 					class="button is-primary"
 					id="editsong-icon"
-					v-if="$parent.isAdminOnly() && !song.simpleSong"
+					v-if="$parent.isAdminOnly()"
 					@click="$parent.editSong(song)"
 				>
 					<i class="material-icons icon-with-button">edit</i>
@@ -86,7 +137,10 @@
 import { mapState, mapActions } from "vuex";
 import { formatDistance, parseISO } from "date-fns";
 
+import UserIdToUsername from "../../../components/common/UserIdToUsername.vue";
+
 export default {
+	components: { UserIdToUsername },
 	props: {
 		song: {
 			type: Object,

+ 25 - 12
frontend/src/pages/Station/components/Sidebar/Queue/QueueItem.vue

@@ -3,7 +3,20 @@
 		<div id="thumbnail-and-info">
 			<img
 				class="item-thumbnail"
-				:src="song.ytThumbnail ? song.ytThumbnail : song.thumbnail"
+				:src="
+					song.songId &&
+					(!song.thumbnail ||
+						(song.thumbnail &&
+							(song.thumbnail.lastIndexOf('notes-transparent') !==
+								-1 ||
+								song.thumbnail.lastIndexOf(
+									'/assets/notes.png'
+								) !== -1)) ||
+						song.thumbnail === 'empty' ||
+						song.thumbnail == null)
+						? `https://img.youtube.com/vi/${song.songId}/mqdefault.jpg`
+						: song.thumbnail
+				"
 				onerror="this.src='/assets/notes-transparent.png'"
 			/>
 			<div id="song-info">
@@ -15,6 +28,13 @@
 					:title="song.title"
 				>
 					{{ song.title }}
+					<i
+						v-if="song.status === 'verified'"
+						class="material-icons verified-song"
+						title="Verified Song"
+					>
+						check_circle
+					</i>
 				</h4>
 				<h5
 					class="item-description"
@@ -41,10 +61,11 @@
 								parseISO(song.requestedAt),
 								new Date(),
 								{
-									addSuffix: true
+									includeSeconds: true
 								}
 							)
 						}}
+						ago
 					</strong>
 				</p>
 			</div>
@@ -57,11 +78,7 @@
 			</p>
 			<div class="universal-item-actions">
 				<i
-					v-if="
-						$parent.loggedIn &&
-							song.likes !== -1 &&
-							song.dislikes !== -1
-					"
+					v-if="$parent.loggedIn"
 					class="material-icons report-icon"
 					@click="report(song)"
 				>
@@ -73,11 +90,7 @@
 					>queue</i
 				>
 				<i
-					v-if="
-						$parent.isAdminOnly() &&
-							song.likes !== -1 &&
-							song.dislikes !== -1
-					"
+					v-if="$parent.isAdminOnly()"
 					class="material-icons edit-icon"
 					@click="$parent.$parent.$parent.editSong(song)"
 				>

+ 8 - 21
frontend/src/pages/Station/index.vue

@@ -172,10 +172,6 @@
 									<!-- Ratings (Like/Dislike) Buttons -->
 									<div
 										id="ratings"
-										v-if="
-											currentSong.likes !== -1 &&
-												currentSong.dislikes !== -1
-										"
 										:class="{
 											liked: liked,
 											disliked: disliked
@@ -253,13 +249,7 @@
 								</div>
 								<div id="right-buttons" v-else>
 									<!-- Disabled Ratings (Like/Dislike) Buttons -->
-									<div
-										id="ratings"
-										v-if="
-											currentSong.likes !== -1 &&
-												currentSong.dislikes !== -1
-										"
-									>
+									<div id="ratings">
 										<!-- Disabled Like Song Button -->
 										<button
 											class="button is-success tooltip tooltip-top disabled"
@@ -490,12 +480,12 @@
 				>
 				<span><b>Station paused</b>: {{ stationPaused }}</span>
 				<span
-					><b>Station Genres</b>:
-					{{ station.genres.join(", ") }}</span
+					><b>Station Included Playlists</b>:
+					{{ station.includedPlaylists.join(", ") }}</span
 				>
 				<span
-					><b>Station Blacklisted Genres</b>:
-					{{ station.blacklistedGenres.join(", ") }}</span
+					><b>Station Excluded Playlists</b>:
+					{{ station.excludedPlaylists.join(", ") }}</span
 				>
 			</template>
 		</floating-box>
@@ -641,9 +631,6 @@ export default {
 
 			const { currentSong } = data;
 
-			if (currentSong && !currentSong.thumbnail)
-				currentSong.ytThumbnail = `https://img.youtube.com/vi/${currentSong.songId}/mqdefault.jpg`;
-
 			this.updateCurrentSong(currentSong || {});
 
 			let nextSong = null;
@@ -1535,9 +1522,6 @@ export default {
 							? res.data.currentSong
 							: {};
 
-						if (currentSong && !currentSong.thumbnail)
-							currentSong.ytThumbnail = `https://img.youtube.com/vi/${currentSong.songId}/mqdefault.jpg`;
-
 						this.updateCurrentSong(currentSong);
 
 						this.startedAt = res.data.startedAt;
@@ -2227,6 +2211,9 @@ export default {
 }
 
 @media (min-width: 1500px) {
+	#station-left-column {
+		max-width: calc(100% - 650px);
+	}
 	#station-right-column {
 		max-width: 650px;
 	}

+ 0 - 7
frontend/src/store/modules/station.js

@@ -76,13 +76,6 @@ const mutations = {
 		state.users = users;
 	},
 	updateCurrentSong(state, currentSong) {
-		if (currentSong.likes === -1 && currentSong.dislikes === -1) {
-			currentSong.skipDuration = 0;
-			currentSong.simpleSong = true;
-		} else {
-			currentSong.simpleSong = false;
-		}
-
 		state.currentSong = currentSong;
 	},
 	updatePreviousSong(state, previousSong) {