Browse Source

Merge branch 'v3.3.0' into staging

Owen Diffey 2 years ago
parent
commit
e7677079e3

+ 5 - 2
backend/logic/actions/apis.js

@@ -129,7 +129,9 @@ export default {
 			room.startsWith("profile.") ||
 			room.startsWith("manage-station.") ||
 			room.startsWith("edit-song.") ||
-			room.startsWith("view-report.")
+			room.startsWith("view-report.") ||
+			room.startsWith("edit-user.") ||
+			room === "import-album"
 		) {
 			WSModule.runJob("SOCKET_JOIN_ROOM", {
 				socketId: session.socketId,
@@ -157,7 +159,8 @@ export default {
 			room.startsWith("profile.") ||
 			room.startsWith("manage-station.") ||
 			room.startsWith("edit-song.") ||
-			room.startsWith("view-report.")
+			room.startsWith("view-report.") ||
+			room === "import-album"
 		) {
 			WSModule.runJob("SOCKET_LEAVE_ROOM", {
 				socketId: session.socketId,

+ 46 - 0
backend/logic/actions/playlists.js

@@ -2031,5 +2031,51 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Create missing genre playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	createMissingGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("CREATE_MISSING_GENRE_PLAYLISTS", this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
+						`Creating missing genre playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
+					`Successfully created missing genre playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Missing genre playlists have been successfully created"
+				});
+			}
+		);
 	})
 };

+ 26 - 156
backend/logic/actions/songs.js

