Browse Source

feat: added bulk adding/removing songs from admin playlists

Kristian Vos 2 years ago
parent
commit
329a24214a

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

@@ -490,6 +490,50 @@ export default {
 		);
 	}),
 
+	/**
+	 * Searches through all admin playlists
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	 searchAdmin: useHasPermission("playlists.get", async function searchAdmin(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includePrivate: true,
+						includeSongs: true,
+						includeAdmin: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_ADMIN", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_ADMIN", "Searching playlists successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
 	/**
 	 * Gets the first song from a private playlist
 	 *
@@ -1253,6 +1297,336 @@ export default {
 		);
 	}),
 
+	/**
+	 * Adds songs to a playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are adding the songs to
+	 * @param {Array} youtubeIds - the YouTube ids of the songs we are trying to add
+	 * @param {Function} cb - gets called with the result
+	 */
+	addSongsToPlaylist: useHasPermission(
+		"playlists.songs.add",
+		async function addSongsToPlaylist(session, playlistId, youtubeIds, cb) {
+			const successful = [];
+			const existing = [];
+			const failed = {};
+			const errors = {};
+			const lastYoutubeId = "none";
+
+			const addError = message => {
+				if (!errors[message]) errors[message] = 1;
+				else errors[message] += 1;
+			};
+
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Bulk add songs to playlist",
+				message: "Adding songs to playlist.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
+
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								if (!playlist) return next("Playlist not found.");
+								return next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist.type !== "admin") return next("Playlist must be of type admin.");
+						return next();
+					},
+
+					next => {
+						async.eachLimit(
+							youtubeIds,
+							1,
+							(youtubeId, next) => {
+								this.publishProgress({ status: "update", message: `Adding song "${youtubeId}"` });
+								PlaylistsModule.runJob("ADD_SONG_TO_PLAYLIST", { playlistId, youtubeId }, this)
+									.then(() => {
+										successful.push(youtubeId);
+										next();
+									})
+									.catch(async err => {
+										err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+										if (err === "That song is already in the playlist.") {
+											existing.push(youtubeId);
+											next();
+										} else {
+											addError(err);
+											failed[youtubeId] = err;
+											next();
+										}
+									});
+							},
+							err => {
+								if (err) next(err);
+								else next();
+							}
+						);
+					},
+
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								if (!playlist) return next("Playlist not found.");
+								return next(null, playlist);
+							})
+							.catch(next);
+					}
+				],
+				async (err, playlist) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"PLAYLIST_ADD_SONGS",
+							`Adding songs to playlist "${playlistId}" failed for user "${
+								session.userId
+							}". "${err}". Stats: successful:${successful.length}, existing:${existing.length}, failed:${
+								Object.keys(failed).length
+							}, last youtubeId:${lastYoutubeId}, youtubeIds length:${
+								youtubeIds ? youtubeIds.length : null
+							}`
+						);
+						return cb({
+							status: "error",
+							message: err,
+							data: {
+								stats: {
+									successful,
+									existing,
+									failed,
+									errors
+								}
+							}
+						});
+					}
+
+					this.log(
+						"SUCCESS",
+						"PLAYLIST_ADD_SONGS",
+						`Successfully added songs to playlist "${playlistId}" for user "${
+							session.userId
+						}". Stats: successful:${successful.length}, existing:${existing.length}, failed:${
+							Object.keys(failed).length
+						}, youtubeIds length:${youtubeIds ? youtubeIds.length : null}`
+					);
+
+					CacheModule.runJob("PUB", {
+						channel: "playlist.updated",
+						value: { playlistId }
+					});
+
+					const message = `Done adding songs. Succesful: ${successful.length}, failed: ${
+						Object.keys(failed).length
+					}, existing: ${existing.length}.`;
+
+					this.publishProgress({
+						status: "success",
+						message
+					});
+
+					return cb({
+						status: "success",
+						message,
+						data: {
+							songs: playlist.songs,
+							stats: {
+								successful,
+								existing,
+								failed,
+								errors
+							}
+						}
+					});
+				}
+			);
+		}
+	),
+
+	/**
+	 * Removes songs from a playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are removing the songs from
+	 * @param {Array} youtubeIds - the YouTube ids of the songs we are trying to remove
+	 * @param {Function} cb - gets called with the result
+	 */
+	removeSongsFromPlaylist: useHasPermission(
+		"playlists.songs.remove",
+		async function removeSongsFromPlaylist(session, playlistId, youtubeIds, cb) {
+			const successful = [];
+			const notInPlaylist = [];
+			const failed = {};
+			const errors = {};
+			const lastYoutubeId = "none";
+
+			const addError = message => {
+				if (!errors[message]) errors[message] = 1;
+				else errors[message] += 1;
+			};
+
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Bulk remove songs from playlist",
+				message: "Removing songs from playlist.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
+
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								if (!playlist) return next("Playlist not found.");
+								return next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist.type !== "admin") return next("Playlist must be of type admin.");
+						return next();
+					},
+
+					next => {
+						async.eachLimit(
+							youtubeIds,
+							1,
+							(youtubeId, next) => {
+								this.publishProgress({ status: "update", message: `Removing song "${youtubeId}"` });
+								PlaylistsModule.runJob("REMOVE_FROM_PLAYLIST", { playlistId, youtubeId }, this)
+									.then(() => {
+										successful.push(youtubeId);
+										next();
+									})
+									.catch(async err => {
+										err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+										if (err === "That song is not currently in the playlist.") {
+											notInPlaylist.push(youtubeId);
+											next();
+										} else {
+											addError(err);
+											failed[youtubeId] = err;
+											next();
+										}
+									});
+							},
+							err => {
+								if (err) next(err);
+								else next();
+							}
+						);
+					},
+
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								if (!playlist) return next("Playlist not found.");
+								return next(null, playlist);
+							})
+							.catch(next);
+					}
+				],
+				async (err, playlist) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"PLAYLIST_REMOVE_SONGS",
+							`Removing songs from playlist "${playlistId}" failed for user "${
+								session.userId
+							}". "${err}". Stats: successful:${successful.length}, notInPlaylist:${
+								notInPlaylist.length
+							}, failed:${
+								Object.keys(failed).length
+							}, last youtubeId:${lastYoutubeId}, youtubeIds length:${
+								youtubeIds ? youtubeIds.length : null
+							}`
+						);
+						return cb({
+							status: "error",
+							message: err,
+							data: {
+								stats: {
+									successful,
+									notInPlaylist,
+									failed,
+									errors
+								}
+							}
+						});
+					}
+
+					this.log(
+						"SUCCESS",
+						"PLAYLIST_REMOVE_SONGS",
+						`Successfully removed songs from playlist "${playlistId}" for user "${
+							session.userId
+						}". Stats: successful:${successful.length}, notInPlaylist:${notInPlaylist.length}, failed:${
+							Object.keys(failed).length
+						}, youtubeIds length:${youtubeIds ? youtubeIds.length : null}`
+					);
+
+					CacheModule.runJob("PUB", {
+						channel: "playlist.updated",
+						value: { playlistId }
+					});
+
+					const message = `Done removing songs. Succesful: ${successful.length}, failed: ${
+						Object.keys(failed).length
+					}, not in playlist: ${notInPlaylist.length}.`;
+
+					this.publishProgress({
+						status: "success",
+						message
+					});
+
+					return cb({
+						status: "success",
+						message,
+						data: {
+							songs: playlist.songs,
+							stats: {
+								successful,
+								notInPlaylist,
+								failed,
+								errors
+							}
+						}
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Adds a set of songs to a private playlist
 	 *

+ 1 - 0
frontend/src/App.vue

@@ -1702,6 +1702,7 @@ h4.section-title {
 		}
 
 		.stop-icon,
+		.remove-from-playlist-icon,
 		.delete-icon {
 			color: var(--dark-red);
 		}

+ 2 - 1
frontend/src/components/ModalManager.vue

@@ -26,7 +26,8 @@ const modalComponents = shallowRef(
 		importAlbum: "ImportAlbum.vue",
 		confirm: "Confirm.vue",
 		editSong: "EditSong/index.vue",
-		viewYoutubeVideo: "ViewYoutubeVideo.vue"
+		viewYoutubeVideo: "ViewYoutubeVideo.vue",
+		bulkEditPlaylist: "BulkEditPlaylist.vue"
 	})
 );
 </script>

