Browse Source

refactor: Split song ratings off into new module for added youtube video support

Owen Diffey 2 years ago
parent
commit
508ba549f7

+ 1 - 0
backend/index.js

@@ -258,6 +258,7 @@ if (!config.get("migration")) {
 	moduleManager.addModule("punishments");
 	moduleManager.addModule("songs");
 	moduleManager.addModule("stations");
+	moduleManager.addModule("ratings");
 	moduleManager.addModule("tasks");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");

+ 3 - 1
backend/logic/actions/index.js

@@ -10,6 +10,7 @@ import news from "./news";
 import punishments from "./punishments";
 import utils from "./utils";
 import youtube from "./youtube";
+import ratings from "./ratings";
 
 export default {
 	apis,
@@ -23,5 +24,6 @@ export default {
 	news,
 	punishments,
 	utils,
-	youtube
+	youtube,
+	ratings
 };

+ 3 - 4
backend/logic/actions/playlists.js

@@ -15,6 +15,7 @@ const CacheModule = moduleManager.modules.cache;
 const PlaylistsModule = moduleManager.modules.playlists;
 const YouTubeModule = moduleManager.modules.youtube;
 const ActivitiesModule = moduleManager.modules.activities;
+const RatingsModule = moduleManager.modules.ratings;
 
 CacheModule.runJob("SUB", {
 	channel: "playlist.create",
@@ -1191,8 +1192,7 @@ export default {
 				},
 				(playlist, newSong, next) => {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
-							songId: newSong._id,
+						RatingsModule.runJob("RECALCULATE_RATINGS", {
 							youtubeId: newSong.youtubeId
 						})
 							.then(ratings => next(null, playlist, newSong, ratings))
@@ -1553,8 +1553,7 @@ export default {
 
 				(playlist, newSong, next) => {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
-							songId: newSong._id,
+						RatingsModule.runJob("RECALCULATE_RATINGS", {
 							youtubeId: newSong.youtubeId
 						})
 							.then(ratings => next(null, playlist, newSong, ratings))

+ 814 - 0
backend/logic/actions/ratings.js

@@ -0,0 +1,814 @@
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const SongsModule = moduleManager.modules.songs;
+const ActivitiesModule = moduleManager.modules.activities;
+const RatingsModule = moduleManager.modules.ratings;
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.like",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.liked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: true,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.dislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.disliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: true
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.unlike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.unliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.undislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.undisliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+export default {
+	/**
+	 * Recalculates all ratings
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	recalculateAll: isAdminRequired(async function recalculateAll(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					RatingsModule.runJob("RECALCULATE_ALL_RATINGS", {}, this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "RATINGS_RECALCULATE_ALL", `Failed to recalculate all ratings. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "RATINGS_RECALCULATE_ALL", `Recalculated all ratings successfully.`);
+				return cb({ status: "success", message: "Successfully recalculated all ratings." });
+			}
+		);
+	}),
+
+	/**
+	 * Like
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	like: isLoginRequired(async function like(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) =>
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already liked this song.");
+								return next("Unable to add song to the 'Liked Songs' playlist.");
+							}
+
+							return next(null, song);
+						})
+						.catch(err => next(err)),
+
+				(song, next) => {
+					RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"RATINGS_LIKE",
+						`User "${session.userId}" failed to like song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.like",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__like",
+					payload: {
+						message: `Liked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully liked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Dislike
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	dislike: isLoginRequired(async function dislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song, user.dislikedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, dislikedSongsPlaylist, next) =>
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already disliked this song.");
+								return next("Unable to add song to the 'Disliked Songs' playlist.");
+							}
+
+							return next(null, song);
+						})
+						.catch(err => next(err)),
+
+				(song, next) => {
+					RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"RATINGS_DISLIKE",
+						`User "${session.userId}" failed to dislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.dislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__dislike",
+					payload: {
+						message: `Disliked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully disliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Undislike
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	undislike: isLoginRequired(async function undislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"RATINGS_UNDISLIKE",
+						`User "${session.userId}" failed to undislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.undislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__undislike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Disliked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully undisliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Unlike
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	unlike: isLoginRequired(async function unlike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"RATINGS_UNLIKE",
+						`User "${session.userId}" failed to unlike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.unlike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__unlike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Liked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully unliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Get ratings
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+
+	getRatings: isLoginRequired(async function getRatings(session, youtubeId, cb) {
+		async.waterfall(
+			[
+				next => {
+					RatingsModule.runJob("GET_RATINGS", { youtubeId }, this)
+						.then(res => next(null, res.ratings))
+						.catch(next);
+				},
+
+				(ratings, next) => {
+					next(null, {
+						likes: ratings.likes,
+						dislikes: ratings.dislikes
+					});
+				}
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"RATINGS_GET_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						likes,
+						dislikes
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets user's own ratings
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	getOwnRatings: isLoginRequired(async function getOwnRatings(session, youtubeId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(() => next())
+						.catch(next);
+				},
+
+				next =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Liked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Liked Songs' playlist does not exist.");
+
+							let isLiked = false;
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'liked songs' playlist
+								if (song.youtubeId === youtubeId) isLiked = true;
+							});
+
+							return next(null, isLiked);
+						}
+					),
+
+				(isLiked, next) =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Disliked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Disliked Songs' playlist does not exist.");
+
+							const ratings = { isLiked, isDisliked: false };
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'disliked songs' playlist
+								if (song.youtubeId === youtubeId) ratings.isDisliked = true;
+							});
+
+							return next(null, ratings);
+						}
+					)
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"RATINGS_GET_OWN_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { isLiked, isDisliked } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						youtubeId,
+						liked: isLiked,
+						disliked: isDisliked
+					}
+				});
+			}
+		);
+	})
+};

+ 0 - 740
backend/logic/actions/songs.js

@@ -10,7 +10,6 @@ const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 const CacheModule = moduleManager.modules.cache;
 const SongsModule = moduleManager.modules.songs;
-const ActivitiesModule = moduleManager.modules.activities;
 const PlaylistsModule = moduleManager.modules.playlists;
 const StationsModule = moduleManager.modules.stations;
 
@@ -40,114 +39,6 @@ CacheModule.runJob("SUB", {
 	}
 });
 
-CacheModule.runJob("SUB", {
-	channel: "song.like",
-	cb: data => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
-			args: [
-				"event:song.liked",
-				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
-				}
-			]
-		});
-
-		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
-			sockets.forEach(socket => {
-				socket.dispatch("event:song.ratings.updated", {
-					data: {
-						youtubeId: data.youtubeId,
-						liked: true,
-						disliked: false
-					}
-				});
-			});
-		});
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.dislike",
-	cb: data => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
-			args: [
-				"event:song.disliked",
-				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
-				}
-			]
-		});
-
-		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
-			sockets.forEach(socket => {
-				socket.dispatch("event:song.ratings.updated", {
-					data: {
-						youtubeId: data.youtubeId,
-						liked: false,
-						disliked: true
-					}
-				});
-			});
-		});
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.unlike",
-	cb: data => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
-			args: [
-				"event:song.unliked",
-				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
-				}
-			]
-		});
-
-		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
-			sockets.forEach(socket => {
-				socket.dispatch("event:song.ratings.updated", {
-					data: {
-						youtubeId: data.youtubeId,
-						liked: false,
-						disliked: false
-					}
-				});
-			});
-		});
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "song.undislike",
-	cb: data => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `song.${data.youtubeId}`,
-			args: [
-				"event:song.undisliked",
-				{
-					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
-				}
-			]
-		});
-
-		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
-			sockets.forEach(socket => {
-				socket.dispatch("event:song.ratings.updated", {
-					data: {
-						youtubeId: data.youtubeId,
-						liked: false,
-						disliked: false
-					}
-				});
-			});
-		});
-	}
-});
-
 export default {
 	/**
 	 * Returns the length of the songs list
@@ -342,41 +233,6 @@ export default {
 		);
 	}),
 
-	/**
-	 * Recalculates all song ratings
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param cb
-	 */
-	recalculateAllRatings: isAdminRequired(async function recalculateAllRatings(session, cb) {
-		async.waterfall(
-			[
-				next => {
-					SongsModule.runJob("RECALCULATE_ALL_SONG_RATINGS", {}, this)
-						.then(() => {
-							next();
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_RECALCULATE_ALL_RATINGS",
-						`Failed to recalculate all song ratings. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-				this.log("SUCCESS", "SONGS_RECALCULATE_ALL_RATINGS", `Recalculated all song ratings successfully.`);
-				return cb({ status: "success", message: "Successfully recalculated all song ratings." });
-			}
-		);
-	}),
-
 	/**
 	 * Gets a song from the Musare song id
 	 *
@@ -1106,602 +962,6 @@ export default {
 		);
 	}),
 
-	/**
-	 * Likes a song
-	 *
-	 * @param session
-	 * @param youtubeId - the youtube id
-	 * @param cb
-	 */
-	like: isLoginRequired(async function like(session, youtubeId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-
-		async.waterfall(
-			[
-				next => songModel.findOne({ youtubeId }, next),
-
-				(song, next) => {
-					if (!song) return next("No song found with that id.");
-					return next(null, song);
-				},
-
-				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
-
-				(song, user, next) => {
-					if (!user) return next("User does not exist.");
-
-					return this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
-								return next("Unable to remove song from the 'Disliked Songs' playlist.");
-							return next(null, song, user.likedSongsPlaylist);
-						})
-						.catch(err => next(err));
-				},
-
-				(song, likedSongsPlaylist, next) =>
-					this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "addSongToPlaylist",
-								args: [false, youtubeId, likedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error") {
-								if (res.message === "That song is already in the playlist")
-									return next("You have already liked this song.");
-								return next("Unable to add song to the 'Liked Songs' playlist.");
-							}
-
-							return next(null, song);
-						})
-						.catch(err => next(err)),
-
-				(song, next) => {
-					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
-						.then(ratings => next(null, song, ratings))
-						.catch(err => next(err));
-				}
-			],
-			async (err, song, ratings) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_LIKE",
-						`User "${session.userId}" failed to like song ${youtubeId}. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				const { likes, dislikes } = ratings;
-
-				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
-
-				CacheModule.runJob("PUB", {
-					channel: "song.like",
-					value: JSON.stringify({
-						youtubeId,
-						userId: session.userId,
-						likes,
-						dislikes
-					})
-				});
-
-				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: session.userId,
-					type: "song__like",
-					payload: {
-						message: `Liked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
-						youtubeId,
-						thumbnail: song.thumbnail
-					}
-				});
-
-				return cb({
-					status: "success",
-					message: "You have successfully liked this song."
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Dislikes a song
-	 *
-	 * @param session
-	 * @param youtubeId - the youtube id
-	 * @param cb
-	 */
-	dislike: isLoginRequired(async function dislike(session, youtubeId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					songModel.findOne({ youtubeId }, next);
-				},
-
-				(song, next) => {
-					if (!song) return next("No song found with that id.");
-					return next(null, song);
-				},
-
-				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
-
-				(song, user, next) => {
-					if (!user) return next("User does not exist.");
-
-					return this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.likedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
-								return next("Unable to remove song from the 'Liked Songs' playlist.");
-							return next(null, song, user.dislikedSongsPlaylist);
-						})
-						.catch(err => next(err));
-				},
-
-				(song, dislikedSongsPlaylist, next) =>
-					this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "addSongToPlaylist",
-								args: [false, youtubeId, dislikedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error") {
-								if (res.message === "That song is already in the playlist")
-									return next("You have already disliked this song.");
-								return next("Unable to add song to the 'Disliked Songs' playlist.");
-							}
-
-							return next(null, song);
-						})
-						.catch(err => next(err)),
-
-				(song, next) => {
-					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
-						.then(ratings => next(null, song, ratings))
-						.catch(err => next(err));
-				}
-			],
-			async (err, song, ratings) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_DISLIKE",
-						`User "${session.userId}" failed to dislike song ${youtubeId}. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				const { likes, dislikes } = ratings;
-
-				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
-
-				CacheModule.runJob("PUB", {
-					channel: "song.dislike",
-					value: JSON.stringify({
-						youtubeId,
-						userId: session.userId,
-						likes,
-						dislikes
-					})
-				});
-
-				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: session.userId,
-					type: "song__dislike",
-					payload: {
-						message: `Disliked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
-						youtubeId,
-						thumbnail: song.thumbnail
-					}
-				});
-
-				return cb({
-					status: "success",
-					message: "You have successfully disliked this song."
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Undislikes a song
-	 *
-	 * @param session
-	 * @param youtubeId - the youtube id
-	 * @param cb
-	 */
-	undislike: isLoginRequired(async function undislike(session, youtubeId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					songModel.findOne({ youtubeId }, next);
-				},
-
-				(song, next) => {
-					if (!song) return next("No song found with that id.");
-					return next(null, song);
-				},
-
-				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
-
-				(song, user, next) => {
-					if (!user) return next("User does not exist.");
-
-					return this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error")
-								return next("Unable to remove song from the 'Disliked Songs' playlist.");
-							return next(null, song, user.likedSongsPlaylist);
-						})
-						.catch(err => next(err));
-				},
-
-				(song, likedSongsPlaylist, next) => {
-					this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
-								return next("Unable to remove song from the 'Liked Songs' playlist.");
-							return next(null, song);
-						})
-						.catch(err => next(err));
-				},
-
-				(song, next) => {
-					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
-						.then(ratings => next(null, song, ratings))
-						.catch(err => next(err));
-				}
-			],
-			async (err, song, ratings) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_UNDISLIKE",
-						`User "${session.userId}" failed to undislike song ${youtubeId}. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				const { likes, dislikes } = ratings;
-
-				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
-
-				CacheModule.runJob("PUB", {
-					channel: "song.undislike",
-					value: JSON.stringify({
-						youtubeId,
-						userId: session.userId,
-						likes,
-						dislikes
-					})
-				});
-
-				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: session.userId,
-					type: "song__undislike",
-					payload: {
-						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
-							", "
-						)}</youtubeId> from your Disliked Songs`,
-						youtubeId,
-						thumbnail: song.thumbnail
-					}
-				});
-
-				return cb({
-					status: "success",
-					message: "You have successfully undisliked this song."
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Unlikes a song
-	 *
-	 * @param session
-	 * @param youtubeId - the youtube id
-	 * @param cb
-	 */
-	unlike: isLoginRequired(async function unlike(session, youtubeId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					songModel.findOne({ youtubeId }, next);
-				},
-
-				(song, next) => {
-					if (!song) return next("No song found with that id.");
-					return next(null, song);
-				},
-
-				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
-
-				(song, user, next) => {
-					if (!user) return next("User does not exist.");
-
-					return this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, user.dislikedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error" && res.message !== "Song wasn't in playlist.")
-								return next("Unable to remove song from the 'Disliked Songs' playlist.");
-							return next(null, song, user.likedSongsPlaylist);
-						})
-						.catch(err => next(err));
-				},
-
-				(song, likedSongsPlaylist, next) => {
-					this.module
-						.runJob(
-							"RUN_ACTION2",
-							{
-								session,
-								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
-							},
-							this
-						)
-						.then(res => {
-							if (res.status === "error")
-								return next("Unable to remove song from the 'Liked Songs' playlist.");
-							return next(null, song);
-						})
-						.catch(err => next(err));
-				},
-
-				(song, next) => {
-					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
-						.then(ratings => next(null, song, ratings))
-						.catch(err => next(err));
-				}
-			],
-			async (err, song, ratings) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_UNLIKE",
-						`User "${session.userId}" failed to unlike song ${youtubeId}. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				const { likes, dislikes } = ratings;
-
-				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
-
-				CacheModule.runJob("PUB", {
-					channel: "song.unlike",
-					value: JSON.stringify({
-						youtubeId,
-						userId: session.userId,
-						likes,
-						dislikes
-					})
-				});
-
-				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: session.userId,
-					type: "song__unlike",
-					payload: {
-						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
-							", "
-						)}</youtubeId> from your Liked Songs`,
-						youtubeId,
-						thumbnail: song.thumbnail
-					}
-				});
-
-				return cb({
-					status: "success",
-					message: "You have successfully unliked this song."
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Gets song ratings
-	 *
-	 * @param session
-	 * @param songId - the Musare song id
-	 * @param cb
-	 */
-
-	getSongRatings: isLoginRequired(async function getSongRatings(session, songId, cb) {
-		async.waterfall(
-			[
-				next => {
-					SongsModule.runJob("GET_SONG", { songId }, this)
-						.then(res => next(null, res.song))
-						.catch(next);
-				},
-
-				(song, next) => {
-					next(null, {
-						likes: song.likes,
-						dislikes: song.dislikes
-					});
-				}
-			],
-			async (err, ratings) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_GET_RATINGS",
-						`User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				const { likes, dislikes } = ratings;
-
-				return cb({
-					status: "success",
-					data: {
-						likes,
-						dislikes
-					}
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Gets user's own song ratings
-	 *
-	 * @param session
-	 * @param youtubeId - the youtube id
-	 * @param cb
-	 */
-	getOwnSongRatings: isLoginRequired(async function getOwnSongRatings(session, youtubeId, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-
-		async.waterfall(
-			[
-				next => songModel.findOne({ youtubeId }, next),
-				(song, next) => {
-					if (!song) return next("No song found with that id.");
-					return next(null);
-				},
-
-				next =>
-					playlistModel.findOne(
-						{ createdBy: session.userId, displayName: "Liked Songs" },
-						(err, playlist) => {
-							if (err) return next(err);
-							if (!playlist) return next("'Liked Songs' playlist does not exist.");
-
-							let isLiked = false;
-
-							Object.values(playlist.songs).forEach(song => {
-								// song is found in 'liked songs' playlist
-								if (song.youtubeId === youtubeId) isLiked = true;
-							});
-
-							return next(null, isLiked);
-						}
-					),
-
-				(isLiked, next) =>
-					playlistModel.findOne(
-						{ createdBy: session.userId, displayName: "Disliked Songs" },
-						(err, playlist) => {
-							if (err) return next(err);
-							if (!playlist) return next("'Disliked Songs' playlist does not exist.");
-
-							const ratings = { isLiked, isDisliked: false };
-
-							Object.values(playlist.songs).forEach(song => {
-								// song is found in 'disliked songs' playlist
-								if (song.youtubeId === youtubeId) ratings.isDisliked = true;
-							});
-
-							return next(null, ratings);
-						}
-					)
-			],
-			async (err, ratings) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_GET_OWN_RATINGS",
-						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				const { isLiked, isDisliked } = ratings;
-
-				return cb({
-					status: "success",
-					data: {
-						youtubeId,
-						liked: isLiked,
-						disliked: isDisliked
-					}
-				});
-			}
-		);
-	}),
-
 	/**
 	 * Gets a list of all genres
 	 *

+ 7 - 11
backend/logic/actions/users.js

@@ -17,9 +17,9 @@ const WSModule = moduleManager.modules.ws;
 const CacheModule = moduleManager.modules.cache;
 const MailModule = moduleManager.modules.mail;
 const PunishmentsModule = moduleManager.modules.punishments;
-const SongsModule = moduleManager.modules.songs;
 const ActivitiesModule = moduleManager.modules.activities;
 const PlaylistsModule = moduleManager.modules.playlists;
+const RatingsModule = moduleManager.modules.ratings;
 
 CacheModule.runJob("SUB", {
 	channel: "user.updatePreferences",
@@ -358,9 +358,7 @@ export default {
 				(playlist, next) => {
 					if (!playlist) return next();
 
-					playlist.songs.forEach(song =>
-						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
-					);
+					playlist.songs.forEach(song => songsToAdjustRatings.push({ youtubeId: song.youtubeId }));
 
 					return next();
 				},
@@ -374,9 +372,9 @@ export default {
 					async.each(
 						songsToAdjustRatings,
 						(song, next) => {
-							const { songId, youtubeId } = song;
+							const { youtubeId } = song;
 
-							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+							RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
 								.then(() => next())
 								.catch(next);
 						},
@@ -586,9 +584,7 @@ export default {
 				(playlist, next) => {
 					if (!playlist) return next();
 
-					playlist.songs.forEach(song =>
-						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
-					);
+					playlist.songs.forEach(song => songsToAdjustRatings.push({ youtubeId: song.youtubeId }));
 
 					return next();
 				},
@@ -602,9 +598,9 @@ export default {
 					async.each(
 						songsToAdjustRatings,
 						(song, next) => {
-							const { songId, youtubeId } = song;
+							const { youtubeId } = song;
 
-							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+							RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
 								.then(() => next())
 								.catch(next);
 						},

+ 2 - 1
backend/logic/cache/index.js

@@ -37,7 +37,8 @@ class _CacheModule extends CoreClass {
 			officialPlaylist: await importSchema("officialPlaylist"),
 			song: await importSchema("song"),
 			punishment: await importSchema("punishment"),
-			recentActivity: await importSchema("recentActivity")
+			recentActivity: await importSchema("recentActivity"),
+			ratings: await importSchema("ratings")
 		};
 
 		return new Promise((resolve, reject) => {

+ 1 - 0
backend/logic/cache/schemas/ratings.js

@@ -0,0 +1 @@
+export default ratings => ratings;

+ 9 - 4
backend/logic/db/index.js

@@ -12,11 +12,12 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	punishment: 1,
 	queueSong: 1,
 	report: 5,
-	song: 8,
+	song: 9,
 	station: 8,
 	user: 3,
 	youtubeApiRequest: 1,
-	youtubeVideo: 1
+	youtubeVideo: 1,
+	ratings: 1
 };
 
 const regex = {
@@ -72,7 +73,8 @@ class _DBModule extends CoreClass {
 						report: {},
 						punishment: {},
 						youtubeApiRequest: {},
-						youtubeVideo: {}
+						youtubeVideo: {},
+						ratings: {}
 					};
 
 					const importSchema = schemaName =>
@@ -95,6 +97,7 @@ class _DBModule extends CoreClass {
 					await importSchema("punishment");
 					await importSchema("youtubeApiRequest");
 					await importSchema("youtubeVideo");
+					await importSchema("ratings");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -108,7 +111,8 @@ class _DBModule extends CoreClass {
 						report: mongoose.model("report", this.schemas.report),
 						punishment: mongoose.model("punishment", this.schemas.punishment),
 						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),
-						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo)
+						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),
+						ratings: mongoose.model("ratings", this.schemas.ratings)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -252,6 +256,7 @@ class _DBModule extends CoreClass {
 					this.models.user.syncIndexes();
 					this.models.youtubeApiRequest.syncIndexes();
 					this.models.youtubeVideo.syncIndexes();
+					this.models.ratings.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 6 - 0
backend/logic/db/schemas/ratings.js

@@ -0,0 +1,6 @@
+export default {
+	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
+	likes: { type: Number, default: 0, required: true },
+	dislikes: { type: Number, default: 0, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 1 - 10
backend/logic/db/schemas/song.js

@@ -7,8 +7,6 @@ export default {
 	duration: { type: Number, min: 1, required: true },
 	skipDuration: { type: Number, required: true, default: 0 },
 	thumbnail: { type: String, trim: true },
-	likes: { type: Number, default: 0, required: true },
-	dislikes: { type: Number, default: 0, required: true },
 	explicit: { type: Boolean },
 	requestedBy: { type: String },
 	requestedAt: { type: Date },
@@ -16,12 +14,5 @@ export default {
 	verifiedBy: { type: String },
 	verifiedAt: { type: Date },
 	discogs: { type: Object },
-	// sources: [
-	// 	{
-	// 		type: { type: String, enum: ["youtube"], required: true },
-	// 		youtube: { type: mongoose.Schema.Types.ObjectId, ref: "youtubevideos" }
-	// 	}
-	// ],
-	documentVersion: { type: Number, default: 8, required: true }
-	// documentVersion: { type: Number, default: 9, required: true }
+	documentVersion: { type: Number, default: 9, required: true }
 };

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

@@ -67,7 +67,8 @@ class _MigrationModule extends CoreClass {
 						playlist: mongoose.model("playlist", new mongoose.Schema({}, { strict: false })),
 						news: mongoose.model("news", new mongoose.Schema({}, { strict: false })),
 						report: mongoose.model("report", new mongoose.Schema({}, { strict: false })),
-						punishment: mongoose.model("punishment", new mongoose.Schema({}, { strict: false }))
+						punishment: mongoose.model("punishment", new mongoose.Schema({}, { strict: false })),
+						ratings: mongoose.model("ratings", new mongoose.Schema({}, { strict: false }))
 					};
 
 					const files = fs.readdirSync(path.join(__dirname, "migrations"));

+ 101 - 0
backend/logic/migration/migrations/migration21.js

@@ -0,0 +1,101 @@
+import async from "async";
+
+/**
+ * Migration 21
+ *
+ * Migration for song ratings
+ *
+ * @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 ratingsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "ratings" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 21. Finding songs with document version 8.`);
+					songModel.find({ documentVersion: 8 }, { youtubeId: true }, next);
+				},
+
+				(songs, next) => {
+					async.eachLimit(
+						songs.map(song => song.youtubeId),
+						2,
+						(youtubeId, next) => {
+							async.waterfall(
+								[
+									next => {
+										playlistModel.countDocuments(
+											{ songs: { $elemMatch: { youtubeId } }, type: "user-liked" },
+											(err, likes) => {
+												if (err) return next(err);
+												return next(null, likes);
+											}
+										);
+									},
+
+									(likes, next) => {
+										playlistModel.countDocuments(
+											{ songs: { $elemMatch: { youtubeId } }, type: "user-disliked" },
+											(err, dislikes) => {
+												if (err) return next(err);
+												return next(err, { likes, dislikes });
+											}
+										);
+									},
+
+									({ likes, dislikes }, next) => {
+										ratingsModel.updateOne(
+											{ youtubeId },
+											{
+												$set: {
+													likes,
+													dislikes
+												}
+											},
+											{ upsert: true },
+											next
+										);
+									}
+								],
+								next
+							);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					songModel.updateMany(
+						{ documentVersion: 8 },
+						{
+							$set: { documentVersion: 9 },
+							$unset: { likes: true, dislikes: true }
+						},
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 21 (songs). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 258 - 0
backend/logic/ratings.js

@@ -0,0 +1,258 @@
+import async from "async";
+import CoreClass from "../core";
+
+let RatingsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+let YouTubeModule;
+let SongsModule;
+
+class _RatingsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("ratings");
+
+		RatingsModule = this;
+	}
+
+	/**
+	 * Initialises the ratings module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		YouTubeModule = this.moduleManager.modules.youtube;
+		SongsModule = this.moduleManager.modules.songs;
+
+		this.RatingsModel = await DBModule.runJob("GET_MODEL", { modelName: "ratings" });
+		this.RatingsSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "ratings" });
+
+		this.setStage(2);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "ratings" })
+							.then(ratings => {
+								next(null, ratings);
+							})
+							.catch(next);
+					},
+
+					(ratings, next) => {
+						this.setStage(3);
+
+						if (!ratings) return next();
+
+						const youtubeIds = Object.keys(ratings);
+
+						return async.each(
+							youtubeIds,
+							(youtubeId, next) => {
+								RatingsModule.RatingsModel.findOne({ youtubeId }, (err, rating) => {
+									if (err) next(err);
+									else if (!rating)
+										CacheModule.runJob("HDEL", {
+											table: "ratings",
+											key: youtubeId
+										})
+											.then(() => next())
+											.catch(next);
+									else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						RatingsModule.RatingsModel.find({}, next);
+					},
+
+					(ratings, next) => {
+						this.setStage(5);
+						async.each(
+							ratings,
+							(rating, next) => {
+								CacheModule.runJob("HSET", {
+									table: "ratings",
+									key: rating.youtubeId,
+									value: RatingsModule.RatingsSchemaCache(rating)
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err });
+						reject(new Error(err));
+					} else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Recalculates dislikes and likes
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async RECALCULATE_RATINGS(payload) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-liked" },
+							(err, likes) => {
+								if (err) return next(err);
+								return next(null, likes);
+							}
+						);
+					},
+
+					(likes, next) => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-disliked" },
+							(err, dislikes) => {
+								if (err) return next(err);
+								return next(err, { likes, dislikes });
+							}
+						);
+					},
+
+					({ likes, dislikes }, next) => {
+						RatingsModule.RatingsModel.updateOne(
+							{ youtubeId: payload.youtubeId },
+							{
+								$set: {
+									likes,
+									dislikes
+								}
+							},
+							{ upsert: true },
+							err => next(err, { likes, dislikes })
+						);
+					}
+				],
+				(err, { likes, dislikes }) => {
+					if (err) return reject(new Error(err));
+					return resolve({ likes, dislikes });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Recalculates all dislikes and likes
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	RECALCULATE_ALL_RATINGS() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({}, { youtubeId: true }, next);
+					},
+
+					(songs, next) => {
+						YouTubeModule.youtubeVideoModel.find({}, { youtubeId: true }, (err, videos) => {
+							if (err) next(err);
+							else
+								next(null, [
+									...songs.map(song => song.youtubeId),
+									...videos.map(video => video.youtubeId)
+								]);
+						});
+					},
+
+					(youtubeIds, next) => {
+						async.eachLimit(
+							youtubeIds,
+							2,
+							(youtubeId, next) => {
+								RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets ratings by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_RATINGS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next =>
+						CacheModule.runJob("HGET", { table: "ratings", key: payload.youtubeId }, this)
+							.then(ratings => next(null, ratings))
+							.catch(next),
+
+					(ratings, next) => {
+						if (ratings) return next(true, ratings);
+						return RatingsModule.RatingsModel.findOne({ youtubeId: payload.youtubeId }, next);
+					},
+
+					(ratings, next) => {
+						if (ratings) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "ratings",
+									key: payload.youtubeId,
+									value: ratings
+								},
+								this
+							).then(ratings => next(null, ratings));
+						} else next("Ratings not found.");
+					}
+				],
+				(err, ratings) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ ratings });
+				}
+			);
+		});
+	}
+}
+
+export default new _RatingsModule();

+ 0 - 95
backend/logic/songs.js

@@ -1016,101 +1016,6 @@ class _SongsModule extends CoreClass {
 		});
 	}
 
-	/**
-	 * Recalculates dislikes and likes for a song
-	 *
-	 * @param {object} payload - returns an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube id of the song
-	 * @param {string} payload.songId - the song id of the song
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	async RECALCULATE_SONG_RATINGS(payload) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-liked" },
-							(err, likes) => {
-								if (err) return next(err);
-								return next(null, likes);
-							}
-						);
-					},
-
-					(likes, next) => {
-						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-disliked" },
-							(err, dislikes) => {
-								if (err) return next(err);
-								return next(err, { likes, dislikes });
-							}
-						);
-					},
-
-					({ likes, dislikes }, next) => {
-						SongsModule.SongModel.updateOne(
-							{ _id: payload.songId },
-							{
-								$set: {
-									likes,
-									dislikes
-								}
-							},
-							err => next(err, { likes, dislikes })
-						);
-					}
-				],
-				(err, { likes, dislikes }) => {
-					if (err) return reject(new Error(err));
-					return resolve({ likes, dislikes });
-				}
-			);
-		});
-	}
-
-	/**
-	 * Recalculates dislikes and likes for all songs
-	 *
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	RECALCULATE_ALL_SONG_RATINGS() {
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.find({}, { _id: true }, next);
-					},
-
-					(songs, next) => {
-						async.eachLimit(
-							songs,
-							2,
-							(song, next) => {
-								SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id }, this)
-									.then(() => {
-										next();
-									})
-									.catch(err => {
-										next(err);
-									});
-							},
-							err => {
-								next(err);
-							}
-						);
-					}
-				],
-				err => {
-					if (err) return reject(new Error(err));
-					return resolve();
-				}
-			);
-		});
-	}
-
 	/**
 	 * Gets an array of all genres
 	 *

+ 2 - 56
frontend/src/pages/Admin/Songs/index.vue

@@ -107,16 +107,6 @@
 					slotProps.item.tags.join(", ")
 				}}</span>
 			</template>
-			<template #column-likes="slotProps">
-				<span :title="slotProps.item.likes">{{
-					slotProps.item.likes
-				}}</span>
-			</template>
-			<template #column-dislikes="slotProps">
-				<span :title="slotProps.item.dislikes">{{
-					slotProps.item.dislikes
-				}}</span>
-			</template>
 			<template #column-_id="slotProps">
 				<span :title="slotProps.item._id">{{
 					slotProps.item._id
@@ -315,24 +305,6 @@ export default {
 					properties: ["tags"],
 					sortable: false
 				},
-				{
-					name: "likes",
-					displayName: "Likes",
-					properties: ["likes"],
-					sortProperty: "likes",
-					minWidth: 100,
-					defaultWidth: 100,
-					defaultVisibility: "hidden"
-				},
-				{
-					name: "dislikes",
-					displayName: "Dislikes",
-					properties: ["dislikes"],
-					sortProperty: "dislikes",
-					minWidth: 100,
-					defaultWidth: 100,
-					defaultVisibility: "hidden"
-				},
 				{
 					name: "_id",
 					displayName: "Song ID",
@@ -504,32 +476,6 @@ export default {
 					filterTypes: ["boolean"],
 					defaultFilterType: "boolean"
 				},
-				{
-					name: "likes",
-					displayName: "Likes",
-					property: "likes",
-					filterTypes: [
-						"numberLesserEqual",
-						"numberLesser",
-						"numberGreater",
-						"numberGreaterEqual",
-						"numberEquals"
-					],
-					defaultFilterType: "numberLesser"
-				},
-				{
-					name: "dislikes",
-					displayName: "Dislikes",
-					property: "dislikes",
-					filterTypes: [
-						"numberLesserEqual",
-						"numberLesser",
-						"numberGreater",
-						"numberGreaterEqual",
-						"numberEquals"
-					],
-					defaultFilterType: "numberLesser"
-				},
 				{
 					name: "duration",
 					displayName: "Duration",
@@ -575,8 +521,8 @@ export default {
 					socket: "songs.updateAll"
 				},
 				{
-					name: "Recalculate all song ratings",
-					socket: "songs.recalculateAllRatings"
+					name: "Recalculate all ratings",
+					socket: "ratings.recalculateAll"
 				}
 			]
 		};

+ 13 - 2
frontend/src/pages/Admin/YouTube/Videos.vue

@@ -6,6 +6,9 @@
 				<h1>YouTube Videos</h1>
 				<p>Manage YouTube video cache</p>
 			</div>
+			<div class="button-row">
+				<run-job-dropdown :jobs="jobs" />
+			</div>
 		</div>
 		<advanced-table
 			:column-default="columnDefault"
@@ -130,10 +133,12 @@ import { mapActions, mapGetters } from "vuex";
 import Toast from "toasters";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
 
 export default {
 	components: {
-		AdvancedTable
+		AdvancedTable,
+		RunJobDropdown
 	},
 	data() {
 		return {
@@ -272,7 +277,13 @@ export default {
 					event: "admin.youtubeVideo.removed",
 					id: "videoId"
 				}
-			}
+			},
+			jobs: [
+				{
+					name: "Recalculate all ratings",
+					socket: "ratings.recalculateAll"
+				}
+			]
 		};
 	},
 	computed: {

+ 12 - 12
frontend/src/pages/Station/index.vue

@@ -978,7 +978,7 @@ export default {
 			return true;
 		});
 
-		this.socket.on("event:song.liked", res => {
+		this.socket.on("event:ratings.liked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
@@ -986,7 +986,7 @@ export default {
 			}
 		});
 
-		this.socket.on("event:song.disliked", res => {
+		this.socket.on("event:ratings.disliked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
@@ -994,7 +994,7 @@ export default {
 			}
 		});
 
-		this.socket.on("event:song.unliked", res => {
+		this.socket.on("event:ratings.unliked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
@@ -1002,7 +1002,7 @@ export default {
 			}
 		});
 
-		this.socket.on("event:song.undisliked", res => {
+		this.socket.on("event:ratings.undisliked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
@@ -1010,7 +1010,7 @@ export default {
 			}
 		});
 
-		this.socket.on("event:song.ratings.updated", res => {
+		this.socket.on("event:ratings.updated", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateOwnCurrentSongRatings(res.data);
@@ -1364,8 +1364,8 @@ export default {
 				);
 
 				this.socket.dispatch(
-					"songs.getSongRatings",
-					currentSong._id,
+					"ratings.getRatings",
+					currentSong.youtubeId,
 					res => {
 						if (currentSong._id === this.currentSong._id) {
 							const { likes, dislikes } = res.data;
@@ -1376,7 +1376,7 @@ export default {
 
 				if (this.loggedIn) {
 					this.socket.dispatch(
-						"songs.getOwnSongRatings",
+						"ratings.getOwnRatings",
 						currentSong.youtubeId,
 						res => {
 							console.log("getOwnSongRatings", res);
@@ -1794,7 +1794,7 @@ export default {
 		toggleLike() {
 			if (this.currentSong.liked)
 				this.socket.dispatch(
-					"songs.unlike",
+					"ratings.unlike",
 					this.currentSong.youtubeId,
 					res => {
 						if (res.status !== "success")
@@ -1803,7 +1803,7 @@ export default {
 				);
 			else
 				this.socket.dispatch(
-					"songs.like",
+					"ratings.like",
 					this.currentSong.youtubeId,
 					res => {
 						if (res.status !== "success")
@@ -1814,7 +1814,7 @@ export default {
 		toggleDislike() {
 			if (this.currentSong.disliked)
 				return this.socket.dispatch(
-					"songs.undislike",
+					"ratings.undislike",
 					this.currentSong.youtubeId,
 					res => {
 						if (res.status !== "success")
@@ -1823,7 +1823,7 @@ export default {
 				);
 
 			return this.socket.dispatch(
-				"songs.dislike",
+				"ratings.dislike",
 				this.currentSong.youtubeId,
 				res => {
 					if (res.status !== "success")