Procházet zdrojové kódy

refactor: Continued youtube video removal handling improvements

Owen Diffey před 2 roky
rodič
revize
41b497b1aa

+ 13 - 124
backend/logic/actions/playlists.js

@@ -10,12 +10,10 @@ const DBModule = moduleManager.modules.db;
 const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 const SongsModule = moduleManager.modules.songs;
-const StationsModule = moduleManager.modules.stations;
 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",
@@ -1111,17 +1109,6 @@ export default {
 						.catch(next);
 				},
 
-				(playlist, next) => {
-					async.each(
-						playlist.songs,
-						(song, nextSong) => {
-							if (song.youtubeId === youtubeId) return next("That song is already in the playlist");
-							return nextSong();
-						},
-						err => next(err, playlist)
-					);
-				},
-
 				(playlist, next) => {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
 						const oppositeType = playlist.type === "user-liked" ? "user-disliked" : "user-liked";
@@ -1141,65 +1128,12 @@ export default {
 				},
 
 				next => {
-					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-						.then(UserModel => {
-							UserModel.findOne(
-								{ _id: session.userId },
-								{ "preferences.anonymousSongRequests": 1 },
-								next
-							);
-						})
-						.catch(next);
-				},
-
-				(user, next) => {
-					SongsModule.runJob(
-						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
-						{
-							youtubeId,
-							userId: user.preferences.anonymousSongRequests ? null : session.userId,
-							automaticallyRequested: true
-						},
-						this
-					)
-						.then(response => {
-							const { song } = response;
-							const { _id, title, artists, thumbnail, duration, verified } = song;
-							next(null, {
-								_id,
-								youtubeId,
-								title,
-								artists,
-								thumbnail,
-								duration,
-								verified
-							});
+					PlaylistsModule.runJob("ADD_SONG_TO_PLAYLIST", { playlistId, youtubeId }, this)
+						.then(res => {
+							const { playlist, song, ratings } = res;
+							next(null, playlist, song, ratings);
 						})
 						.catch(next);
-				},
-				(newSong, next) => {
-					playlistModel.updateOne(
-						{ _id: playlistId },
-						{ $push: { songs: newSong } },
-						{ runValidators: true },
-						err => {
-							if (err) return next(err);
-							return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-								.then(playlist => next(null, playlist, newSong))
-								.catch(next);
-						}
-					);
-				},
-				(playlist, newSong, next) => {
-					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-						RatingsModule.runJob("RECALCULATE_RATINGS", {
-							youtubeId: newSong.youtubeId
-						})
-							.then(ratings => next(null, playlist, newSong, ratings))
-							.catch(next);
-					} else {
-						next(null, playlist, newSong, null);
-					}
 				}
 			],
 			async (err, playlist, newSong, ratings) => {
@@ -1236,14 +1170,6 @@ export default {
 					});
 				}
 
