Browse Source

refactor(Playlists): Using types instead of isUserModifiable and allowed more control over liked/disliked playlists

Owen Diffey 3 years ago
parent
commit
004602b310

+ 144 - 82
backend/logic/actions/playlists.js

@@ -415,7 +415,7 @@ export default {
 
 					const match = {
 						createdBy: userId,
-						type: "user"
+						type: { $in: ["user", "user-liked", "user-disliked"] }
 					};
 
 					// if a playlist order exists
@@ -471,10 +471,9 @@ export default {
 	 * Gets all playlists for the user requesting it
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {boolean} showNonModifiablePlaylists - whether or not to show non modifiable playlists e.g. liked songs
 	 * @param {Function} cb - gets called with the result
 	 */
-	indexMyPlaylists: isLoginRequired(async function indexMyPlaylists(session, showNonModifiablePlaylists, cb) {
+	indexMyPlaylists: isLoginRequired(async function indexMyPlaylists(session, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -489,12 +488,9 @@ export default {
 
 					const match = {
 						createdBy: session.userId,
-						type: "user"
+						type: { $in: ["user", "user-liked", "user-disliked"] }
 					};
 
-					// if non modifiable playlists should be shown as well
-					if (!showNonModifiablePlaylists) match.isUserModifiable = true;
-
 					// if a playlist order exists
 					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
 
@@ -787,67 +783,6 @@ export default {
 		);
 	},
 
-	/**
-	 * Shuffles songs in a private playlist
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} playlistId - the id of the playlist we are updating
-	 * @param {Function} cb - gets called with the result
-	 */
-	shuffle: isLoginRequired(async function shuffle(session, playlistId, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					if (!playlistId) return next("No playlist id.");
-					return playlistModel.findById(playlistId, next);
-				},
-
-				(playlist, next) => {
-					if (!playlist.isUserModifiable) return next("Playlist cannot be shuffled.");
-
-					return UtilsModule.runJob("SHUFFLE_SONG_POSITIONS", { array: playlist.songs }, this)
-						.then(result => next(null, result.array))
-						.catch(next);
-				},
-
-				(songs, next) => {
-					playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, { runValidators: true }, next);
-				},
-
-				(res, next) => {
-					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-						.then(playlist => next(null, playlist))
-						.catch(next);
-				}
-			],
-			async (err, playlist) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"PLAYLIST_SHUFFLE",
-						`Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_SHUFFLE",
-					`Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				return cb({
-					status: "success",
-					message: "Successfully shuffled playlist.",
-					data: { playlist }
-				});
-			}
-		);
-	}),
-
 	/**
 	 * Changes the order (position) of a song in a playlist
 	 *
@@ -1013,9 +948,21 @@ export default {
 								.catch(next);
 						}
 					);
+				},
+				(playlist, newSong, next) => {
+					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
+							songId: newSong._id,
+							youtubeId: newSong.youtubeId
+						})
+							.then(ratings => next(null, playlist, newSong, ratings))
+							.catch(next);
+					} else {
+						next(null, playlist, newSong, null);
+					}
 				}
 			],
-			async (err, playlist, newSong) => {
+			async (err, playlist, newSong, ratings) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -1068,6 +1015,55 @@ export default {
 					}
 				});
 
+				if (ratings && (playlist.type === "user-liked" || playlist.type === "user-disliked")) {
+					const { _id, youtubeId, title, artists, thumbnail } = newSong;
+					const { likes, dislikes } = ratings;
+
+					SongsModule.runJob("UPDATE_SONG", { songId: _id });
+
+					if (playlist.type === "user-liked") {
+						CacheModule.runJob("PUB", {
+							channel: "song.like",
+							value: JSON.stringify({
+								youtubeId,
+								userId: session.userId,
+								likes,
+								dislikes
+							})
+						});
+
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "song__like",
+							payload: {
+								message: `Liked song <youtubeId>${title} by ${artists.join(", ")}</youtubeId>`,
+								youtubeId,
+								thumbnail
+							}
+						});
+					} else {
+						CacheModule.runJob("PUB", {
+							channel: "song.dislike",
+							value: JSON.stringify({
+								youtubeId,
+								userId: session.userId,
+								likes,
+								dislikes
+							})
+						});
+
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "song__dislike",
+							payload: {
+								message: `Disliked song <youtubeId>${title} by ${artists.join(", ")}</youtubeId>`,
+								youtubeId,
+								thumbnail
+							}
+						});
+					}
+				}
+
 				return cb({
 					status: "success",
 					message: "Song has been successfully added to the playlist",
@@ -1158,7 +1154,7 @@ export default {
 
 				(playlist, next) => {
 					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					if (playlist.type !== "user") return next("Playlist cannot be modified.");
 
 					return next(null, playlist);
 				}
@@ -1246,9 +1242,11 @@ export default {
 					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
 						.then(res =>
 							next(null, playlist, {
+								_id: res.song._id,
 								title: res.song.title,
 								thumbnail: res.song.thumbnail,
-								artists: res.song.artists
+								artists: res.song.artists,
+								youtubeId: res.song.youtubeId
 							})
 						)
 						.catch(() => {
@@ -1258,14 +1256,26 @@ export default {
 						});
 				},
 
-				(playlist, youtubeSong, next) => {
-					const songName = youtubeSong.artists
-						? `${youtubeSong.title} by ${youtubeSong.artists.join(", ")}`
-						: youtubeSong.title;
+				(playlist, newSong, next) => {
+					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
+							songId: newSong._id,
+							youtubeId: newSong.youtubeId
+						})
+							.then(ratings => next(null, playlist, newSong, ratings))
+							.catch(next);
+					} else {
+						next(null, playlist, newSong, null);
+					}
+				},
+
+				(playlist, newSong, ratings, next) => {
+					const { _id, title, artists, thumbnail } = newSong;
+					const songName = artists ? `${title} by ${artists.join(", ")}` : title;
 
 					if (
-						playlist.displayName !== "Liked Songs" &&
-						playlist.displayName !== "Disliked Songs" &&
+						playlist.type !== "user-liked" &&
+						playlist.type !== "user-disliked" &&
 						playlist.privacy === "public"
 					) {
 						ActivitiesModule.runJob("ADD_ACTIVITY", {
@@ -1273,13 +1283,65 @@ export default {
 							type: "playlist__remove_song",
 							payload: {
 								message: `Removed <youtubeId>${songName}</youtubeId> from playlist <playlistId>${playlist.displayName}</playlistId>`,
-								thumbnail: youtubeSong.thumbnail,
+								thumbnail,
 								playlistId,
-								youtubeId
+								youtubeId: newSong.youtubeId
 							}
 						});
 					}
 
+					if (ratings && (playlist.type === "user-liked" || playlist.type === "user-disliked")) {
+						const { likes, dislikes } = ratings;
+
+						SongsModule.runJob("UPDATE_SONG", { songId: _id });
+
+						if (playlist.type === "user-liked") {
+							CacheModule.runJob("PUB", {
+								channel: "song.unlike",
+								value: JSON.stringify({
+									youtubeId: newSong.youtubeId,
+									userId: session.userId,
+									likes,
+									dislikes
+								})
+							});
+
+							ActivitiesModule.runJob("ADD_ACTIVITY", {
+								userId: session.userId,
+								type: "song__unlike",
+								payload: {
+									message: `Removed <youtubeId>${title} by ${artists.join(
+										", "
+									)}</youtubeId> from your Liked Songs`,
+									youtubeId: newSong.youtubeId,
+									thumbnail
+								}
+							});
+						} else {
+							CacheModule.runJob("PUB", {
+								channel: "song.undislike",
+								value: JSON.stringify({
+									youtubeId: newSong.youtubeId,
+									userId: session.userId,
+									likes,
+									dislikes
+								})
+							});
+
+							ActivitiesModule.runJob("ADD_ACTIVITY", {
+								userId: session.userId,
+								type: "song__undislike",
+								payload: {
+									message: `Removed <youtubeId>${title} by ${artists.join(
+										", "
+									)}</youtubeId> from your Disliked Songs`,
+									youtubeId: newSong.youtubeId,
+									thumbnail
+								}
+							});
+						}
+					}
+
 					return next(null, playlist);
 				}
 			],
@@ -1338,7 +1400,7 @@ export default {
 				},
 
 				(playlist, next) => {
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					if (playlist.type !== "user") return next("Playlist cannot be modified.");
 					return next(null);
 				},
 
@@ -1421,7 +1483,7 @@ export default {
 
 				(playlist, next) => {
 					if (playlist.createdBy !== session.userId) return next("You do not own this playlist.");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					if (playlist.type !== "user") return next("Playlist cannot be removed.");
 					return next(null, playlist);
 				},
 
@@ -1501,7 +1563,7 @@ export default {
 				},
 
 				(playlist, next) => {
-					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					if (playlist.type !== "user") return next("Playlist cannot be removed.");
 					return next(null, playlist);
 				},
 

+ 0 - 1
backend/logic/actions/stations.js

@@ -2621,7 +2621,6 @@ export default {
 					const stationId = mongoose.Types.ObjectId();
 					playlistModel.create(
 						{
-							isUserModifiable: false,
 							displayName: `Station - ${data.name}`,
 							songs: [],
 							createdBy: data.type === "official" ? "Musare" : session.userId,

+ 4 - 4
backend/logic/actions/users.js

@@ -753,10 +753,10 @@ export default {
 
 				// create a liked songs playlist for the new user
 				(userId, next) => {
-					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+					PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 						userId,
 						displayName: "Liked Songs",
-						type: "user"
+						type: "user-liked"
 					})
 						.then(likedSongsPlaylist => {
 							next(null, likedSongsPlaylist, userId);
@@ -766,10 +766,10 @@ export default {
 
 				// create a disliked songs playlist for the new user
 				(likedSongsPlaylist, userId, next) => {
-					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+					PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 						userId,
 						displayName: "Disliked Songs",
-						type: "user"
+						type: "user-disliked"
 					})
 						.then(dislikedSongsPlaylist => {
 							next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);

+ 4 - 4
backend/logic/app.js

@@ -325,10 +325,10 @@ class _AppModule extends CoreClass {
 
 						// create a liked songs playlist for the new user
 						(userId, next) => {
-							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+							PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 								userId,
 								displayName: "Liked Songs",
-								type: "user"
+								type: "user-liked"
 							})
 								.then(likedSongsPlaylist => {
 									next(null, likedSongsPlaylist, userId);
@@ -338,10 +338,10 @@ class _AppModule extends CoreClass {
 
 						// create a disliked songs playlist for the new user
 						(likedSongsPlaylist, userId, next) => {
-							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+							PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 								userId,
 								displayName: "Disliked Songs",
-								type: "user"
+								type: "user-disliked"
 							})
 								.then(dislikedSongsPlaylist => {
 									next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);

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

@@ -8,7 +8,7 @@ import CoreClass from "../../core";
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 2,
 	news: 2,
-	playlist: 4,
+	playlist: 5,
 	punishment: 1,
 	queueSong: 1,
 	report: 5,

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

@@ -2,7 +2,6 @@ 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 },
@@ -18,6 +17,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", "station"], required: true },
-	documentVersion: { type: Number, default: 4, required: true }
+	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "station"], required: true },
+	documentVersion: { type: Number, default: 5, required: true }
 };

+ 65 - 0
backend/logic/migration/migrations/migration16.js

@@ -0,0 +1,65 @@
+import async from "async";
+
+/**
+ * Migration 16
+ *
+ * Migration for playlists to remove isUserModifiable
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 16. Finding playlists with document version 4.`);
+					playlistModel.find({ documentVersion: 4 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlisti => playlisti._doc),
+								1,
+								(playlisti, next) => {
+									// set liked/disliked playlist to new type
+									if (playlisti.type === "user" && playlisti.displayName === "Liked Songs")
+										playlisti.type = "user-liked";
+									else if (playlisti.type === "user" && playlisti.displayName === "Disliked Songs")
+										playlisti.type = "user-disliked";
+
+									// update the database
+									playlistModel.updateOne(
+										{ _id: playlisti._id },
+										{
+											$unset: {
+												isUserModifiable: ""
+											},
+											$set: {
+												type: playlisti.type,
+												documentVersion: 5
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 16. Playlists found: ${playlists.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 3 - 4
backend/logic/playlists.js

@@ -134,18 +134,18 @@ class _PlaylistsModule extends CoreClass {
 	// }
 
 	/**
-	 * Creates a playlist that is not generated or editable by a user e.g. liked songs playlist
+	 * Creates a playlist owned by a user
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.userId - the id of the user to create the playlist for
 	 * @param {string} payload.displayName - the display name of the playlist
+	 * @param {string} payload.type - the type of the playlist
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	CREATE_READ_ONLY_PLAYLIST(payload) {
+	CREATE_USER_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			PlaylistsModule.playlistModel.create(
 				{
-					isUserModifiable: false,
 					displayName: payload.displayName,
 					songs: [],
 					createdBy: payload.userId,
@@ -178,7 +178,6 @@ class _PlaylistsModule extends CoreClass {
 					if (err.message === "Playlist not found") {
 						PlaylistsModule.playlistModel.create(
 							{
-								isUserModifiable: false,
 								displayName: `Genre - ${payload.genre}`,
 								songs: [],
 								createdBy: "Musare",

+ 7 - 15
frontend/src/components/AddToPlaylistDropdown.vue

@@ -84,13 +84,9 @@ export default {
 			socket: "websockets/getSocket"
 		}),
 		...mapState({
+			playlists: state => state.user.playlists.playlists,
 			fetchedPlaylists: state => state.user.playlists.fetchedPlaylists
-		}),
-		playlists() {
-			return this.$store.state.user.playlists.playlists.filter(
-				playlist => playlist.isUserModifiable
-			);
-		}
+		})
 	},
 	mounted() {
 		ws.onConnect(this.init);
@@ -123,15 +119,11 @@ export default {
 	methods: {
 		init() {
 			if (!this.fetchedPlaylists)
-				this.socket.dispatch(
-					"playlists.indexMyPlaylists",
-					true,
-					res => {
-						if (res.status === "success")
-							if (!this.fetchedPlaylists)
-								this.setPlaylists(res.data.playlists);
-					}
-				);
+				this.socket.dispatch("playlists.indexMyPlaylists", res => {
+					if (res.status === "success")
+						if (!this.fetchedPlaylists)
+							this.setPlaylists(res.data.playlists);
+				});
 		},
 		toggleSongInPlaylist(playlistIndex) {
 			const playlist = this.playlists[playlistIndex];

+ 13 - 16
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,14 +1,14 @@
 <template>
 	<div class="settings-tab section">
-		<div v-if="isEditable()">
-			<h4 class="section-title">Edit Details</h4>
-
-			<p class="section-description">
-				Change the display name and privacy of the playlist
-			</p>
-
-			<hr class="section-horizontal-rule" />
-
+		<div
+			v-if="
+				isEditable() &&
+				!(
+					playlist.type === 'user-liked' ||
+					playlist.type === 'user-liked'
+				)
+			"
+		>
 			<label class="label"> Change display name </label>
 
 			<div class="control is-grouped input-with-button">
@@ -32,12 +32,7 @@
 			</div>
 		</div>
 
-		<div
-			v-if="
-				userId === playlist.createdBy ||
-				(playlist.type === 'genre' && isAdmin())
-			"
-		>
+		<div v-if="isEditable() || (playlist.type === 'genre' && isAdmin())">
 			<label class="label"> Change privacy </label>
 			<div class="control is-grouped input-with-button">
 				<div class="control is-expanded select">
@@ -84,7 +79,9 @@ export default {
 	methods: {
 		isEditable() {
 			return (
-				this.playlist.isUserModifiable &&
+				(this.playlist.type === "user" ||
+					this.playlist.type === "user-liked" ||
+					this.playlist.type === "user-disliked") &&
 				(this.userId === this.playlist.createdBy ||
 					this.userRole === "admin")
 			);

+ 13 - 18
frontend/src/components/modals/EditPlaylist/index.vue

@@ -223,7 +223,16 @@
 						Clear and refill genre playlist
 					</a>
 				</confirm>
-				<confirm v-if="isEditable()" @confirm="removePlaylist()">
+				<confirm
+					v-if="
+						isEditable() &&
+						!(
+							playlist.type === 'user-liked' ||
+							playlist.type === 'user-liked'
+						)
+					"
+					@confirm="removePlaylist()"
+				>
 					<a class="button is-danger"> Remove Playlist </a>
 				</confirm>
 			</div>
@@ -370,7 +379,9 @@ export default {
 		},
 		isEditable() {
 			return (
-				this.playlist.isUserModifiable &&
+				(this.playlist.type === "user" ||
+					this.playlist.type === "user-liked" ||
+					this.playlist.type === "user-disliked") &&
 				(this.userId === this.playlist.createdBy ||
 					this.userRole === "admin")
 			);
@@ -431,22 +442,6 @@ export default {
 			});
 			return this.utils.formatTimeLong(length);
 		},
-		shuffle() {
-			this.socket.dispatch(
-				"playlists.shuffle",
-				this.playlist._id,
-				res => {
-					new Toast(res.message);
-					if (res.status === "success") {
-						this.updatePlaylistSongs(
-							res.data.playlist.songs.sort(
-								(a, b) => a.position - b.position
-							)
-						);
-					}
-				}
-			);
-		},
 		removeSongFromPlaylist(id) {
 			if (this.playlist.displayName === "Liked Songs")
 				return this.socket.dispatch("songs.unlike", id, res => {

+ 1 - 1
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -886,7 +886,7 @@ export default {
 	},
 	methods: {
 		init() {
-			this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			this.socket.dispatch("playlists.indexMyPlaylists", res => {
 				if (res.status === "success")
 					this.setPlaylists(res.data.playlists);
 				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database

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

@@ -57,7 +57,6 @@
 					<tr>
 						<td>Display name</td>
 						<td>Type</td>
-						<td>Is user modifiable</td>
 						<td>Privacy</td>
 						<td>Songs #</td>
 						<td>Playlist length</td>
@@ -72,7 +71,6 @@
 					<tr v-for="playlist in playlists" :key="playlist._id">
 						<td>{{ playlist.displayName }}</td>
 						<td>{{ playlist.type }}</td>
-						<td>{{ playlist.isUserModifiable }}</td>
 						<td>{{ playlist.privacy }}</td>
 						<td>{{ playlist.songs.length }}</td>
 						<td>{{ totalLengthForPlaylist(playlist.songs) }}</td>

+ 1 - 1
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -174,7 +174,7 @@ export default {
 	methods: {
 		init() {
 			/** Get playlists for user */
-			this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			this.socket.dispatch("playlists.indexMyPlaylists", res => {
 				if (res.status === "success")
 					this.setPlaylists(res.data.playlists);
 				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database