Browse Source

Changed verified property to status. Removed ability to delete songs. Added hidden songs admin tab.

Kristian Vos 4 years ago
parent
commit
67d5330f09

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

@@ -878,14 +878,14 @@ export default {
 					SongsModule.runJob("ENSURE_SONG_EXISTS_BY_SONG_ID", { songId }, this)
 						.then(response => {
 							const { song } = response;
-							const { _id, title, thumbnail, duration, verified } = song;
+							const { _id, title, thumbnail, duration, status } = song;
 							next(null, {
 								_id,
 								songId,
 								title,
 								thumbnail,
 								duration,
-								verified
+								status
 							});
 						})
 						.catch(next);

+ 143 - 76
backend/logic/actions/songs.js

@@ -203,12 +203,12 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	length: isAdminRequired(async function length(session, verified, cb) {
+	length: isAdminRequired(async function length(session, status, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
 				next => {
-					songModel.countDocuments({ verified }, next);
+					songModel.countDocuments({ status }, next);
 				}
 			],
 			async (err, count) => {
@@ -217,14 +217,14 @@ export default {
 					this.log(
 						"ERROR",
 						"SONGS_LENGTH",
-						`Failed to get length from songs that are ${verified ? "verified" : "not verified"}. "${err}"`
+						`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 that are ${verified ? "verified" : "not verified"} successfully.`
+					`Got length from songs that have the status ${status} successfully.`
 				);
 				return cb(count);
 			}
@@ -238,13 +238,13 @@ export default {
 	 * @param set - the set number to return
 	 * @param cb
 	 */
-	getSet: isAdminRequired(async function getSet(session, set, verified, 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({ verified })
+						.find({ status })
 						.skip(15 * (set - 1))
 						.limit(15)
 						.exec(next);
@@ -256,15 +256,11 @@ export default {
 					this.log(
 						"ERROR",
 						"SONGS_GET_SET",
-						`Failed to get set from songs that are ${verified ? "verified" : "not verified"}. "${err}"`
+						`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 that are ${verified ? "verified" : "not verified"} successfully.`
-				);
+				this.log("SUCCESS", "SONGS_GET_SET", `Got set from songs that have the status ${status} successfully.`);
 				return cb(songs);
 			}
 		);
@@ -436,12 +432,12 @@ export default {
 
 				this.log("SUCCESS", "SONGS_UPDATE", `Successfully updated song "${songId}".`);
 
-				if (song.verified) {
+				if (song.status === "verified") {
 					CacheModule.runJob("PUB", {
 						channel: "song.updatedVerifiedSong",
 						value: song.songId
 					});
-				} else {
+				} else if (song.status === "unverified") {
 					CacheModule.runJob("PUB", {
 						channel: "song.updatedUnverifiedSong",
 						value: song.songId
@@ -457,73 +453,73 @@ 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);
-				},
+	// /**
+	//  * 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);
+	// 			},
 
-				(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);
+	// 			(_song, next) => {
+	// 				song = _song;
+	// 				songModel.deleteOne({ _id: songId }, next);
+	// 			},
 
-					this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
+	// 			(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);
 
-					return cb({ status: "failure", message: err });
-				}
+	// 				this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
 
-				this.log("SUCCESS", "SONGS_REMOVE", `Successfully remove song "${songId}".`);
+	// 				return cb({ status: "failure", message: err });
+	// 			}
 
-				if (song.verified) {
-					CacheModule.runJob("PUB", {
-						channel: "song.removedVerifiedSong",
-						value: songId
-					});
-				} else {
-					CacheModule.runJob("PUB", {
-						channel: "song.removedUnverifiedSong",
-						value: songId
-					});
-				}
+	// 			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"
-				});
-			}
-		);
-	}),
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Song has been successfully removed"
+	// 			});
+	// 		}
+	// 	);
+	// }),
 
 	/**
 	 * Requests a song
@@ -579,7 +575,7 @@ export default {
 				(song, next) => {
 					song.acceptedBy = session.userId;
 					song.acceptedAt = Date.now();
-					song.verified = true;
+					song.status = "verified";
 					song.save(err => {
 						next(err, song);
 					});
@@ -620,6 +616,77 @@ export default {
 		// TODO Check if video is in queue and Add the song to the appropriate stations
 	}),
 
+	/**
+	 * Un-verifies a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	unverify: isAdminRequired(async function add(session, songId, cb) {
+		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					SongModel.findOne({ _id: songId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
+				},
+
+				(song, next) => {
+					song.status = "unverified";
+					song.save(err => {
+						next(err, song);
+					});
+				},
+
+				(song, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					next(null);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_UNVERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
+
+					return cb({ status: "failure", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"SONGS_UNVERIFY",
+					`User "${session.userId}" successfully unverified song "${songId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "song.newUnverifiedSong",
+					value: songId
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "song.removedVerifiedSong",
+					value: songId
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been unverified successfully."
+				});
+			}
+		);
+		// TODO Check if video is in queue and Add the song to the appropriate stations
+	}),
+
 	/**
 	 * Requests a set of songs
 	 *

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

@@ -2818,7 +2818,7 @@ export default {
 					SongsModule.runJob("ENSURE_SONG_EXISTS_BY_SONG_ID", { songId }, this)
 						.then(response => {
 							const { song } = response;
-							const { _id, title, thumbnail, duration, verified } = song;
+							const { _id, title, thumbnail, duration, status } = song;
 							next(
 								null,
 								{
@@ -2827,7 +2827,7 @@ export default {
 									title,
 									thumbnail,
 									duration,
-									verified
+									status
 								},
 								station
 							);

+ 3 - 3
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: 2,
-	station: 3,
+	song: 3,
+	station: 4,
 	user: 1
 };
 

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

@@ -11,7 +11,7 @@ export default {
 			duration: { type: Number },
 			thumbnail: { type: String, required: false },
 			artists: { type: Array, required: false },
-			verified: { type: Boolean, default: false },
+			status: { type: String },
 			position: { type: Number }
 		}
 	],
@@ -20,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 }
 };

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

@@ -14,6 +14,6 @@ export default {
 	acceptedBy: { type: String }, // TODO Should be verifiedBy
 	acceptedAt: { type: Date }, // TODO Should be verifiedAt
 	discogs: { type: Object },
-	verified: { type: Boolean, required: true, default: false },
-	documentVersion: { type: Number, default: 2, required: true }
+	status: { type: String, required: true, default: "hidden", enum: ["hidden", "unverified", "verified"] },
+	documentVersion: { type: Number, default: 3, required: true }
 };

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

@@ -18,7 +18,7 @@ export default {
 		dislikes: { type: Number, default: -1 },
 		skipVotes: [{ type: String }],
 		requestedAt: { type: Date },
-		verified: { type: Boolean, default: false }
+		status: { type: String }
 	},
 	currentSongIndex: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
@@ -35,11 +35,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 },
-			verified: { type: Boolean, required: true, default: false }
+			status: { type: String }
 		}
 	],
 	owner: { type: String },
@@ -48,5 +48,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 }
 };

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

+ 11 - 8
backend/logic/songs.js

@@ -278,7 +278,7 @@ class _SongsModule extends CoreClass {
 
 					(song, next) => {
 						next(null, song);
-						const { _id, songId, title, artists, thumbnail, duration, verified } = song;
+						const { _id, songId, title, artists, thumbnail, duration, status } = song;
 						const trimmedSong = {
 							_id,
 							songId,
@@ -286,7 +286,7 @@ class _SongsModule extends CoreClass {
 							artists,
 							thumbnail,
 							duration,
-							verified
+							status
 						};
 						this.log("INFO", `Going to update playlists and stations now for song ${_id}`);
 						DBModule.runJob("GET_MODEL", { modelName: "playlist" }).then(playlistModel => {
@@ -316,7 +316,7 @@ class _SongsModule extends CoreClass {
 										"queue.$.artists": artists,
 										"queue.$.thumbnail": thumbnail,
 										"queue.$.duration": duration,
-										"queue.$.verified": verified
+										"queue.$.status": status
 									}
 								},
 								err => {
@@ -441,7 +441,7 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.SongModel.find({ verified: true }, { genres: 1, _id: false }, next);
+						SongsModule.SongModel.find({ status: "verified" }, { genres: 1, _id: false }, next);
 					},
 
 					(songs, next) => {
@@ -479,7 +479,10 @@ class _SongsModule extends CoreClass {
 				[
 					next => {
 						SongsModule.SongModel.find(
-							{ verified: true, genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") } },
+							{
+								status: "verified",
+								genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") }
+							},
 							next
 						);
 					}
@@ -568,7 +571,7 @@ class _SongsModule extends CoreClass {
 								song.explicit = false;
 								song.requestedBy = userId;
 								song.requestedAt = requestedAt;
-								song.verified = false;
+								song.status = "unverified";
 								next(null, song);
 							})
 							.catch(next);
@@ -659,7 +662,7 @@ class _SongsModule extends CoreClass {
 										},
 
 										(song, next) => {
-											const { _id, title, artists, thumbnail, duration, verified } = song;
+											const { _id, title, artists, thumbnail, duration, status } = song;
 											const trimmedSong = {
 												_id,
 												songId,
@@ -667,7 +670,7 @@ class _SongsModule extends CoreClass {
 												artists,
 												thumbnail,
 												duration,
-												verified
+												status
 											};
 											playlistModel.updateMany(
 												{ "songs.songId": song.songId },

+ 3 - 3
backend/logic/stations.js

@@ -42,7 +42,7 @@ class _StationsModule extends CoreClass {
 			likes: -1,
 			dislikes: -1,
 			requestedAt: Date.now(),
-			verified: false
+			status: "unverified"
 		};
 
 		this.userList = {};
@@ -621,7 +621,7 @@ class _StationsModule extends CoreClass {
 											requestedAt: queueSong.requestedAt,
 											likes: song.likes,
 											dislikes: song.dislikes,
-											verified: song.verified
+											status: song.status
 										};
 
 										return next(null, newSong);
@@ -902,7 +902,7 @@ class _StationsModule extends CoreClass {
 								skipDuration: song.skipDuration,
 								thumbnail: song.thumbnail,
 								requestedAt: song.requestedAt,
-								verified: song.verified
+								status: song.status
 							};
 						}
 

+ 1 - 1
frontend/src/components/modals/EditPlaylist/components/PlaylistSongItem.vue

@@ -22,7 +22,7 @@
 				<h4 class="item-title" :title="song.title">
 					{{ song.title }}
 					<i
-						v-if="song.verified"
+						v-if="song.status === 'verified'"
 						class="material-icons verified-song"
 						title="Verified Song"
 					>

+ 21 - 0
frontend/src/pages/Admin/index.vue

@@ -27,6 +27,18 @@
 						<span>&nbsp;Verified Songs</span>
 					</router-link>
 				</li>
+				<li
+					:class="{ 'is-active': currentTab == 'hiddensongs' }"
+					@click="showTab('hiddensongs')"
+				>
+					<router-link
+						class="tab hiddensongs"
+						to="/admin/hiddensongs"
+					>
+						<i class="material-icons">music_note</i>
+						<span>&nbsp;Hidden Songs</span>
+					</router-link>
+				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'stations' }"
 					@click="showTab('stations')"
@@ -110,6 +122,7 @@
 
 		<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'" />
@@ -129,6 +142,7 @@ export default {
 		MainHeader,
 		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"),
@@ -160,6 +174,9 @@ export default {
 				case "/admin/verifiedsongs":
 					this.currentTab = "verifiedsongs";
 					break;
+				case "/admin/hiddensongs":
+					this.currentTab = "hiddensongs";
+					break;
 				case "/admin/stations":
 					this.currentTab = "stations";
 					break;
@@ -220,6 +237,10 @@ export default {
 		border-color: var(--teal);
 	}
 	.verifiedsongs {
+		color: var(--teal);
+		border-color: var(--teal);
+	}
+	.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.songId, 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>

+ 11 - 6
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -281,12 +281,17 @@ export default {
 			if (this.position >= this.maxPosition) return;
 			this.isGettingSet = true;
 
-			this.socket.dispatch("songs.getSet", this.position, false, 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)
@@ -306,7 +311,7 @@ export default {
 			if (this.songs.length > 0)
 				this.position = Math.ceil(this.songs.length / 15) + 1;
 
-			this.socket.dispatch("songs.length", false, length => {
+			this.socket.dispatch("songs.length", "unverified", length => {
 				this.maxPosition = Math.ceil(length / 15) + 1;
 
 				this.getSet();

+ 15 - 10
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -353,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 });
@@ -367,14 +367,19 @@ export default {
 			if (this.position >= this.maxPosition) return;
 			this.isGettingSet = true;
 
-			this.socket.dispatch("songs.getSet", this.position, true, 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)
@@ -404,7 +409,7 @@ export default {
 			if (this.songs.length > 0)
 				this.position = Math.ceil(this.songs.length / 15) + 1;
 
-			this.socket.dispatch("songs.length", true, length => {
+			this.socket.dispatch("songs.length", "verified", length => {
 				this.maxPosition = Math.ceil(length / 15) + 1;
 
 				this.getSet();

+ 1 - 1
frontend/src/pages/Station/components/CurrentlyPlaying.vue

@@ -55,7 +55,7 @@
 				>
 					{{ song.title }}
 					<i
-						v-if="song.verified"
+						v-if="song.status === 'verified'"
 						class="material-icons verified-song"
 						title="Verified Song"
 					>

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

@@ -28,7 +28,7 @@
 				>
 					{{ song.title }}
 					<i
-						v-if="song.verified"
+						v-if="song.status === 'verified'"
 						class="material-icons verified-song"
 						title="Verified Song"
 					>