-				StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
-					.then(response => {
-						response.stationIds.forEach(stationId => {
-							PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
-						});
-					})
-					.catch();
-
 				CacheModule.runJob("PUB", {
 					channel: "playlist.addSong",
 					value: {
@@ -1484,8 +1410,6 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	removeSongFromPlaylist: isLoginRequired(async function removeSongFromPlaylist(session, youtubeId, playlistId, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
 		async.waterfall(
 			[
 				next => {
@@ -1500,38 +1424,18 @@ export default {
 							if (!playlist || playlist.createdBy !== session.userId) {
 								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
 									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user && user.role === "admin") return next();
+										if (user && user.role === "admin") return next(null, playlist);
 										return next("Something went wrong when trying to get the playlist");
 									});
 								});
 							}
-							return next();
+							return next(null, playlist);
 						})
 						.catch(next);
 				},
 
-				// remove song from playlist
-				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { youtubeId } } }, next),
-
-				// update cache representation of the playlist
-				(res, next) => {
-					if (res.modifiedCount === 1)
-						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-							.then(playlist => next(null, playlist))
-							.catch(next);
-					else next("Song wasn't in playlist.");
-				},
-
 				(playlist, next) => {
-					StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
-						.then(response => {
-							response.stationIds.forEach(stationId => {
-								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
-							});
-						})
-						.catch();
-
-					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
+					SongsModule.runJob("ENSURE_SONG_EXISTS_BY_YOUTUBE_ID", { youtubeId }, this)
 						.then(res =>
 							next(null, playlist, {
 								_id: res.song._id,
@@ -1541,26 +1445,16 @@ export default {
 								youtubeId: res.song.youtubeId
 							})
 						)
-						.catch(() => {
-							YouTubeModule.runJob("GET_VIDEO", { identifier: youtubeId, createMissing: true }, this)
-								.then(response => {
-									const { youtubeId, title, author, duration } = response.video;
-									next(null, playlist, { youtubeId, title, artists: [author], duration });
-								})
-								.catch(next);
-						});
+						.catch(next);
 				},
 
 				(playlist, newSong, next) => {
-					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-						RatingsModule.runJob("RECALCULATE_RATINGS", {
-							youtubeId: newSong.youtubeId
+					PlaylistsModule.runJob("REMOVE_FROM_PLAYLIST", { playlistId, youtubeId }, this)
+						.then(res => {
+							const { ratings } = res;
+							next(null, playlist, newSong, ratings);
 						})
-							.then(ratings => next(null, playlist, newSong, ratings))
-							.catch(next);
-					} else {
-						next(null, playlist, newSong, null);
-					}
+						.catch(next);
 				},
 
 				(playlist, newSong, ratings, next) => {
@@ -1662,11 +1556,6 @@ export default {
 					}
 				});
 
-				CacheModule.runJob("PUB", {
-					channel: "playlist.updated",
-					value: { playlistId }
-				});
-
 				return cb({
 					status: "success",
 					message: "Song has been successfully removed from playlist",

+ 9 - 17
backend/logic/actions/songs.js

@@ -471,27 +471,19 @@ export default {
 								stations,
 								1,
 								(station, next) => {
-									WSModule.runJob(
-										"RUN_ACTION2",
-										{
-											session,
-											namespace: "stations",
-											action: "removeFromQueue",
-											args: [station._id, song.youtubeId]
-										},
+									StationsModule.runJob(
+										"REMOVE_FROM_QUEUE",
+										{ stationId: station._id, youtubeId: song.youtubeId },
 										this
 									)
-										.then(res => {
+										.then(() => next())
+										.catch(err => {
 											if (
-												res.status === "error" &&
-												res.message !== "Station not found" &&
-												res.message !== "Song is not currently in the queue."
+												err === "Station not found" ||
+												err === "Song is not currently in the queue."
 											)
-												next(res.message);
-											else next();
-										})
-										.catch(err => {
-											next(err);
+												next();
+											else next(err);
 										});
 								},
 								err => {

+ 11 - 250
backend/logic/actions/stations.js

@@ -10,7 +10,6 @@ import moduleManager from "../../index";
 const DBModule = moduleManager.modules.db;
 const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
-const SongsModule = moduleManager.modules.songs;
 const PlaylistsModule = moduleManager.modules.playlists;
 const CacheModule = moduleManager.modules.cache;
 const NotificationsModule = moduleManager.modules.notifications;
@@ -878,26 +877,7 @@ export default {
 
 					data.currentSong.skipVotes = data.currentSong.skipVotes.length;
 
-					return SongsModule.runJob(
-						"GET_SONG_FROM_YOUTUBE_ID",
-						{ youtubeId: data.currentSong.youtubeId },
-						this
-					)
-						.then(response => {
-							const { song } = response;
-							if (song) {
-								data.currentSong.likes = song.likes;
-								data.currentSong.dislikes = song.dislikes;
-							} else {
-								data.currentSong.likes = -1;
-								data.currentSong.dislikes = -1;
-							}
-						})
-						.catch(() => {
-							data.currentSong.likes = -1;
-							data.currentSong.dislikes = -1;
-						})
-						.finally(() => next(null, data));
+					return next(null, data);
 				},
 
 				(data, next) => {
@@ -1825,14 +1805,6 @@ export default {
 	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
-		const stationModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "station"
-			},
-			this
-		);
-
 		async.waterfall(
 			[
 				next => {
@@ -1872,183 +1844,23 @@ export default {
 						this
 					)
 						.then(canView => {
-							if (canView) return next(null, station);
+							if (canView) return next();
 							return next("Insufficient permissions.");
 						})
 						.catch(err => next(err)),
 
-				(station, next) => {
-					if (station.currentSong && station.currentSong.youtubeId === youtubeId)
-						return next("That song is currently playing.");
-
-					return async.each(
-						station.queue,
-						(queueSong, next) => {
-							if (queueSong.youtubeId === youtubeId) return next("That song is already in the queue.");
-							return next();
-						},
-						err => next(err, station)
-					);
-				},
-
-				(station, next) => {
-					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-						.then(UserModel => {
-							UserModel.findOne(
-								{ _id: session.userId },
-								{ "preferences.anonymousSongRequests": 1 },
-								(err, user) => next(err, station, user)
-							);
-						})
-						.catch(next);
-				},
-
-				(station, user, next) => {
-					SongsModule.runJob(
-						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+				next =>
+					StationsModule.runJob(
+						"ADD_TO_QUEUE",
 						{
+							stationId,
 							youtubeId,
-							userId: user.preferences.anonymousSongRequests ? null : session.userId,
-							automaticallyRequested: true
+							requestUser: session.userId
 						},
 						this
 					)
-						.then(response => {
-							const { song } = response;
-							const { _id, title, skipDuration, artists, thumbnail, duration, verified } = song;
-							next(
-								null,
-								{
-									_id,
-									youtubeId,
-									title,
-									skipDuration,
-									artists,
-									thumbnail,
-									duration,
-									verified
-								},
-								station
-							);
-						})
-						.catch(next);
-				},
-
-				(song, station, next) => {
-					const blacklist = [];
-					async.eachLimit(
-						station.blacklist,
-						1,
-						(playlistId, next) => {
-							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-								.then(playlist => {
-									blacklist.push(playlist);
-									next();
-								})
-								.catch(next);
-						},
-						err => {
-							next(err, song, station, blacklist);
-						}
-					);
-				},
-
-				(song, station, blacklist, next) => {
-					const blacklistedSongs = blacklist
-						.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
-						.reduce(
-							(items, item) =>
-								items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
-							[]
-						);
-
-					if (blacklistedSongs.find(blacklistedSong => blacklistedSong.youtubeId === song.youtubeId))
-						next("That song is in an blacklisted playlist and cannot be played.");
-					else next(null, song, station);
-				},
-
-				(song, station, next) => {
-					song.requestedBy = session.userId;
-					song.requestedAt = Date.now();
-					return next(null, song, station);
-				},
-
-				(song, station, next) => {
-					if (station.queue.length === 0) return next(null, song);
-					let totalSongs = 0;
-					station.queue.forEach(song => {
-						if (session.userId === song.requestedBy) {
-							totalSongs += 1;
-						}
-					});
-
-					if (totalSongs >= station.requests.limit)
-						return next(`The max amount of songs per user is ${station.requests.limit}.`);
-
-					return next(null, song);
-				},
-
-				// (song, station, next) => {
-				// 	song.requestedBy = session.userId;
-				// 	song.requestedAt = Date.now();
-				// 	let totalDuration = 0;
-				// 	station.queue.forEach(song => {
-				// 		totalDuration += song.duration;
-				// 	});
-				// 	if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
-				// 	return next(null, song, station);
-				// },
-
-				// (song, station, next) => {
-				// 	if (station.queue.length === 0) return next(null, song, station);
-				// 	let totalDuration = 0;
-				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
-				// 	station.queue.forEach(song => {
-				// 		if (userId === song.requestedBy) {
-				// 			totalDuration += song.duration;
-				// 		}
-				// 	});
-
-				// 	if (totalDuration >= 900) return next("The max length of songs per user is 15 minutes.");
-				// 	return next(null, song, station);
-				// },
-
-				// (song, station, next) => {
-				// 	if (station.queue.length === 0) return next(null, song);
-				// 	let totalSongs = 0;
-				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
-				// 	station.queue.forEach(song => {
-				// 		if (userId === song.requestedBy) {
-				// 			totalSongs += 1;
-				// 		}
-				// 	});
-
-				// 	if (totalSongs <= 2) return next(null, song);
-				// 	if (totalSongs > 3)
-				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
-				// 	if (
-				// 		station.queue[station.queue.length - 2].requestedBy !== userId ||
-				// 		station.queue[station.queue.length - 3] !== userId
-				// 	)
-				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
-
-				// 	return next(null, song);
-				// },
-
-				(song, next) => {
-					stationModel.updateOne(
-						{ _id: stationId },
-						{ $push: { queue: song } },
-						{ runValidators: true },
-						next
-					);
-				},
-
-				(res, next) => {
-					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
-						.then(station => next(null, station))
-						.catch(next);
-				}
+						.then(() => next())
+						.catch(next)
 			],
 			async err => {
 				if (err) {
@@ -2067,11 +1879,6 @@ export default {
 					`Added song "${youtubeId}" to station "${stationId}" successfully.`
 				);
 
-				CacheModule.runJob("PUB", {
-					channel: "station.queueUpdate",
-					value: stationId
-				});
-
 				return cb({
 					status: "success",
 					message: "Successfully added song to queue."
@@ -2089,53 +1896,12 @@ export default {
 	 * @param cb
 	 */
 	removeFromQueue: isOwnerRequired(async function removeFromQueue(session, stationId, youtubeId, cb) {
-		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
-
 		async.waterfall(
 			[
 				next => {
 					if (!youtubeId) return next("Invalid youtube id.");
-					return StationsModule.runJob("GET_STATION", { stationId }, this)
-						.then(station => next(null, station))
-						.catch(next);
-				},
-
-				(station, next) => {
-					if (!station) return next("Station not found.");
-
-					return async.each(
-						station.queue,
-						(queueSong, next) => {
-							if (queueSong.youtubeId === youtubeId) return next(true);
-							return next();
-						},
-						err => {
-							if (err === true) return next();
-							return next("Song is not currently in the queue.");
-						}
-					);
-				},
-
-				next => {
-					stationModel.updateOne({ _id: stationId }, { $pull: { queue: { youtubeId } } }, next);
-				},
-
-				(res, next) => {
-					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
-						.then(station => {
-							if (station.autofill.enabled)
-								StationsModule.runJob("AUTOFILL_STATION", { stationId }, this)
-									.then(() => next(null, station))
-									.catch(err => {
-										if (
-											err === "Autofill is disabled in this station" ||
-											err === "Autofill limit reached"
-										)
-											return next(null, station);
-										return next(err);
-									});
-							else next(null, station);
-						})
+					return StationsModule.runJob("REMOVE_FROM_QUEUE", { stationId, youtubeId }, this)
+						.then(() => next())
 						.catch(next);
 				}
 			],
@@ -2156,11 +1922,6 @@ export default {
 					`Removed song "${youtubeId}" from station "${stationId}" successfully.`
 				);
 
-				CacheModule.runJob("PUB", {
-					channel: "station.queueUpdate",
-					value: stationId
-				});
-
 				return cb({
 					status: "success",
 					message: "Successfully removed song from queue."

+ 203 - 23
backend/logic/playlists.js

@@ -8,6 +8,8 @@ let SongsModule;
 let CacheModule;
 let DBModule;
 let UtilsModule;
+let RatingsModule;
+let WSModule;
 
 class _PlaylistsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -30,10 +32,39 @@ class _PlaylistsModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
 		SongsModule = this.moduleManager.modules.songs;
+		RatingsModule = this.moduleManager.modules.ratings;
+		WSModule = this.moduleManager.modules.ws;
 
 		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
 		this.playlistSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "playlist" });
 
+		CacheModule.runJob("SUB", {
+			channel: "playlist.updated",
+			cb: async data => {
+				PlaylistsModule.playlistModel.findOne(
+					{ _id: data.playlistId },
+					["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
+					(err, playlist) => {
+						const newPlaylist = {
+							...playlist._doc,
+							songsCount: playlist.songs.length,
+							songsLength: playlist.songs.reduce(
+								(previous, current) => ({
+									duration: previous.duration + current.duration
+								}),
+								{ duration: 0 }
+							).duration
+						};
+						delete newPlaylist.songs;
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: "admin.playlists",
+							args: ["event:admin.playlist.updated", { data: { playlist: newPlaylist } }]
+						});
+					}
+				);
+			}
+		});
+
 		this.setStage(2);
 
 		return new Promise((resolve, reject) => {
@@ -351,35 +382,184 @@ class _PlaylistsModule extends CoreClass {
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
-	 * @param {string} payload.song - the song
+	 * @param {string} payload.youtubeId - the youtube id
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	ADD_SONG_TO_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
-			const { _id, youtubeId, title, artists, thumbnail, duration, verified } = payload.song;
-			const trimmedSong = {
-				_id,
-				youtubeId,
-				title,
-				artists,
-				thumbnail,
-				duration,
-				verified
-			};
+			const { playlistId, youtubeId } = payload;
 
-			PlaylistsModule.playlistModel.updateOne(
-				{ _id: payload.playlistId },
-				{ $push: { songs: trimmedSong } },
-				{ runValidators: true },
-				err => {
-					if (err) reject(new Error(err));
-					else {
-						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
-							.then(() => resolve())
-							.catch(err => {
-								reject(new Error(err));
-							});
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) return next("Playlist not found.");
+						if (playlist.songs.find(song => song.youtubeId === youtubeId))
+							return next("That song is already in the playlist.");
+						return next();
+					},
+
+					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);
+					},
+
+					(newSong, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: playlistId },
+							{ $push: { songs: newSong } },
+							{ runValidators: true },
+							err => {
+								if (err) return next(err);
+								return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+									.then(playlist => next(null, playlist, newSong))
+									.catch(next);
+							}
+						);
+					},
+
+					(playlist, newSong, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
+							.then(response => {
+								async.each(
+									response.stationIds,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
+											.then()
+											.catch();
+										next();
+									},
+									err => {
+										if (err) next(err);
+										else next(null, playlist, newSong);
+									}
+								);
+							})
+							.catch(next);
+					},
+
+					(playlist, newSong, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							RatingsModule.runJob("RECALCULATE_RATINGS", {
+								youtubeId: newSong.youtubeId
+							})
+								.then(ratings => next(null, playlist, newSong, ratings))
+								.catch(next);
+						} else {
+							next(null, playlist, newSong, null);
+						}
 					}
+				],
+				(err, playlist, song, ratings) => {
+					if (err) reject(err);
+					else resolve({ playlist, song, ratings });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove from playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FROM_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId, youtubeId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) return next("Playlist not found.");
+						if (!playlist.songs.find(song => song.youtubeId === youtubeId))
+							return next("That song is not currently in the playlist.");
+
+						return PlaylistsModule.playlistModel.updateOne(
+							{ _id: playlistId },
+							{ $pull: { songs: { youtubeId } } },
+							next
+						);
+					},
+
+					(res, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
+							.then(response => {
+								async.each(
+									response.stationIds,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
+											.then()
+											.catch();
+										next();
+									},
+									err => {
+										if (err) next(err);
+										else next(null, playlist);
+									}
+								);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+								.then(ratings => next(null, playlist, ratings))
+								.catch(next);
+						} else next(null, playlist, null);
+					},
+
+					(playlist, ratings, next) =>
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "playlist.updated",
+								value: { playlistId }
+							},
+							this
+						)
+							.then(() => next(null, playlist, ratings))
+							.catch(next)
+				],
+				(err, playlist, ratings) => {
+					if (err) reject(err);
+					else resolve({ playlist, ratings });
 				}
 			);
 		});

+ 0 - 23
backend/logic/songs.js

@@ -255,29 +255,6 @@ class _SongsModule extends CoreClass {
 		});
 	}
 
-	/**
-	 * Gets a song by youtube id
-	 *
-	 * @param {object} payload - an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube id of the song we are trying to get
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	GET_SONG_FROM_YOUTUBE_ID(payload) {
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
-					}
-				],
-				(err, song) => {
-					if (err && err !== true) return reject(new Error(err));
-					return resolve({ song });
-				}
-			);
-		});
-	}
-
 	/**
 	 * Create song
 	 *

+ 315 - 77
backend/logic/stations.js

@@ -534,25 +534,40 @@ class _StationsModule extends CoreClass {
 					},
 
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
-						SongsModule.runJob("GET_SONGS", {
-							songIds: songsToAdd.map(song => song._id),
-							properties: [
-								"youtubeId",
-								"title",
-								"duration",
-								"skipDuration",
-								"artists",
-								"thumbnail",
-								"verified"
-							]
-						})
-							.then(response => {
-								const newSongsToAdd = songsToAdd.map(song =>
-									response.songs.find(newSong => newSong._id.toString() === song._id.toString())
-								);
-								next(null, currentSongs, newSongsToAdd, currentSongIndex);
-							})
-							.catch(err => next(err));
+						const songs = [];
+						async.eachLimit(
+							songsToAdd.map(song => song.youtubeId),
+							2,
+							(youtubeId, next) => {
+								SongsModule.runJob("ENSURE_SONG_EXISTS_BY_YOUTUBE_ID", { youtubeId }, this)
+									.then(response => {
+										const { song } = response;
+										const { _id, title, artists, thumbnail, duration, skipDuration, verified } =
+											song;
+										songs.push({
+											_id,
+											youtubeId,
+											title,
+											artists,
+											thumbnail,
+											duration,
+											skipDuration,
+											verified
+										});
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else {
+									const newSongsToAdd = songsToAdd.map(song =>
+										songs.find(newSong => newSong._id.toString() === song._id.toString())
+									);
+									next(null, currentSongs, newSongsToAdd, currentSongIndex);
+								}
+							}
+						);
 					},
 
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
@@ -893,25 +908,23 @@ class _StationsModule extends CoreClass {
 						$set.startedAt = Date.now();
 						$set.timePaused = 0;
 						if (station.paused) $set.pausedAt = Date.now();
-						next(null, $set, song, station);
+						next(null, $set, station);
 					},
 
-					($set, song, station, next) => {
+					($set, station, next) => {
 						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
 							if (err) return next(err);
 
 							return StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
 								.then(station => {
-									next(null, station, song);
+									next(null, station);
 								})
 								.catch(next);
 						});
 					},
 
-					(station, song, next) => {
+					(station, next) => {
 						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
-							station.currentSong.likes = song.likes;
-							station.currentSong.dislikes = song.dislikes;
 							station.currentSong.skipVotes = 0;
 						}
 						next(null, station);
@@ -1297,19 +1310,11 @@ class _StationsModule extends CoreClass {
 					},
 
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $push: { "autofill.playlists": payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $push: { "autofill.playlists": payload.playlistId } },
+							next
+						);
 					},
 
 					(res, next) => {
@@ -1371,19 +1376,11 @@ class _StationsModule extends CoreClass {
 					},
 
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $pull: { "autofill.playlists": payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $pull: { "autofill.playlists": payload.playlistId } },
+							next
+						);
 					},
 
 					(res, next) => {
@@ -1455,19 +1452,11 @@ class _StationsModule extends CoreClass {
 					},
 
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $push: { blacklist: payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $push: { blacklist: payload.playlistId } },
+							next
+						);
 					},
 
 					(res, next) => {
@@ -1529,19 +1518,11 @@ class _StationsModule extends CoreClass {
 					},
 
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $pull: { blacklist: payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $pull: { blacklist: payload.playlistId } },
+							next
+						);
 					},
 
 					(res, next) => {
@@ -1751,6 +1732,263 @@ class _StationsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Add to a station queue
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.requestUser - the requesting user id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	ADD_TO_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, youtubeId, requestUser } = payload;
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						if (!station.requests.enabled) return next("Requests are disabled in this station.");
+						if (station.currentSong && station.currentSong.youtubeId === youtubeId)
+							return next("That song is currently playing.");
+						if (station.queue.find(song => song.youtubeId === youtubeId))
+							return next("That song is already in the queue.");
+
+						return next(null, station);
+					},
+
+					(station, next) => {
+						SongsModule.runJob("ENSURE_SONG_EXISTS_BY_YOUTUBE_ID", { youtubeId }, this)
+							.then(response => {
+								const { song } = response;
+								const { _id, title, skipDuration, artists, thumbnail, duration, verified } = song;
+								next(
+									null,
+									{
+										_id,
+										youtubeId,
+										title,
+										skipDuration,
+										artists,
+										thumbnail,
+										duration,
+										verified
+									},
+									station
+								);
+							})
+							.catch(next);
+					},
+
+					(song, station, next) => {
+						const blacklist = [];
+						async.eachLimit(
+							station.blacklist,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										blacklist.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, song, station, blacklist);
+							}
+						);
+					},
+
+					(song, station, blacklist, next) => {
+						const blacklistedSongs = blacklist
+							.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
+							.reduce(
+								(items, item) =>
+									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+								[]
+							);
+
+						if (blacklistedSongs.find(blacklistedSong => blacklistedSong.youtubeId === song.youtubeId))
+							next("That song is in an blacklisted playlist and cannot be played.");
+						else next(null, song, station);
+					},
+
+					(song, station, next) => {
+						song.requestedBy = requestUser;
+						song.requestedAt = Date.now();
+						if (station.queue.length === 0) return next(null, song);
+						if (
+							requestUser &&
+							station.queue.filter(queueSong => queueSong.requestedBy === song.requestedBy).length >=
+								station.requests.limit
+						)
+							return next(`The max amount of songs per user is ${station.requests.limit}.`);
+						return next(null, song);
+					},
+
+					// (song, station, next) => {
+					// 	song.requestedBy = session.userId;
+					// 	song.requestedAt = Date.now();
+					// 	let totalDuration = 0;
+					// 	station.queue.forEach(song => {
+					// 		totalDuration += song.duration;
+					// 	});
+					// 	if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
+					// 	return next(null, song, station);
+					// },
+
+					// (song, station, next) => {
+					// 	if (station.queue.length === 0) return next(null, song, station);
+					// 	let totalDuration = 0;
+					// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+					// 	station.queue.forEach(song => {
+					// 		if (userId === song.requestedBy) {
+					// 			totalDuration += song.duration;
+					// 		}
+					// 	});
+
+					// 	if (totalDuration >= 900) return next("The max length of songs per user is 15 minutes.");
+					// 	return next(null, song, station);
+					// },
+
+					// (song, station, next) => {
+					// 	if (station.queue.length === 0) return next(null, song);
+					// 	let totalSongs = 0;
+					// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+					// 	station.queue.forEach(song => {
+					// 		if (userId === song.requestedBy) {
+					// 			totalSongs += 1;
+					// 		}
+					// 	});
+
+					// 	if (totalSongs <= 2) return next(null, song);
+					// 	if (totalSongs > 3)
+					// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
+					// 	if (
+					// 		station.queue[station.queue.length - 2].requestedBy !== userId ||
+					// 		station.queue[station.queue.length - 3] !== userId
+					// 	)
+					// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
+
+					// 	return next(null, song);
+					// },
+
+					(song, next) => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $push: { queue: song } },
+							{ runValidators: true },
+							next
+						);
+					},
+
+					(res, next) => {
+						StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next => {
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "station.queueUpdate",
+								value: stationId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove from a station queue
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FROM_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, youtubeId } = payload;
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						if (!station.queue.find(song => song.youtubeId === youtubeId))
+							return next("That song is not currently in the queue.");
+
+						return StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $pull: { queue: { youtubeId } } },
+							next
+						);
+					},
+
+					(res, next) => {
+						StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+							.then(station => {
+								if (station.autofill.enabled)
+									StationsModule.runJob("AUTOFILL_STATION", { stationId }, this)
+										.then(() => next())
+										.catch(err => {
+											if (
+												err === "Autofill is disabled in this station" ||
+												err === "Autofill limit reached"
+											)
+												return next();
+											return next(err);
+										});
+								else next();
+							})
+							.catch(next);
+					},
+
+					next =>
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "station.queueUpdate",
+								value: stationId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next)
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
 }
 
 export default new _StationsModule();

+ 108 - 5
backend/logic/youtube.js

@@ -44,6 +44,10 @@ let YouTubeModule;
 let CacheModule;
 let DBModule;
 let RatingsModule;
+let SongsModule;
+let StationsModule;
+let PlaylistsModule;
+let WSModule;
 
 const isQuotaExceeded = apiCalls => {
 	const reversedApiCalls = apiCalls.slice().reverse();
@@ -105,6 +109,10 @@ class _YouTubeModule extends CoreClass {
 			CacheModule = this.moduleManager.modules.cache;
 			DBModule = this.moduleManager.modules.db;
 			RatingsModule = this.moduleManager.modules.ratings;
+			SongsModule = this.moduleManager.modules.songs;
+			StationsModule = this.moduleManager.modules.stations;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+			WSModule = this.moduleManager.modules.ws;
 
 			this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
 				modelName: "youtubeApiRequest"
@@ -1126,17 +1134,112 @@ class _YouTubeModule extends CoreClass {
 						if (!videoIds.every(videoId => mongoose.Types.ObjectId.isValid(videoId)))
 							next("One or more videoIds are not a valid ObjectId.");
 						else {
-							YouTubeModule.youtubeVideoModel.find({_id: { $in: videoIds }}, next);
+							YouTubeModule.youtubeVideoModel.find({_id: { $in: videoIds }}, (err, videos) => {
+								if (err) next(err);
+								else next(null, videos.map(video => video.youtubeId));
+							});
 						}
 					},
 
-					(videos, next) => {
-						const youtubeIds = videos.map(video => video.youtubeId);
-						RatingsModule.runJob("REMOVE_RATINGS", { youtubeIds }, this)
-							.then(() => next())
+					(youtubeIds, next) => {
+						SongsModule.SongModel.find({ youtubeId: { $in: youtubeIds } }, (err, songs) => {
+							if (err) next(err);
+							else {
+								next(null, youtubeIds.filter(youtubeId => !songs.find(song => song.youtubeId === youtubeId)));
+							}
+						});
+					},
+
+					(youtubeIds, next) => {
+						RatingsModule.runJob("REMOVE_RATINGS",{youtubeIds},this)
+							.then(() => next(null, youtubeIds))
 							.catch(next);
 					},
 
+					(youtubeIds, next) => {
+						async.eachLimit(
+							youtubeIds,
+							2,
+							(youtubeId, next) => {
+								async.waterfall(
+									[
+										next => {
+											PlaylistsModule.playlistModel.find({ "songs.youtubeId": youtubeId }, (err, playlists) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														playlists,
+														1,
+														(playlist, next) => {
+															PlaylistsModule.runJob("REMOVE_FROM_PLAYLIST", { playlistId: playlist._id, youtubeId }, this)
+																.then(() => next())
+																.catch(next);
+														},
+														next
+													);
+												}
+											});
+										},
+						
+										next => {
+											StationsModule.stationModel.find({ "queue.youtubeId": youtubeId }, (err, stations) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														stations,
+														1,
+														(station, next) => {
+															StationsModule.runJob("REMOVE_FROM_QUEUE", { stationId: station._id, youtubeId }, this)
+																.then(() => next())
+																.catch(err => {
+																	if (
+																		err === "Station not found" ||
+																		err === "Song is not currently in the queue."
+																	)
+																		next();
+																	else next(err);
+																});
+														},
+														next
+													);
+												}
+											});
+										},
+						
+										next => {
+											StationsModule.stationModel.find({ "currentSong.youtubeId": youtubeId }, (err, stations) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														stations,
+														1,
+														(station, next) => {
+															StationsModule.runJob(
+																"SKIP_STATION",
+																{ stationId: station._id, natural: false },
+																this
+															)
+																.then(() => {
+																	next();
+																})
+																.catch(err => {
+																	if (err.message === "Station not found.") next();
+																	else next(err);
+																});
+														},
+														next
+													);
+												}
+											});
+										}
+									],
+									next
+								);
+							},
+							next
+						);
+					},
+
 					next => {
 						YouTubeModule.youtubeVideoModel.deleteMany({_id: { $in: videoIds }}, next);
 					}

+ 3 - 1
frontend/src/pages/Station/index.vue

@@ -1367,7 +1367,9 @@ export default {
 					"ratings.getRatings",
 					currentSong.youtubeId,
 					res => {
-						if (currentSong._id === this.currentSong._id) {
+						if (
+							currentSong.youtubeId === this.currentSong.youtubeId
+						) {
 							const { likes, dislikes } = res.data;
 							this.updateCurrentSongRatings({ likes, dislikes });
 						}