@@ -14,121 +14,22 @@ const YouTubeModule = moduleManager.modules.youtube;
 const PlaylistsModule = moduleManager.modules.playlists;
 
 CacheModule.runJob("SUB", {
-	channel: "song.newUnverifiedSong",
-	cb: async songId => {
+	channel: "song.updated",
+	cb: async data => {
 		const songModel = await DBModule.runJob("GET_MODEL", {
 			modelName: "song"
 		});
 
-		songModel.findOne({ _id: songId }, (err, song) =>
+		songModel.findOne({ _id: data.songId }, (err, song) => {
 			WSModule.runJob("EMIT_TO_ROOMS", {
-				rooms: ["admin.unverifiedSongs", `edit-song.${songId}`],
-				args: ["event:admin.unverifiedSong.created", { data: { song } }]
-			})
-		);
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.removedUnverifiedSong",
-	cb: songId => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.unverifiedSongs",
-			args: ["event:admin.unverifiedSong.deleted", { data: { 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", { data: { song } }]
-			});
-		});
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.newVerifiedSong",
-	cb: async songId => {
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
-
-		songModel.findOne({ _id: songId }, (err, song) =>
-			WSModule.runJob("EMIT_TO_ROOMS", {
-				rooms: ["admin.songs", `edit-song.${songId}`],
-				args: ["event:admin.verifiedSong.created", { data: { song } }]
-			})
-		);
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.removedVerifiedSong",
-	cb: songId => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.songs",
-			args: ["event:admin.verifiedSong.deleted", { data: { songId } }]
-		});
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.updatedVerifiedSong",
-	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.songs",
-				args: ["event:admin.verifiedSong.updated", { data: { 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_ROOMS", {
-				rooms: ["admin.hiddenSongs", `edit-song.${songId}`],
-				args: ["event:admin.hiddenSong.created", { data: { song } }]
-			})
-		);
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.removedHiddenSong",
-	cb: songId => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.hiddenSongs",
-			args: ["event:admin.hiddenSong.deleted", { data: { 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", { data: { song } }]
+				rooms: [
+					"import-album",
+					"admin.songs",
+					"admin.unverifiedSongs",
+					"admin.hiddenSongs",
+					`edit-song.${data.songId}`
+				],
+				args: ["event:admin.song.updated", { data: { song, oldStatus: data.oldStatus } }]
 			});
 		});
 	}
@@ -421,23 +322,6 @@ export default {
 
 				this.log("SUCCESS", "SONGS_UPDATE", `Successfully updated song "${songId}".`);
 
-				if (song.status === "verified") {
-					CacheModule.runJob("PUB", {
-						channel: "song.updatedVerifiedSong",
-						value: song._id
-					});
-				} else if (song.status === "unverified") {
-					CacheModule.runJob("PUB", {
-						channel: "song.updatedUnverifiedSong",
-						value: song._id
-					});
-				} else if (song.status === "hidden") {
-					CacheModule.runJob("PUB", {
-						channel: "song.updatedHiddenSong",
-						value: song._id
-					});
-				}
-
 				return cb({
 					status: "success",
 					message: "Song has been successfully updated",
@@ -684,11 +568,17 @@ export default {
 							.catch(() => {});
 					});
 
-					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+					song.artists.forEach(artist => {
+						PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id, oldStatus });
 					next(null, song, oldStatus);
 				}
 			],
-			async (err, song, oldStatus) => {
+			async err => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log("ERROR", "SONGS_VERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
@@ -697,22 +587,6 @@ export default {
 
 				this.log("SUCCESS", "SONGS_VERIFY", `User "${session.userId}" successfully verified song "${songId}".`);
 
-				if (oldStatus === "hidden")
-					CacheModule.runJob("PUB", {
-						channel: "song.removedHiddenSong",
-						value: song._id
-					});
-
-				CacheModule.runJob("PUB", {
-					channel: "song.newVerifiedSong",
-					value: song._id
-				});
-
-				CacheModule.runJob("PUB", {
-					channel: "song.removedUnverifiedSong",
-					value: song._id
-				});
-
 				return cb({
 					status: "success",
 					message: "Song has been verified successfully."
@@ -756,7 +630,13 @@ export default {
 							.catch(() => {});
 					});
 
-					SongsModule.runJob("UPDATE_SONG", { songId });
+					song.artists.forEach(artist => {
+						PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: "verified" });
 
 					next(null);
 				}
@@ -776,16 +656,6 @@ export default {
 					`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."

+ 179 - 0
backend/logic/actions/users.js

@@ -157,6 +157,16 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "user.removeAccount",
+	cb: userId => {
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.users", `edit-user.${userId}`],
+			args: ["event:user.removed", { data: { userId } }]
+		});
+	}
+});
+
 export default {
 	/**
 	 * Lists all Users
@@ -360,6 +370,175 @@ export default {
 					`Successfully removed data and account for user "${session.userId}"`
 				);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.removeAccount",
+					value: session.userId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed data and account."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes all data held on a user, including their ability to login, by userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the user id that is going to be banned
+	 * @param {Function} cb - gets called with the result
+	 */
+	adminRemove: isAdminRequired(async function adminRemove(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
+		const songsToAdjustRatings = [];
+
+		async.waterfall(
+			[
+				next => {
+					if (!userId) return next("You must provide a userId to remove.");
+					return next();
+				},
+				// activities related to the user
+				next => {
+					activityModel.deleteMany({ userId }, next);
+				},
+
+				// user's stations
+				(res, next) => {
+					stationModel.find({ owner: userId }, (err, stations) => {
+						if (err) return next(err);
+
+						return async.each(
+							stations,
+							(station, callback) => {
+								// delete the station
+								stationModel.deleteOne({ _id: station._id }, err => {
+									if (err) return callback(err);
+
+									// if applicable, delete the corresponding playlist for the station
+									if (station.playlist)
+										return PlaylistsModule.runJob("DELETE_PLAYLIST", {
+											playlistId: station.playlist
+										})
+											.then(() => callback())
+											.catch(callback);
+
+									return callback();
+								});
+							},
+							err => next(err)
+						);
+					});
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: userId, displayName: "Liked Songs" }, next);
+				},
+
+				// get all liked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: userId, displayName: "Disliked Songs" }, next);
+				},
+
+				// get all disliked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				// user's playlists
+				next => {
+					playlistModel.deleteMany({ createdBy: userId }, next);
+				},
+
+				(res, next) => {
+					async.each(
+						songsToAdjustRatings,
+						(song, next) => {
+							const { songId, youtubeId } = song;
+
+							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+								.then(() => next())
+								.catch(next);
+						},
+						err => next(err)
+					);
+				},
+
+				// user object
+				next => {
+					userModel.deleteMany({ _id: userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, userId, "remove", err => next(err));
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_ADMIN_REMOVE",
+						`Removing data and account for user "${userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_ADMIN_REMOVE", `Successfully removed data and account for user "${userId}"`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.removeAccount",
+					value: userId
+				});
+
 				return cb({
 					status: "success",
 					message: "Successfully removed data and account."

+ 17 - 38
backend/logic/songs.js

@@ -297,6 +297,7 @@ class _SongsModule extends CoreClass {
 	 *
 	 * @param {object} payload - an object containing the payload
 	 * @param {string} payload.songId - the id of the song we are trying to update
+	 * @param {string} payload.oldStatus - old status of song being updated (optional)
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	UPDATE_SONG(payload) {
@@ -460,6 +461,14 @@ class _SongsModule extends CoreClass {
 				],
 				(err, song) => {
 					if (err && err !== true) return reject(new Error(err));
+
+					if (!payload.oldStatus) payload.oldStatus = null;
+
+					CacheModule.runJob("PUB", {
+						channel: "song.updated",
+						value: { songId: song._id, oldStatus: payload.oldStatus }
+					});
+
 					return resolve(song);
 				}
 			)
@@ -946,11 +955,6 @@ class _SongsModule extends CoreClass {
 
 					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
-					CacheModule.runJob("PUB", {
-						channel: "song.newUnverifiedSong",
-						value: song._id
-					});
-
 					return resolve({ song: trimmedSong });
 				}
 			);
@@ -979,36 +983,22 @@ class _SongsModule extends CoreClass {
 						if (!song) return next("This song does not exist.");
 						if (song.status === "hidden") return next("This song is already hidden.");
 						// TODO Add err object as first param of callback
-						return next();
+						return next(null, song.status);
 					},
 
-					next => {
-						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, next);
+					(oldStatus, next) => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, res =>
+							next(null, res, oldStatus)
+						);
 					},
 
-					(res, next) => {
-						SongsModule.runJob("UPDATE_SONG", { songId });
+					(res, oldStatus, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId, oldStatus });
 						next();
 					}
 				],
 				async err => {
 					if (err) reject(err);
-
-					CacheModule.runJob("PUB", {
-						channel: "song.newHiddenSong",
-						value: songId
-					});
-
-					CacheModule.runJob("PUB", {
-						channel: "song.removedUnverifiedSong",
-						value: songId
-					});
-
-					CacheModule.runJob("PUB", {
-						channel: "song.removedVerifiedSong",
-						value: songId
-					});
-
 					resolve();
 				}
 			);
@@ -1045,23 +1035,12 @@ class _SongsModule extends CoreClass {
 					},
 
 					(res, next) => {
-						SongsModule.runJob("UPDATE_SONG", { songId });
+						SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: "hidden" });
 						next();
 					}
 				],
 				async err => {
 					if (err) reject(err);
-
-					CacheModule.runJob("PUB", {
-						channel: "song.newUnverifiedSong",
-						value: songId
-					});
-
-					CacheModule.runJob("PUB", {
-						channel: "song.removedHiddenSong",
-						value: songId
-					});
-
 					resolve();
 				}
 			);