+ 245 - 0
frontend/src/components/modals/BulkEditPlaylist.vue

@@ -0,0 +1,245 @@
+<script setup lang="ts">
+import {
+	reactive,
+	computed,
+	defineAsyncComponent,
+	onMounted,
+	onBeforeUnmount
+} from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useModalsStore } from "@/stores/modals";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const PlaylistItem = defineAsyncComponent(
+	() => import("@/components/PlaylistItem.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	youtubeIds: { type: Array, required: true }
+});
+
+const { closeCurrentModal } = useModalsStore();
+
+const { setJob } = useLongJobsStore();
+
+const { socket } = useWebsocketsStore();
+
+const { openModal } = useModalsStore();
+
+const search = reactive({
+	query: "",
+	searchedQuery: "",
+	page: 0,
+	count: 0,
+	resultsLeft: 0,
+	pageSize: 0,
+	results: []
+});
+
+const resultsLeftCount = computed(() => search.count - search.results.length);
+
+const nextPageResultsCount = computed(() =>
+	Math.min(search.pageSize, resultsLeftCount.value)
+);
+
+const searchForPlaylists = page => {
+	if (search.page >= page || search.searchedQuery !== search.query) {
+		search.results = [];
+		search.page = 0;
+		search.count = 0;
+		search.resultsLeft = 0;
+		search.pageSize = 0;
+	}
+
+	const { query } = search;
+	const action = "playlists.searchAdmin";
+
+	search.searchedQuery = search.query;
+	socket.dispatch(action, query, page, res => {
+		const { data } = res;
+		if (res.status === "success") {
+			const { count, pageSize, playlists } = data;
+			search.results = [...search.results, ...playlists];
+			search.page = page;
+			search.count = count;
+			search.resultsLeft = count - search.results.length;
+			search.pageSize = pageSize;
+		} else if (res.status === "error") {
+			search.results = [];
+			search.page = 0;
+			search.count = 0;
+			search.resultsLeft = 0;
+			search.pageSize = 0;
+			new Toast(res.message);
+		}
+	});
+};
+
+const addSongsToPlaylist = playlistId => {
+	let id;
+	let title;
+
+	socket.dispatch(
+		"playlists.addSongsToPlaylist",
+		playlistId,
+		props.youtubeIds,
+		{
+			cb: () => {},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+					closeCurrentModal();
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+
+const removeSongsFromPlaylist = playlistId => {
+	let id;
+	let title;
+
+	socket.dispatch(
+		"playlists.removeSongsFromPlaylist",
+		playlistId,
+		props.youtubeIds,
+		{
+			cb: data => {
+				console.log("FINISHED", data);
+			},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+					closeCurrentModal();
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+</script>
+
+<template>
+	<div>
+		<modal
+			title="Bulk Edit Playlist"
+			class="bulk-edit-playlist-modal"
+			size="slim"
+		>
+			<template #body>
+				<div>
+					<label class="label">Search for a playlist</label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your playlist query here..."
+								v-model="search.query"
+								@keyup.enter="searchForPlaylists(1)"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click="searchForPlaylists(1)"
+								><i class="material-icons icon-with-button"
+									>search</i
+								>Search</a
+							>
+						</p>
+					</div>
+					<div v-if="search.results.length > 0">
+						<playlist-item
+							v-for="playlist in search.results"
+							:key="`searchKey-${playlist._id}`"
+							:playlist="playlist"
+							:show-owner="true"
+						>
+							<template #actions>
+								<quick-confirm
+									@confirm="addSongsToPlaylist(playlist._id)"
+								>
+									<i
+										class="material-icons add-to-playlist-icon"
+										:content="`Add songs to playlist`"
+										v-tippy
+									>
+										playlist_add
+									</i>
+								</quick-confirm>
+								<quick-confirm
+									@confirm="
+										removeSongsFromPlaylist(playlist._id)
+									"
+								>
+									<i
+										class="material-icons remove-from-playlist-icon"
+										:content="`Remove songs from playlist`"
+										v-tippy
+									>
+										playlist_remove
+									</i>
+								</quick-confirm>
+								<i
+									@click="
+										openModal({
+											modal: 'editPlaylist',
+											props: { playlistId: playlist._id }
+										})
+									"
+									class="material-icons edit-icon"
+									content="Edit Playlist"
+									v-tippy
+									>edit</i
+								>
+							</template>
+						</playlist-item>
+						<button
+							v-if="resultsLeftCount > 0"
+							class="button is-primary load-more-button"
+							@click="searchForPlaylists(search.page + 1)"
+						>
+							Load {{ nextPageResultsCount }} more results
+						</button>
+					</div>
+				</div>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.label {
+	text-transform: capitalize;
+}
+
+.playlist-item:not(:last-of-type) {
+	margin-bottom: 10px;
+}
+.load-more-button {
+	width: 100%;
+	margin-top: 10px;
+}
+</style>

+ 19 - 0
frontend/src/pages/Admin/Songs/index.vue

@@ -469,6 +469,15 @@ const setGenres = selectedRows => {
 	});
 };
 
+const bulkEditPlaylist = selectedRows => {
+	openModal({
+		modal: "bulkEditPlaylist",
+		props: {
+			youtubeIds: selectedRows.map(row => row.youtubeId)
+		}
+	});
+};
+
 const deleteOne = songId => {
 	socket.dispatch("songs.remove", songId, res => {
 		new Toast(res.message);
@@ -765,6 +774,16 @@ onMounted(() => {
 					>
 						theater_comedy
 					</i>
+					<i
+						v-if="hasPermission('playlists.songs.add')"
+						class="material-icons playlist-bulk-edit-icon"
+						@click.prevent="bulkEditPlaylist(slotProps.item)"
+						content="Add To Playlist"
+						v-tippy
+						tabindex="0"
+					>
+						playlist_add
+					</i>
 					<i
 						v-if="hasPermission('songs.remove')"
 						class="material-icons delete-icon"

+ 19 - 0
frontend/src/pages/Admin/YouTube/Videos.vue

@@ -250,6 +250,15 @@ const importAlbum = selectedRows => {
 	});
 };
 
+const bulkEditPlaylist = selectedRows => {
+	openModal({
+		modal: "bulkEditPlaylist",
+		props: {
+			youtubeIds: selectedRows.map(row => row.youtubeId)
+		}
+	});
+};
+
 const removeVideos = videoIds => {
 	let id;
 	let title;
@@ -429,6 +438,16 @@ const removeVideos = videoIds => {
 					>
 						album
 					</i>
+					<i
+						v-if="hasPermission('playlists.songs.add')"
+						class="material-icons playlist-bulk-edit-icon"
+						@click.prevent="bulkEditPlaylist(slotProps.item)"
+						content="Add To Playlist"
+						v-tippy
+						tabindex="0"
+					>
+						playlist_add
+					</i>
 					<i
 						v-if="hasPermission('youtube.removeVideos')"
 						class="material-icons delete-icon"