Browse Source

Merge branch 'polishing' into refactor-websockets

Jonathan Graham 4 years ago
parent
commit
4ef0e02f70

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

@@ -1497,5 +1497,40 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Deletes all orphaned station playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedStationPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_STATION_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
+						`Deleting orphaned station playlists failed. "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
+					"Deleting orphaned station playlists successfull."
+				);
+				return cb({ status: "success", message: "Success" });
+			}
+		);
 	})
 };

+ 656 - 85
backend/logic/actions/stations.js

@@ -1,4 +1,5 @@
 import async from "async";
+import mongoose from "mongoose";
 
 import { isLoginRequired, isOwnerRequired } from "./hooks";
 
@@ -8,6 +9,7 @@ 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;
 const StationsModule = moduleManager.modules.stations;
@@ -889,6 +891,148 @@ export default {
 		);
 	},
 
+	getStationIncludedPlaylistsById(session, stationId, cb) {
+		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.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const playlists = [];
+
+					async.eachLimit(
+						station.includedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									playlists.push(playlist);
+									next();
+								})
+								.catch(() => {
+									playlists.push(null);
+									next();
+								});
+						},
+						err => {
+							next(err, playlists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_INCLUDED_PLAYLISTS_BY_ID",
+						`Getting station "${stationId}"'s included playlists failed. "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_INCLUDED_PLAYLISTS_BY_ID",
+					`Got station "${stationId}"'s included playlists successfully.`
+				);
+				return cb({ status: "success", playlists });
+			}
+		);
+	},
+
+	getStationExcludedPlaylistsById(session, stationId, cb) {
+		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.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const playlists = [];
+
+					async.eachLimit(
+						station.excludedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									playlists.push(playlist);
+									next();
+								})
+								.catch(() => {
+									playlists.push(null);
+									next();
+								});
+						},
+						err => {
+							next(err, playlists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_EXCLUDED_PLAYLISTS_BY_ID",
+						`Getting station "${stationId}"'s excluded playlists failed. "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_EXCLUDED_PLAYLISTS_BY_ID",
+					`Got station "${stationId}"'s excluded playlists successfully.`
+				);
+				return cb({ status: "success", playlists });
+			}
+		);
+	},
+
 	/**
 	 * Toggles if a station is locked
 	 *
@@ -1225,6 +1369,7 @@ export default {
 	 */
 	updateDisplayName: isOwnerRequired(async function updateDisplayName(session, stationId, newDisplayName, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
 		async.waterfall(
 			[
@@ -1241,6 +1386,16 @@ export default {
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
 						.then(station => next(null, station))
 						.catch(next);
+				},
+
+				(station, next) => {
+					playlistModel.updateOne(
+						{ _id: station.playlist2 },
+						{ $set: { displayName: `Station - ${station.displayName}` } },
+						err => {
+							next(err, station);
+						}
+					);
 				}
 			],
 			async err => {
@@ -1438,25 +1593,122 @@ export default {
 	 * @param cb
 	 */
 	updateGenres: isOwnerRequired(async function updateGenres(session, stationId, newGenres, cb) {
-		const stationModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "station"
-			},
-			this
-		);
 		async.waterfall(
 			[
 				next => {
-					stationModel.updateOne(
-						{ _id: stationId },
-						{ $set: { genres: newGenres } },
-						{ runValidators: true },
-						next
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					const playlists = [];
+					async.eachLimit(
+						newGenres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre, includeSongs: false }, this)
+								.then(response => {
+									playlists.push(response.playlist);
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found")
+										next(
+											`The genre playlist for "${genre}" was not found. Please ensure that this genre playlist exists.`
+										);
+									else next(err);
+								});
+						},
+						err => {
+							next(
+								err,
+								station,
+								playlists.map(playlist => playlist._id.toString())
+							);
+						}
 					);
 				},
 
-				(res, next) => {
+				(station, playlists, next) => {
+					const playlistsToRemoveFromExcluded = playlists.filter(
+						playlistId => station.excludedPlaylists.indexOf(playlistId) !== -1
+					);
+					console.log(
+						`playlistsToRemoveFromExcluded: ${playlistsToRemoveFromExcluded.length}`,
+						playlistsToRemoveFromExcluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromIncluded = station.includedPlaylists.filter(
+						playlistId => playlists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToRemoveFromIncluded: ${playlistsToRemoveFromIncluded.length}`,
+						playlistsToRemoveFromIncluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToAddToIncluded = playlists.filter(
+						playlistId => station.includedPlaylists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToAddToIncluded: ${playlistsToAddToIncluded.length}`,
+						playlistsToAddToIncluded
+					);
+
+					async.eachLimit(
+						playlistsToAddToIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
 						.then(station => next(null, station))
 						.catch(next);
@@ -1522,24 +1774,122 @@ export default {
 		newBlacklistedGenres,
 		cb
 	) {
-		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
-
 		async.waterfall(
 			[
 				next => {
-					stationModel.updateOne(
-						{ _id: stationId },
-						{
-							$set: {
-								blacklistedGenres: newBlacklistedGenres
-							}
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					const playlists = [];
+					async.eachLimit(
+						newBlacklistedGenres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre, includeSongs: false }, this)
+								.then(response => {
+									playlists.push(response.playlist);
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found")
+										next(
+											`The genre playlist for "${genre}" was not found. Please ensure that this genre playlist exists.`
+										);
+									else next(err);
+								});
 						},
-						{ runValidators: true },
-						next
+						err => {
+							next(
+								err,
+								station,
+								playlists.map(playlist => playlist._id.toString())
+							);
+						}
 					);
 				},
 
-				(res, next) => {
+				(station, playlists, next) => {
+					const playlistsToRemoveFromIncluded = playlists.filter(
+						playlistId => station.includedPlaylists.indexOf(playlistId) !== -1
+					);
+					console.log(
+						`playlistsToRemoveFromIncluded: ${playlistsToRemoveFromIncluded.length}`,
+						playlistsToRemoveFromIncluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromExcluded = station.excludedPlaylists.filter(
+						playlistId => playlists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToRemoveFromExcluded: ${playlistsToRemoveFromExcluded.length}`,
+						playlistsToRemoveFromExcluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToAddToExcluded = playlists.filter(
+						playlistId => station.excludedPlaylists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToAddToExcluded: ${playlistsToAddToExcluded.length}`,
+						playlistsToAddToExcluded
+					);
+
+					async.eachLimit(
+						playlistsToAddToExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
 						.then(station => next(null, station))
 						.catch(next);
@@ -1899,6 +2249,12 @@ export default {
 					CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this)
 						.then(next(null, station))
 						.catch(next);
+				},
+
+				(station, next) => {
+					if (station.playlist2)
+						PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist2 }).then().catch();
+					next(null, station);
 				}
 			],
 			async (err, station) => {
@@ -1939,6 +2295,7 @@ export default {
 	create: isLoginRequired(async function create(session, data, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
 		data.name = data.name.toLowerCase();
 
@@ -2008,73 +2365,271 @@ export default {
 					this.log(station);
 
 					if (station) return next("A station with that name or display name already exists.");
-					const { name, displayName, description, genres, playlist, type, blacklistedGenres } = data;
+					const { name, displayName, description, playlist, type, genres, blacklistedGenres } = data;
+					const stationId = mongoose.Types.ObjectId();
+
 					if (type === "official") {
 						return userModel.findOne({ _id: session.userId }, (err, user) => {
 							if (err) return next(err);
 							if (!user) return next("User not found.");
 							if (user.role !== "admin") return next("Admin required.");
-							return stationModel.create(
-								{
-									name,
-									displayName,
-									description,
-									type,
-									privacy: "private",
-									playlist,
-									genres,
-									blacklistedGenres,
-									currentSong: StationsModule.defaultSong
-								},
-								next
+
+							return async.waterfall(
+								[
+									next => {
+										const playlists = [];
+										async.eachLimit(
+											genres,
+											1,
+											(genre, next) => {
+												PlaylistsModule.runJob(
+													"GET_GENRE_PLAYLIST",
+													{ genre, includeSongs: false },
+													this
+												)
+													.then(response => {
+														playlists.push(response.playlist);
+														next();
+													})
+													.catch(err => {
+														next(
+															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
+														);
+													});
+											},
+											err => {
+												next(
+													err,
+													playlists.map(playlist => playlist._id.toString())
+												);
+											}
+										);
+									},
+
+									(genrePlaylistIds, next) => {
+										const playlists = [];
+										async.eachLimit(
+											blacklistedGenres,
+											1,
+											(genre, next) => {
+												PlaylistsModule.runJob(
+													"GET_GENRE_PLAYLIST",
+													{ genre, includeSongs: false },
+													this
+												)
+													.then(response => {
+														playlists.push(response.playlist);
+														next();
+													})
+													.catch(err => {
+														next(
+															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
+														);
+													});
+											},
+											err => {
+												next(
+													err,
+													genrePlaylistIds,
+													playlists.map(playlist => playlist._id.toString())
+												);
+											}
+										);
+									},
+
+									(genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
+										const duplicateGenre =
+											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
+										const duplicateBlacklistedGenre =
+											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
+										const duplicateCross =
+											genrePlaylistIds.length + blacklistedGenrePlaylistIds.length !==
+											new Set([...genrePlaylistIds, ...blacklistedGenrePlaylistIds]).size;
+										if (duplicateGenre)
+											return next("You cannot have the same genre included twice.");
+										if (duplicateBlacklistedGenre)
+											return next("You cannot have the same blacklisted genre included twice.");
+										if (duplicateCross)
+											return next(
+												"You cannot have the same genre included and blacklisted at the same time."
+											);
+										return next(null, genrePlaylistIds, blacklistedGenrePlaylistIds);
+									}
+								],
+								(err, genrePlaylistIds, blacklistedGenrePlaylistIds) => {
+									if (err) return next(err);
+									return playlistModel.create(
+										{
+											isUserModifiable: false,
+											displayName: `Station - ${displayName}`,
+											songs: [],
+											createdBy: "Musare",
+											createdFor: `${stationId}`,
+											createdAt: Date.now(),
+											type: "station"
+										},
+
+										(err, playlist2) => {
+											if (err) next(err);
+											else {
+												stationModel.create(
+													{
+														_id: stationId,
+														name,
+														displayName,
+														description,
+														type,
+														privacy: "private",
+														playlist2: playlist2._id,
+														playlist,
+														currentSong: StationsModule.defaultSong
+													},
+													(err, station) => {
+														next(
+															err,
+															station,
+															genrePlaylistIds,
+															blacklistedGenrePlaylistIds
+														);
+													}
+												);
+											}
+										}
+									);
+								}
 							);
 						});
 					}
 					if (type === "community") {
 						if (blacklist.indexOf(name) !== -1)
 							return next("That name is blacklisted. Please use a different name.");
-						return stationModel.create(
+						return playlistModel.create(
 							{
-								name,
-								displayName,
-								description,
-								type,
-								privacy: "private",
-								owner: session.userId,
-								queue: [],
-								currentSong: null
+								isUserModifiable: false,
+								displayName: `Station - ${name}`,
+								songs: [],
+								createdBy: session.userId,
+								createdFor: `${stationId}`,
+								createdAt: Date.now(),
+								type: "station"
 							},
-							next
+
+							(err, playlist2) => {
+								if (err) next(err);
+								else {
+									stationModel.create(
+										{
+											name,
+											displayName,
+											description,
+											playlist2: playlist2._id,
+											type,
+											privacy: "private",
+											owner: session.userId,
+											queue: [],
+											currentSong: null
+										},
+										(err, station) => {
+											next(err, station, null, null);
+										}
+									);
+								}
+							}
 						);
 					}
+				},
+
+				(station, genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
+					if (station.type !== "official") return next(null, station);
+
+					const stationId = station._id;
+					console.log(111, station, genrePlaylistIds, blacklistedGenrePlaylistIds, next);
+
+					return async.waterfall(
+						[
+							next => {
+								async.eachLimit(
+									genrePlaylistIds,
+									1,
+									(playlistId, next) => {
+										StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+											.then(() => {
+												next();
+											})
+											.catch(next);
+									},
+									next
+								);
+							},
+
+							next => {
+								async.eachLimit(
+									blacklistedGenrePlaylistIds,
+									1,
+									(playlistId, next) => {
+										StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+											.then(() => {
+												next();
+											})
+											.catch(next);
+									},
+									next
+								);
+							},
+
+							next => {
+								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+								next();
+							}
+						],
+						async err => {
+							if (err) {
+								err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+								this.log(
+									"ERROR",
+									"STATIONS_CREATE",
+									`Created station ${stationId} successfully, but an error occurred during playing including/excluding. Error: ${err}`
+								);
+							}
+							next(null, station, err);
+						}
+					);
 				}
 			],
-			async (err, station) => {
+			async (err, station, extraError) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log("ERROR", "STATIONS_CREATE", `Creating station failed. "${err}"`);
-					return cb({ status: "failure", message: err });
-				}
-				this.log("SUCCESS", "STATIONS_CREATE", `Created station "${station._id}" successfully.`);
+					cb({ status: "failure", message: err });
+				} else {
+					this.log("SUCCESS", "STATIONS_CREATE", `Created station "${station._id}" successfully.`);
 
-				CacheModule.runJob("PUB", {
-					channel: "station.create",
-					value: station._id
-				});
+					CacheModule.runJob("PUB", {
+						channel: "station.create",
+						value: station._id
+					});
 
-				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: session.userId,
-					type: "station__create",
-					payload: {
-						message: `Created a station named <stationId>${station.displayName}</stationId>`,
-						stationId: station._id
-					}
-				});
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__create",
+						payload: {
+							message: `Created a station named <stationId>${station.displayName}</stationId>`,
+							stationId: station._id
+						}
+					});
 
-				return cb({
-					status: "success",
-					message: "Successfully created station."
-				});
+					if (!extraError) {
+						cb({
+							status: "success",
+							message: "Successfully created station."
+						});
+					} else {
+						cb({
+							status: "success",
+							message: `Successfully created station, but with one error at the end: ${extraError}`
+						});
+					}
+				}
 			}
 		);
 	}),
@@ -2110,6 +2665,7 @@ export default {
 
 				(station, next) => {
 					if (!station) return next("Station not found.");
+					if (!station.partyMode) return next("Station is not in party mode.");
 
 					if (station.locked) {
 						return userModel.findOne({ _id: session.userId }, (err, user) => {
@@ -2171,19 +2727,21 @@ export default {
 
 									return next(null, song, station);
 								})
-								.catch(err => next(err));
+								.catch(err => {
+									next(err);
+								});
 						})
-						.catch(err => next(err));
+						.catch(err => {
+							next(err);
+						});
 				},
 
 				(song, station, next) => {
-					const { queue } = station;
 					song.requestedBy = session.userId;
 					song.requestedAt = Date.now();
-					queue.push(song);
 
 					let totalDuration = 0;
-					queue.forEach(song => {
+					station.queue.forEach(song => {
 						totalDuration += song.duration;
 					});
 					if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
@@ -2191,10 +2749,9 @@ export default {
 				},
 
 				(song, station, next) => {
-					const { queue } = station;
-					if (queue.length === 0) return next(null, song, station);
+					if (station.queue.length === 0) return next(null, song, station);
 					let totalDuration = 0;
-					const userId = queue[queue.length - 1].requestedBy;
+					const userId = station.queue[station.queue.length - 1].requestedBy;
 					station.queue.forEach(song => {
 						if (userId === song.requestedBy) {
 							totalDuration += song.duration;
@@ -2206,11 +2763,10 @@ export default {
 				},
 
 				(song, station, next) => {
-					const { queue } = station;
-					if (queue.length === 0) return next(null, song);
+					if (station.queue.length === 0) return next(null, song);
 					let totalSongs = 0;
-					const userId = queue[queue.length - 1].requestedBy;
-					queue.forEach(song => {
+					const userId = station.queue[station.queue.length - 1].requestedBy;
+					station.queue.forEach(song => {
 						if (userId === song.requestedBy) {
 							totalSongs += 1;
 						}
@@ -2219,7 +2775,10 @@ export default {
 					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 (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId)
+					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);
@@ -2228,7 +2787,7 @@ export default {
 				(song, next) => {
 					stationModel.updateOne(
 						{ _id: stationId },
-						{ $pushr: { queue: song } },
+						{ $push: { queue: song } },
 						{ runValidators: true },
 						next
 					);
@@ -2239,6 +2798,14 @@ export default {
 						.then(station => next(null, station))
 						.catch(next);
 				}
+
+				// (res, next) => {
+				// 	StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+				// 		.then(station => {
+				// 			next(null, station);
+				// 		})
+				// 		.catch(next);
+				// }
 			],
 			async err => {
 				if (err) {
@@ -2365,7 +2932,6 @@ export default {
 
 				(station, next) => {
 					if (!station) return next("Station not found.");
-					if (station.type !== "community") return next("Station is not a community station.");
 					return next(null, station);
 				},
 
@@ -2376,9 +2942,14 @@ export default {
 							return next("Insufficient permissions.");
 						})
 						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (station.type === "official") next(null, station.playlist);
+					else next(null, station.queue);
 				}
 			],
-			async (err, station) => {
+			async (err, queue) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -2394,7 +2965,7 @@ export default {
 				return cb({
 					status: "success",
 					message: "Successfully got queue.",
-					queue: station.queue
+					queue
 				});
 			}
 		);

+ 1 - 1
backend/logic/db/index.js

@@ -13,7 +13,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	queueSong: 1,
 	report: 1,
 	song: 1,
-	station: 1,
+	station: 2,
 	user: 1
 };
 

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

@@ -1,8 +1,11 @@
+import mongoose from "mongoose";
+
 export default {
 	displayName: { type: String, min: 2, max: 32, required: true },
 	isUserModifiable: { type: Boolean, default: true, required: true },
 	songs: [
 		{
+			_id: { type: mongoose.Schema.Types.ObjectId, required: false },
 			songId: { type: String },
 			title: { type: String },
 			duration: { type: Number },
@@ -15,6 +18,6 @@ export default {
 	createdAt: { type: Date, default: Date.now, required: true },
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
-	type: { type: String, enum: ["user", "genre"], required: true },
+	type: { type: String, enum: ["user", "genre", "station"], required: true },
 	documentVersion: { type: Number, default: 1, required: true }
 };

+ 4 - 1
backend/logic/db/schemas/station.js

@@ -24,6 +24,7 @@ export default {
 	pausedAt: { type: Number, default: 0, required: true },
 	startedAt: { type: Number, default: 0, required: true },
 	playlist: { type: Array },
+	playlist2: { type: mongoose.Schema.Types.ObjectId, required: true },
 	genres: [{ type: String }],
 	blacklistedGenres: [{ type: String }],
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
@@ -46,5 +47,7 @@ export default {
 	privatePlaylist: { type: mongoose.Schema.Types.ObjectId },
 	partyMode: { type: Boolean },
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange"], default: "blue" },
-	documentVersion: { type: Number, default: 1, required: true }
+	includedPlaylists: [{ type: String }],
+	excludedPlaylists: [{ type: String }],
+	documentVersion: { type: Number, default: 2, required: true }
 };

+ 91 - 0
backend/logic/migration/migrations/migration2.js

@@ -0,0 +1,91 @@
+import async from "async";
+
+/**
+ * Migration 2
+ *
+ * Updates the document version 1 stations to add the includedPlaylists and excludedPlaylists properties, and to create a station playlist and link that playlist with the playlist2 property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 2. Finding stations with document version 1.`);
+					stationModel.find({ documentVersion: 1 }, (err, stations) => {
+						this.log("INFO", `Migration 2. Found ${stations.length} stations with document version 1.`);
+
+						next(
+							null,
+							stations.map(station => station._doc)
+						);
+					});
+				},
+
+				(stations, next) => {
+					async.eachLimit(
+						stations,
+						1,
+						(station, next) => {
+							this.log("INFO", `Migration 2. Creating station playlist for station ${station._id}.`);
+							playlistModel.create(
+								{
+									isUserModifiable: false,
+									displayName: `Station - ${station.displayName}`,
+									songs: [],
+									createdBy: station.type === "official" ? "Musare" : station.createdBy,
+									createdFor: `${station._id}`,
+									createdAt: Date.now(),
+									type: "station",
+									documentVersion: 1
+								},
+								(err, playlist2) => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 2. Updating station ${station._id}.`);
+										stationModel.updateOne(
+											{ _id: station._id },
+											{
+												$set: {
+													playlist2: playlist2._id,
+													includedPlaylists: [],
+													excludedPlaylists: [],
+													playlist: station.type === "official" ? [] : station.playlist,
+													genres: [],
+													documentVersion: 2
+												}
+											},
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 2. Updating station ${station._id} done. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+												}
+											}
+										);
+										next();
+									}
+								}
+							);
+						},
+						next
+					);
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 321 - 2
backend/logic/playlists.js

@@ -3,6 +3,7 @@ import async from "async";
 import CoreClass from "../core";
 
 let PlaylistsModule;
+let StationsModule;
 let SongsModule;
 let CacheModule;
 let DBModule;
@@ -24,6 +25,7 @@ class _PlaylistsModule extends CoreClass {
 	async initialize() {
 		this.setStage(1);
 
+		StationsModule = this.moduleManager.modules.stations;
 		CacheModule = this.moduleManager.modules.cache;
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
@@ -100,7 +102,23 @@ class _PlaylistsModule extends CoreClass {
 							error: err
 						});
 						reject(new Error(formattedErr));
-					} else resolve();
+					} else {
+						resolve();
+
+						PlaylistsModule.runJob("CREATE_MISSING_GENRE_PLAYLISTS", {})
+							.then()
+							.catch()
+							.finally(() => {
+								SongsModule.runJob("GET_ALL_GENRES", {})
+									.then(response => {
+										const { genres } = response;
+										genres.forEach(genre => {
+											PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }).then().catch();
+										});
+									})
+									.catch();
+							});
+					}
 				}
 			)
 		);
@@ -209,6 +227,105 @@ class _PlaylistsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets all missing genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MISSING_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			SongsModule.runJob("GET_ALL_GENRES", {}, this)
+				.then(response => {
+					const { genres } = response;
+					const missingGenres = [];
+					async.eachLimit(
+						genres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob(
+								"GET_GENRE_PLAYLIST",
+								{ genre: genre.toLowerCase(), includeSongs: false },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found") {
+										missingGenres.push(genre);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve({ genres: missingGenres });
+						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Creates all missing genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_MISSING_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_MISSING_GENRE_PLAYLISTS", {}, this)
+				.then(response => {
+					const { genres } = response;
+					async.eachLimit(
+						genres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre }, this)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve();
+						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Gets a station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.staationId - the station id
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.findOne(
+				{ type: "station", createdFor: payload.stationId },
+				includeObject,
+				(err, playlist) => {
+					if (err) reject(new Error(err));
+					else if (!playlist) reject(new Error("Playlist not found"));
+					else resolve({ playlist });
+				}
+			);
+		});
+	}
+
 	/**
 	 * Adds a song to a playlist
 	 *
@@ -359,11 +476,213 @@ class _PlaylistsModule extends CoreClass {
 
 						Promise.allSettled(promises)
 							.then(() => {
-								next();
+								next(null, data.playlist._id);
 							})
 							.catch(err => {
 								next(err);
 							});
+					},
+
+					(playlistId, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+							.then(response => {
+								response.stationIds.forEach(stationId => {
+									PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+								});
+							})
+							.catch();
+						next();
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({});
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets a orphaned station playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_STATION_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "station" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							StationsModule.runJob("GET_STATION", { stationId: playlist.createdFor }, this)
+								.then(station => {
+									if (station.playlist2 !== playlist._id.toString()) {
+										orphanedPlaylists.push(playlist);
+									}
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Station not found") {
+										orphanedPlaylists.push(playlist);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned station playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_STATION_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_STATION_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned station playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Fills a station playlist with songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	AUTOFILL_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			let originalPlaylist = null;
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: station.playlist2 }, this)
+							.then(playlist => {
+								originalPlaylist = playlist;
+								next(null, station);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(station, next) => {
+						const includedPlaylists = [];
+						async.eachLimit(
+							station.includedPlaylists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										includedPlaylists.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, station, includedPlaylists);
+							}
+						);
+					},
+
+					(station, includedPlaylists, next) => {
+						const excludedPlaylists = [];
+						async.eachLimit(
+							station.excludedPlaylists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										excludedPlaylists.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, station, includedPlaylists, excludedPlaylists);
+							}
+						);
+					},
+
+					(station, includedPlaylists, excludedPlaylists, next) => {
+						const excludedSongs = excludedPlaylists
+							.flatMap(excludedPlaylist => excludedPlaylist.songs)
+							.reduce(
+								(items, item) =>
+									items.find(x => x.songId === item.songId) ? [...items] : [...items, item],
+								[]
+							);
+						const includedSongs = includedPlaylists
+							.flatMap(includedPlaylist => includedPlaylist.songs)
+							.reduce(
+								(songs, song) =>
+									songs.find(x => x.songId === song.songId) ? [...songs] : [...songs, song],
+								[]
+							)
+							.filter(song => !excludedSongs.find(x => x.songId === song.songId));
+
+						next(null, station, includedSongs);
+					},
+
+					(station, includedSongs, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: station.playlist2 },
+							{ $set: { songs: includedSongs } },
+							err => {
+								next(err, includedSongs);
+							}
+						);
+					},
+
+					(includedSongs, next) => {
+						if (originalPlaylist.songs.length === 0 && includedSongs.length > 0)
+							StationsModule.runJob("SKIP_STATION", { stationId: payload.stationId });
+						next();
 					}
 				],
 				err => {

+ 550 - 233
backend/logic/stations.js

@@ -1,4 +1,5 @@
 import async from "async";
+import mongoose from "mongoose";
 
 import CoreClass from "../core";
 
@@ -8,6 +9,7 @@ let DBModule;
 let UtilsModule;
 let WSModule;
 let SongsModule;
+let PlaylistsModule;
 let NotificationsModule;
 
 class _StationsModule extends CoreClass {
@@ -29,6 +31,7 @@ class _StationsModule extends CoreClass {
 		UtilsModule = this.moduleManager.modules.utils;
 		WSModule = this.moduleManager.modules.ws;
 		SongsModule = this.moduleManager.modules.songs;
+		PlaylistsModule = this.moduleManager.modules.playlists;
 		NotificationsModule = this.moduleManager.modules.notifications;
 
 		this.defaultSong = {
@@ -311,114 +314,6 @@ class _StationsModule extends CoreClass {
 		});
 	}
 
-	/**
-	 * Calculates the next song for the station
-	 *
-	 * @param {object} payload - object that contains the payload
-	 * @param {string} payload.station - station object to calculate song for
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	async CALCULATE_SONG_FOR_STATION(payload) {
-		// station, bypassValidate = false
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-
-		return new Promise((resolve, reject) => {
-			const songList = [];
-
-			return async.waterfall(
-				[
-					next => {
-						if (payload.station.genres.length === 0) return next();
-
-						const genresDone = [];
-						const blacklistedGenres = payload.station.blacklistedGenres.map(blacklistedGenre =>
-							blacklistedGenre.toLowerCase()
-						);
-
-						return payload.station.genres.forEach(genre => {
-							songModel.find({ genres: { $regex: genre, $options: "i" } }, (err, songs) => {
-								if (!err) {
-									songs.forEach(song => {
-										if (songList.indexOf(song._id) === -1) {
-											let found = false;
-											song.genres.forEach(songGenre => {
-												if (blacklistedGenres.indexOf(songGenre.toLowerCase()) !== -1)
-													found = true;
-											});
-											if (!found) {
-												songList.push(song._id);
-											}
-										}
-									});
-								}
-								genresDone.push(genre);
-								if (genresDone.length === payload.station.genres.length) next();
-							});
-						});
-					},
-
-					next => {
-						const playlist = [];
-						songList.forEach(songId => {
-							if (payload.station.playlist.indexOf(songId) === -1) playlist.push(songId);
-						});
-
-						// eslint-disable-next-line array-callback-return
-						payload.station.playlist.filter(songId => {
-							if (songList.indexOf(songId) !== -1) playlist.push(songId);
-						});
-
-						UtilsModule.runJob("SHUFFLE", { array: playlist })
-							.then(result => {
-								next(null, result.array);
-							}, this)
-							.catch(next);
-					},
-
-					(playlist, next) => {
-						StationsModule.runJob(
-							"CALCULATE_OFFICIAL_PLAYLIST_LIST",
-							{
-								stationId: payload.station._id,
-								songList: playlist
-							},
-							this
-						)
-							.then(() => {
-								next(null, playlist);
-							})
-							.catch(next);
-					},
-
-					(playlist, next) => {
-						StationsModule.stationModel.updateOne(
-							{ _id: payload.station._id },
-							{ $set: { playlist } },
-							{ runValidators: true },
-							() => {
-								StationsModule.runJob(
-									"UPDATE_STATION",
-									{
-										stationId: payload.station._id
-									},
-									this
-								)
-									.then(() => {
-										next(null, playlist);
-									})
-									.catch(next);
-							}
-						);
-					}
-				],
-				(err, newPlaylist) => {
-					if (err) return reject(new Error(err));
-					return resolve(newPlaylist);
-				}
-			);
-		});
-	}
-
 	/**
 	 * Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
 	 *
@@ -443,14 +338,6 @@ class _StationsModule extends CoreClass {
 
 					(station, next) => {
 						if (station) {
-							if (station.type === "official") {
-								StationsModule.runJob("CALCULATE_OFFICIAL_PLAYLIST_LIST", {
-									stationId: station._id,
-									songList: station.playlist
-								})
-									.then()
-									.catch();
-							}
 							station = StationsModule.stationSchema(station);
 							CacheModule.runJob("HSET", {
 								table: "stations",
@@ -496,12 +383,6 @@ class _StationsModule extends CoreClass {
 
 					(station, next) => {
 						if (station) {
-							if (station.type === "official") {
-								StationsModule.runJob("CALCULATE_OFFICIAL_PLAYLIST_LIST", {
-									stationId: station._id,
-									songList: station.playlist
-								});
-							}
 							station = StationsModule.stationSchema(station);
 							CacheModule.runJob("HSET", {
 								table: "stations",
@@ -578,60 +459,188 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Creates the official playlist for a station
+	 * Fills up the official station playlist queue using the songs from the official station playlist
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the id of the station
-	 * @param {Array} payload.songList - list of songs to put in official playlist
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	async CALCULATE_OFFICIAL_PLAYLIST_LIST(payload) {
-		const officialPlaylistSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "officialPlaylist" }, this);
-
-		console.log(typeof payload.songList, payload.songList);
-
-		return new Promise(resolve => {
-			const lessInfoPlaylist = [];
-
-			return async.each(
-				payload.songList,
-				(song, next) => {
-					SongsModule.runJob("GET_SONG", { id: song }, this)
-						.then(response => {
-							const { song } = response;
-							if (song) {
-								const newSong = {
-									_id: song._id,
-									songId: song.songId,
-									title: song.title,
-									artists: song.artists,
-									duration: song.duration,
-									thumbnail: song.thumbnail,
-									requestedAt: song.requestedAt
-								};
-								lessInfoPlaylist.push(newSong);
+	FILL_UP_OFFICIAL_STATION_PLAYLIST_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_STATION_PLAYLIST", { stationId, includeSongs: true }, this)
+							.then(response => {
+								next(null, response.playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						UtilsModule.runJob("SHUFFLE", { array: playlist.songs }, this)
+							.then(response => {
+								next(null, response.array);
+							})
+							.catch(next);
+					},
+
+					(playlistSongs, next) => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, playlistSongs, station);
+							})
+							.catch(next);
+					},
+
+					(playlistSongs, station, next) => {
+						const songsStillNeeded = 50 - station.playlist.length;
+						const currentSongs = station.playlist;
+						const currentSongIds = station.playlist.map(song => song._id);
+						const songsToAdd = [];
+						playlistSongs
+							.map(song => song._doc)
+							.forEach(song => {
+								if (
+									songsToAdd.length < songsStillNeeded &&
+									currentSongIds.indexOf(song._id.toString()) === -1
+								)
+									songsToAdd.push(song);
+							});
+
+						next(null, [...currentSongs, ...songsToAdd]);
+					},
+
+					(newPlaylist, next) => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $set: { playlist: newPlaylist } },
+							{ runValidators: true },
+							() => {
+								StationsModule.runJob(
+									"UPDATE_STATION",
+									{
+										stationId
+									},
+									this
+								)
+									.then(() => {
+										next(null);
+									})
+									.catch(next);
 							}
-						})
-						.finally(() => {
-							next();
-						});
-				},
-				() => {
-					CacheModule.runJob(
-						"HSET",
-						{
-							table: "officialPlaylists",
-							key: payload.stationId,
-							value: officialPlaylistSchema(payload.stationId, lessInfoPlaylist)
-						},
-						this
-					).finally(() => {
-						CacheModule.runJob("PUB", {
-							channel: "station.newOfficialPlaylist",
-							value: payload.stationId
-						});
-						resolve();
-					});
+						);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets next official station song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_NEXT_OFFICIAL_STATION_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist.length === 0) next("No songs available.");
+						else {
+							next(null, station.playlist[0]);
+						}
+					},
+
+					(song, next) => {
+						console.log(44444, song, song._id);
+						SongsModule.runJob("GET_SONG", { id: song._id }, this)
+							.then(response => {
+								const { song } = response;
+								if (song) {
+									const newSong = {
+										_id: song._id,
+										songId: song.songId,
+										title: song.title,
+										artists: song.artists,
+										duration: song.duration,
+										thumbnail: song.thumbnail,
+										requestedAt: song.requestedAt
+									};
+									next(null, newSong);
+								} else {
+									next(null, song);
+								}
+							})
+							.catch(next);
+					}
+				],
+				(err, song) => {
+					if (err) console.log(33333, err, payload);
+					if (err) reject(err);
+					else resolve({ song });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes first official playlist queue song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FIRST_OFFICIAL_PLAYLIST_QUEUE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $pop: { playlist: -1 } },
+							{ runValidators: true },
+							err => {
+								if (err) next(err);
+								else
+									StationsModule.runJob(
+										"UPDATE_STATION",
+										{
+											stationId
+										},
+										this
+									)
+										.then(() => {
+											next(null);
+										})
+										.catch(next);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
 				}
 			);
 		});
@@ -734,7 +743,7 @@ class _StationsModule extends CoreClass {
 											return next(null, currentSong, currentSongIndex, station);
 										};
 
-										if (playlist[currentSongIndex]._id)
+										if (mongoose.Types.ObjectId.isValid(playlist[currentSongIndex]._id))
 											return SongsModule.runJob(
 												"GET_SONG",
 												{
@@ -760,72 +769,35 @@ class _StationsModule extends CoreClass {
 							);
 						}
 
-						if (station.type === "official" && station.playlist.length === 0) {
-							return StationsModule.runJob("CALCULATE_SONG_FOR_STATION", { station }, this)
-								.then(playlist => {
-									if (playlist.length === 0)
-										return next(null, StationsModule.defaultSong, 0, station);
-
-									return SongsModule.runJob(
-										"GET_SONG",
-										{
-											id: playlist[0]
-										},
+						if (station.type === "official") {
+							StationsModule.runJob(
+								"REMOVE_FIRST_OFFICIAL_PLAYLIST_QUEUE_SONG",
+								{ stationId: station._id },
+								this
+							)
+								.then(() => {
+									StationsModule.runJob(
+										"FILL_UP_OFFICIAL_STATION_PLAYLIST_QUEUE",
+										{ stationId: station._id },
 										this
 									)
-										.then(response => {
-											next(null, response.song, 0, station);
+										.then(() => {
+											StationsModule.runJob(
+												"GET_NEXT_OFFICIAL_STATION_SONG",
+												{ stationId: station._id },
+												this
+											)
+												.then(response => {
+													next(null, response.song, 0, station);
+												})
+												.catch(err => {
+													if (err === "No songs available.") next(null, null, 0, station);
+													else next(err);
+												});
 										})
-										.catch(() => next(null, StationsModule.defaultSong, 0, station));
+										.catch(next);
 								})
-								.catch(err => {
-									next(err);
-								});
-						}
-
-						if (station.type === "official" && station.playlist.length > 0) {
-							return async.doUntil(
-								next => {
-									if (station.currentSongIndex < station.playlist.length - 1) {
-										SongsModule.runJob(
-											"GET_SONG",
-											{
-												id: station.playlist[station.currentSongIndex + 1]
-											},
-											this
-										)
-											.then(response => next(null, response.song, station.currentSongIndex + 1))
-											.catch(() => {
-												station.currentSongIndex += 1;
-												next(null, null, null);
-											});
-									} else {
-										StationsModule.runJob(
-											"CALCULATE_SONG_FOR_STATION",
-											{
-												station
-											},
-											this
-										)
-											.then(newPlaylist => {
-												SongsModule.runJob("GET_SONG", { id: newPlaylist[0] }, this)
-													.then(response => {
-														station.playlist = newPlaylist;
-														next(null, response.song, 0);
-													})
-													.catch(() => next(null, StationsModule.defaultSong, 0));
-											})
-											.catch(() => {
-												next(null, StationsModule.defaultSong, 0);
-											});
-									}
-								},
-								(song, currentSongIndex, next) => {
-									if (song) return next(null, true, currentSongIndex);
-									return next(null, false);
-								},
-								(err, song, currentSongIndex) => next(err, song, currentSongIndex, station)
-							);
+								.catch(next);
 						}
 					},
 					(song, currentSongIndex, station, next) => {
@@ -890,6 +862,7 @@ class _StationsModule extends CoreClass {
 				],
 				async (err, station) => {
 					if (err) {
+						console.log(123, err);
 						err = await UtilsModule.runJob(
 							"GET_ERROR",
 							{
@@ -1211,6 +1184,350 @@ class _StationsModule extends CoreClass {
 				.catch(reject);
 		});
 	}
+
+	/**
+	 * Adds a playlist to be included in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station to include the playlist in
+	 * @param {object} payload.playlistId - the id of the playlist to be included
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	INCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist2 === payload.playlistId) next("You cannot include the station playlist");
+						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already included");
+						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
+							next(
+								"This playlist is currently excluded, please remove it from there before including it"
+							);
+						else
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
+								.then(() => {
+									next(null);
+								})
+								.catch(next);
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $push: { includedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes a playlist that is included in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_INCLUDED_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.includedPlaylists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't included");
+						else next();
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $pull: { includedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Adds a playlist to be excluded in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	EXCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist2 === payload.playlistId) next("You cannot exclude the station playlist");
+						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already excluded");
+						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+							next(
+								"This playlist is currently included, please remove it from there before excluding it"
+							);
+						else
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
+								.then(() => {
+									next(null);
+								})
+								.catch(next);
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $push: { excludedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes a playlist that is excluded in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_EXCLUDED_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			console.log(112, payload);
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.excludedPlaylists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't excluded");
+						else next();
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $pull: { excludedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets stations that include or exclude a specific playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob(
+				"GET_MODEL",
+				{
+					modelName: "station"
+				},
+				this
+			).then(stationModel => {
+				stationModel.find(
+					{ $or: [{ includedPlaylists: payload.playlistId }, { excludedPlaylists: payload.playlistId }] },
+					(err, stations) => {
+						if (err) reject(err);
+						else resolve({ stationIds: stations.map(station => station._id) });
+					}
+				);
+			});
+		});
+	}
 }
 
 export default new _StationsModule();

+ 73 - 27
frontend/src/components/modals/EditStation.vue

@@ -512,33 +512,79 @@ export default {
 		})
 	},
 	mounted() {
-		this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
-			if (res.status === "success") {
-				const { station } = res;
-				// this.song = { ...song };
-				// if (this.song.discogs === undefined)
-				// 	this.song.discogs = null;
-				this.editStation(station);
-
-				// this.songDataLoaded = true;
-
-				this.station.genres = JSON.parse(
-					JSON.stringify(this.station.genres)
-				);
-				this.station.blacklistedGenres = JSON.parse(
-					JSON.stringify(this.station.blacklistedGenres)
-				);
-			} else {
-				new Toast({
-					content: "Station with that ID not found",
-					timeout: 3000
-				});
-				this.closeModal({
-					sector: this.sector,
-					modal: "editStation"
-				});
-			}
-		});
+    this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
+      if (res.status === "success") {
+        const { station } = res;
+        // this.song = { ...song };
+        // if (this.song.discogs === undefined)
+        // 	this.song.discogs = null;
+        this.editStation(station);
+
+        // this.songDataLoaded = true;
+
+        this.socket.dispatch(
+          `stations.getStationIncludedPlaylistsById`,
+          this.stationId,
+          res => {
+            if (res.status === "success") {
+              this.station.genres = res.playlists.map(
+                playlist => {
+                  if (playlist) {
+                    if (playlist.type === "genre")
+                      return playlist.createdFor;
+                    return `Playlist: ${playlist.name}`;
+                  }
+                  return "Unknown/Error";
+                }
+              );
+              this.originalStation.genres = JSON.parse(
+                JSON.stringify(this.station.genres)
+              );
+            }
+          }
+        );
+
+        this.socket.dispatch(
+          `stations.getStationExcludedPlaylistsById`,
+          this.stationId,
+          res => {
+            if (res.status === "success") {
+              this.station.blacklistedGenres = res.playlists.map(
+                playlist => {
+                  if (playlist) {
+                    if (playlist.type === "genre")
+                      return playlist.createdFor;
+                    return `Playlist: ${playlist.name}`;
+                  }
+                  return "Unknown/Error";
+                }
+              );
+              this.originalStation.blacklistedGenres = JSON.parse(
+                JSON.stringify(
+                  this.station.blacklistedGenres
+                )
+              );
+            }
+          }
+        );
+
+        // this.station.genres = JSON.parse(
+        // 	JSON.stringify(this.station.genres)
+        // );
+        // this.station.blacklistedGenres = JSON.parse(
+        // 	JSON.stringify(this.station.blacklistedGenres)
+        // );
+      } else {
+        new Toast({
+          content: "Station with that ID not found",
+          timeout: 3000
+        });
+        this.closeModal({
+          sector: this.sector,
+          modal: "editStation"
+        });
+      }
+    });
 	},
 	methods: {
 		saveChanges() {

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

@@ -2,6 +2,14 @@
 	<div>
 		<metadata title="Admin | Playlists" />
 		<div class="container">
+			<button
+				class="button is-primary"
+				@click="deleteOrphanedStationPlaylists()"
+			>
+				Delete orphaned station playlists
+			</button>
+			<br />
+			<br />
 			<table class="table is-striped">
 				<thead>
 					<tr>
@@ -59,6 +67,8 @@
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
 
+import Toast from "toasters";
+
 // import EditPlaylist from "../../../components/modals/EditPlaylist/index.vue";
 import UserIdToUsername from "../../../components/common/UserIdToUsername.vue";
 
@@ -122,6 +132,24 @@ export default {
 			});
 			return this.utils.formatTimeLong(length);
 		},
+		deleteOrphanedStationPlaylists() {
+			this.socket.emit(
+				"playlists.deleteOrphanedStationPlaylists",
+				res => {
+					if (res.status === "success") {
+						new Toast({
+							content: `${res.message}`,
+							timeout: 4000
+						});
+					} else {
+						new Toast({
+							content: `Error: ${res.message}`,
+							timeout: 8000
+						});
+					}
+				}
+			);
+		},
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };

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

@@ -681,7 +681,29 @@ export default {
 
 			this.songsList.forEach(queueSong => {
 				if (queueSong.requestedBy === this.userId) isInQueue = true;
-			});
+    	});
+      
+      if (
+        !isInQueue &&
+        this.privatePlaylistQueueSelected &&
+        (this.automaticallyRequestedSongId !==
+          this.currentSong.songId ||
+          !this.currentSong.songId)
+      ) {
+        this.addFirstPrivatePlaylistSongToQueue();
+      }
+
+      if (this.station.type === "official") {
+        this.socket.dispatch(
+          "stations.getQueue",
+          this.station._id,
+          res => {
+            if (res.status === "success") {
+              this.updateSongsList(res.queue);
+            }
+          }
+        );
+      }
 
 			if (
 				!isInQueue &&
@@ -1541,6 +1563,17 @@ export default {
 								}
 							);
 						}
+            
+            if (
+              (type === "community" && partyMode === true) ||
+              type === "official"
+            ) {
+              this.socket.dispatch("stations.getQueue", _id, res => {
+                if (res.status === "success") {
+                  this.updateSongsList(res.queue);
+                }
+              });
+            }
 
 						if (this.isOwnerOrAdmin()) {
 							keyboardShortcuts.registerShortcut(