+ 5 - 0
frontend/src/App.vue

@@ -1292,6 +1292,11 @@ button.delete:focus {
 		border-width: 0;
 		color: var(--light-grey);
 	}
+
+	&.is-fullwidth {
+		display: flex;
+		width: 100%;
+	}
 }
 
 .input,

+ 1 - 0
frontend/src/components/SongThumbnail.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="thumbnail">
+		<slot name="icon" />
 		<div
 			v-if="
 				song.youtubeId &&

+ 10 - 23
frontend/src/components/modals/EditSong/index.vue

@@ -673,25 +673,7 @@ export default {
 		this.volumeSliderValue = volume * 100;
 
 		this.socket.on(
-			"event:admin.hiddenSong.created",
-			res => {
-				if (res.data.song._id === this.song._id)
-					this.song.status = res.data.song.status;
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.unverifiedSong.created",
-			res => {
-				if (res.data.song._id === this.song._id)
-					this.song.status = res.data.song.status;
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.verifiedSong.created",
+			"event:admin.song.updated",
 			res => {
 				if (res.data.song._id === this.song._id)
 					this.song.status = res.data.song.status;
@@ -898,13 +880,18 @@ export default {
 				this.song._id,
 				res => {
 					if (res.status === "success") {
-						const { song } = res.data;
+						let { song } = res.data;
+
+						if (this.song.prefill)
+							song = Object.assign(song, this.song.prefill);
+
 						if (this.song.discogs)
-							this.editSong({
+							song = {
 								...song,
 								discogs: this.song.discogs
-							});
-						else this.editSong(song);
+							};
+
+						this.editSong(song);
 
 						this.songDataLoaded = true;
 

+ 34 - 4
frontend/src/components/modals/EditUser.vue

@@ -76,9 +76,12 @@
 				</div>
 			</template>
 			<template #footer>
-				<button class="button is-warning" @click="removeSessions()">
-					<span>&nbsp;Remove all sessions</span>
-				</button>
+				<confirm @confirm="removeSessions()">
+					<a class="button is-warning"> Remove all sessions </a>
+				</confirm>
+				<confirm @confirm="removeAccount()">
+					<a class="button is-danger"> Remove account </a>
+				</confirm>
 			</template>
 		</modal>
 	</div>
@@ -91,9 +94,10 @@ import Toast from "toasters";
 import validation from "@/validation";
 import ws from "@/ws";
 import Modal from "../Modal.vue";
+import Confirm from "@/components/Confirm.vue";
 
 export default {
-	components: { Modal },
+	components: { Modal, Confirm },
 	props: {
 		userId: { type: String, default: "" },
 		sector: { type: String, default: "admin" }
@@ -116,12 +120,33 @@ export default {
 	mounted() {
 		ws.onConnect(this.init);
 	},
+	beforeUnmount() {
+		this.socket.dispatch(
+			"apis.leaveRoom",
+			`edit-user.${this.userId}`,
+			() => {}
+		);
+	},
 	methods: {
 		init() {
 			this.socket.dispatch(`users.getUserFromId`, this.userId, res => {
 				if (res.status === "success") {
 					const user = res.data;
 					this.editUser(user);
+
+					this.socket.dispatch(
+						"apis.joinRoom",
+						`edit-user.${this.userId}`
+					);
+
+					this.socket.on(
+						"event:user.removed",
+						res => {
+							if (res.data.userId === this.userId)
+								this.closeModal("editUser");
+						},
+						{ modal: "editUser" }
+					);
 				} else {
 					new Toast("User with that ID not found");
 					this.closeModal("editUser");
@@ -207,6 +232,11 @@ export default {
 				}
 			);
 		},
+		removeAccount() {
+			this.socket.dispatch(`users.adminRemove`, this.user._id, res => {
+				new Toast(res.message);
+			});
+		},
 		removeSessions() {
 			this.socket.dispatch(`users.removeSessions`, this.user._id, res => {
 				new Toast(res.message);

File diff suppressed because it is too large
+ 502 - 339
frontend/src/components/modals/ImportAlbum.vue


+ 6 - 1
frontend/src/pages/Admin/index.vue

@@ -212,7 +212,11 @@ export default {
 					this.showTab("punishments");
 					break;
 				default:
-					this.showTab("verifiedsongs");
+					if (localStorage.getItem("lastAdminPage")) {
+						this.showTab(localStorage.getItem("lastAdminPage"));
+					} else {
+						this.showTab("verifiedsongs");
+					}
 			}
 		},
 		showTab(tab) {
@@ -222,6 +226,7 @@ export default {
 					block: "nearest"
 				});
 			this.currentTab = tab;
+			localStorage.setItem("lastAdminPage", tab);
 		}
 	}
 };

+ 8 - 11
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -391,17 +391,14 @@ export default {
 	mounted() {
 		ws.onConnect(this.init);
 
-		this.socket.on("event:admin.hiddenSong.created", res => {
-			console.log("CREATED");
-			this.addSong(res.data.song);
-		});
-
-		this.socket.on("event:admin.hiddenSong.deleted", res => {
-			this.removeSong(res.data.songId);
-		});
-
-		this.socket.on("event:admin.hiddenSong.updated", res => {
-			this.updateSong(res.data.song);
+		this.socket.on("event:admin.song.updated", res => {
+			const { song } = res.data;
+			if (res.data.oldStatus && res.data.oldStatus === "hidden") {
+				this.removeSong(song._id);
+			} else {
+				this.addSong(song);
+				this.updateSong(song);
+			}
 		});
 	},
 	methods: {

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

@@ -43,6 +43,14 @@
 						Clear and refill all genre playlists
 					</button>
 				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="createMissingGenrePlaylists()"
+				>
+					<button class="button is-danger">
+						Create missing genre playlists
+					</button>
+				</confirm>
 			</div>
 			<table class="table">
 				<thead>
@@ -271,6 +279,20 @@ export default {
 				}
 			);
 		},
+		createMissingGenrePlaylists() {
+			this.socket.dispatch(
+				"playlists.createMissingGenrePlaylists",
+				data => {
+					console.log(data.message);
+					if (data.status !== "success")
+						new Toast({
+							content: `Error: ${data.message}`,
+							timeout: 8000
+						});
+					else new Toast({ content: data.message, timeout: 4000 });
+				}
+			);
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("user/playlists", ["editPlaylist"]),
 		...mapActions("admin/playlists", [

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

@@ -407,19 +407,17 @@ export default {
 		})
 	},
 	mounted() {
-		this.socket.on("event:admin.unverifiedSong.created", res => {
-			this.addSong(res.data.song);
-		});
-
-		this.socket.on("event:admin.unverifiedSong.deleted", res => {
-			this.removeSong(res.data.songId);
-		});
+		ws.onConnect(this.init);
 
-		this.socket.on("event:admin.unverifiedSong.updated", res => {
-			this.updateSong(res.data.song);
+		this.socket.on("event:admin.song.updated", res => {
+			const { song } = res.data;
+			if (res.data.oldStatus && res.data.oldStatus === "unverified") {
+				this.removeSong(song._id);
+			} else {
+				this.addSong(song);
+				this.updateSong(song);
+			}
 		});
-
-		ws.onConnect(this.init);
 	},
 	methods: {
 		edit(song) {

+ 6 - 0
frontend/src/pages/Admin/tabs/Users.vue

@@ -137,6 +137,12 @@ export default {
 				request => request._id !== res.data.dataRequestId
 			);
 		});
+
+		this.socket.on("event:user.removed", res => {
+			this.users = this.users.filter(
+				user => user._id !== res.data.userId
+			);
+		});
 	},
 	methods: {
 		edit(user) {

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

@@ -425,20 +425,18 @@ export default {
 		})
 	},
 	mounted() {
-		this.socket.on("event:admin.verifiedSong.created", res =>
-			this.addSong(res.data.song)
-		);
-
-		this.socket.on("event:admin.verifiedSong.deleted", res =>
-			this.removeSong(res.data.songId)
-		);
-
-		this.socket.on("event:admin.verifiedSong.updated", res =>
-			this.updateSong(res.data.song)
-		);
-
 		ws.onConnect(this.init);
 
+		this.socket.on("event:admin.song.updated", res => {
+			const { song } = res.data;
+			if (res.data.oldStatus && res.data.oldStatus === "verified") {
+				this.removeSong(song._id);
+			} else {
+				this.addSong(song);
+				this.updateSong(song);
+			}
+		});
+
 		if (this.$route.query.songId) {
 			this.socket.dispatch(
 				"songs.getSongFromSongId",

+ 88 - 4
frontend/src/pages/Home.vue

@@ -63,7 +63,25 @@
 								'--primary-color: var(--' + element.theme + ')'
 							"
 						>
-							<song-thumbnail :song="element.currentSong" />
+							<song-thumbnail :song="element.currentSong">
+								<template #icon v-if="isOwnerOrAdmin(element)">
+									<div class="icon-container">
+										<div
+											class="
+												material-icons
+												manage-station
+											"
+											@click.prevent="
+												manageStation(element._id)
+											"
+											content="Manage Station"
+											v-tippy
+										>
+											settings
+										</div>
+									</div>
+								</template>
+							</song-thumbnail>
 							<div class="card-content">
 								<div class="media">
 									<div class="media-left displayName">
@@ -287,7 +305,20 @@
 					}"
 					:style="'--primary-color: var(--' + station.theme + ')'"
 				>
-					<song-thumbnail :song="station.currentSong" />
+					<song-thumbnail :song="station.currentSong">
+						<template #icon v-if="isOwnerOrAdmin(station)">
+							<div class="icon-container">
+								<div
+									class="material-icons manage-station"
+									@click.prevent="manageStation(station._id)"
+									content="Manage Station"
+									v-tippy
+								>
+									settings
+								</div>
+							</div>
+						</template>
+					</song-thumbnail>
 					<div class="card-content">
 						<div class="media">
 							<div class="media-left displayName">
@@ -428,6 +459,11 @@
 			<main-footer />
 		</div>
 		<create-station v-if="modals.createStation" />
+		<manage-station
+			v-if="modals.manageStation"
+			:station-id="editingStationId"
+			sector="home"
+		/>
 	</div>
 </template>
 
@@ -452,6 +488,9 @@ export default {
 		CreateStation: defineAsyncComponent(() =>
 			import("@/components/modals/CreateStation.vue")
 		),
+		ManageStation: defineAsyncComponent(() =>
+			import("@/components/modals/ManageStation/index.vue")
+		),
 		UserIdToUsername,
 		draggable
 	},
@@ -463,13 +502,15 @@ export default {
 			searchQuery: "",
 			sitename: "Musare",
 			orderOfFavoriteStations: [],
-			handledLoginRegisterRedirect: false
+			handledLoginRegisterRedirect: false,
+			editingStationId: null
 		};
 	},
 	computed: {
 		...mapState({
 			loggedIn: state => state.user.auth.loggedIn,
 			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role,
 			modals: state => state.modalVisibility.modals
 		}),
 		...mapGetters({
@@ -726,7 +767,13 @@ export default {
 			this.socket.dispatch("apis.joinRoom", "home");
 		},
 		isOwner(station) {
-			return station.owner === this.userId;
+			return this.loggedIn && station.owner === this.userId;
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin(station) {
+			return this.isOwner(station) || this.isAdmin();
 		},
 		isPlaying(station) {
 			return typeof station.currentSong.title !== "undefined";
@@ -770,6 +817,10 @@ export default {
 				res => new Toast(res.message)
 			);
 		},
+		manageStation(stationId) {
+			this.editingStationId = stationId;
+			this.openModal("manageStation");
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", ["updateIfStationIsFavorited"])
 	}
@@ -1153,6 +1204,39 @@ html {
 			position: relative;
 			padding-top: 100%;
 		}
+
+		.icon-container {
+			display: flex;
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			bottom: 0;
+			left: 0;
+			right: 0;
+
+			.material-icons.manage-station {
+				display: inline-flex;
+				opacity: 0;
+				background: var(--primary-color);
+				color: var(--white);
+				margin: auto;
+				font-size: 40px;
+				border-radius: 100%;
+				padding: 10px;
+				transition: all 0.2s ease-in-out;
+			}
+
+			&:hover,
+			&:focus {
+				.material-icons.manage-station {
+					opacity: 1;
+					&:hover,
+					&:focus {
+						filter: brightness(90%);
+					}
+				}
+			}
+		}
 	}
 
 	.bottomBar {

+ 9 - 6
frontend/src/store/modules/admin.js

@@ -27,7 +27,8 @@ const modules = {
 				state.songs = [];
 			},
 			addSong(state, song) {
-				state.songs.push(song);
+				if (!state.songs.find(s => s._id === song._id))
+					state.songs.push(song);
 			},
 			removeSong(state, songId) {
 				state.songs = state.songs.filter(song => song._id !== songId);
@@ -35,7 +36,7 @@ const modules = {
 			updateSong(state, updatedSong) {
 				state.songs.forEach((song, index) => {
 					if (song._id === updatedSong._id)
-						this.set(state.songs, index, updatedSong);
+						state.songs[index] = updatedSong;
 				});
 			}
 		}
@@ -58,7 +59,8 @@ const modules = {
 				state.songs = [];
 			},
 			addSong(state, song) {
-				state.songs.push(song);
+				if (!state.songs.find(s => s._id === song._id))
+					state.songs.push(song);
 			},
 			removeSong(state, songId) {
 				state.songs = state.songs.filter(song => song._id !== songId);
@@ -66,7 +68,7 @@ const modules = {
 			updateSong(state, updatedSong) {
 				state.songs.forEach((song, index) => {
 					if (song._id === updatedSong._id)
-						this.set(state.songs, index, updatedSong);
+						state.songs[index] = updatedSong;
 				});
 			}
 		}
@@ -89,7 +91,8 @@ const modules = {
 				state.songs = [];
 			},
 			addSong(state, song) {
-				state.songs.push(song);
+				if (!state.songs.find(s => s._id === song._id))
+					state.songs.push(song);
 			},
 			removeSong(state, songId) {
 				state.songs = state.songs.filter(song => song._id !== songId);
@@ -97,7 +100,7 @@ const modules = {
 			updateSong(state, updatedSong) {
 				state.songs.forEach((song, index) => {
 					if (song._id === updatedSong._id)
-						this.set(state.songs, index, updatedSong);
+						state.songs[index] = updatedSong;
 				});
 			}
 		}

+ 20 - 2
frontend/src/store/modules/modals/importAlbum.js

@@ -6,10 +6,13 @@ export default {
 		discogsAlbum: {},
 		originalPlaylistSongs: [],
 		playlistSongs: [],
-		editingSongs: false
+		editingSongs: false,
+		discogsTab: "search",
+		prefillDiscogs: false
 	},
 	getters: {},
 	actions: {
+		showDiscogsTab: ({ commit }, tab) => commit("showDiscogsTab", tab),
 		selectDiscogsAlbum: ({ commit }, discogsAlbum) =>
 			commit("selectDiscogsAlbum", discogsAlbum),
 		toggleDiscogsAlbum: ({ commit }) => {
@@ -21,9 +24,15 @@ export default {
 			commit("updatePlaylistSongs", playlistSongs),
 		updateEditingSongs: ({ commit }, editingSongs) =>
 			commit("updateEditingSongs", editingSongs),
-		resetPlaylistSongs: ({ commit }) => commit("resetPlaylistSongs")
+		resetPlaylistSongs: ({ commit }) => commit("resetPlaylistSongs"),
+		togglePrefillDiscogs: ({ commit }) => commit("togglePrefillDiscogs"),
+		updatePlaylistSong: ({ commit }, updatedSong) =>
+			commit("updatePlaylistSong", updatedSong)
 	},
 	mutations: {
+		showDiscogsTab(state, tab) {
+			state.discogsTab = tab;
+		},
 		selectDiscogsAlbum(state, discogsAlbum) {
 			state.discogsAlbum = JSON.parse(JSON.stringify(discogsAlbum));
 			if (state.discogsAlbum && state.discogsAlbum.tracks) {
@@ -52,6 +61,15 @@ export default {
 			state.playlistSongs = JSON.parse(
 				JSON.stringify(state.originalPlaylistSongs)
 			);
+		},
+		togglePrefillDiscogs(state) {
+			state.prefillDiscogs = !state.prefillDiscogs;
+		},
+		updatePlaylistSong(state, updatedSong) {
+			state.playlistSongs.forEach((song, index) => {
+				if (song._id === updatedSong._id)
+					state.playlistSongs[index] = updatedSong;
+			});
 		}
 	}
 };

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