Browse Source

feat: allow importing playlist songs from playlist export file

Kristian Vos 1 year ago
parent
commit
fd07df188a

+ 148 - 133
backend/logic/actions/playlists.js

@@ -1305,149 +1305,113 @@ export default {
 	 * @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";
+	addSongsToPlaylist: isLoginRequired(async function addSongsToPlaylist(session, playlistId, youtubeIds, cb) {
+		const successful = [];
+		const existing = [];
+		const failed = {};
+		const errors = {};
+		const lastYoutubeId = "none";
+		const songsAdded = [];
+
+		const addError = message => {
+			if (!errors[message]) errors[message] = 1;
+			else errors[message] += 1;
+		};
 
-			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 }
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							if (!playlist) return next("Playlist not found.");
+							if (playlist.createdBy !== session.userId)
+								return hasPermission("playlists.songs.add", session)
+									.then(() => next(null, playlist))
+									.catch(() => next("Invalid permissions."));
+							return next(null, playlist);
+						})
+						.catch(next);
 				},
-				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();
-					},
+				async () => {
+					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
+					);
+				},
 
-					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);
+				(nothing, 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(({ song }) => {
+									successful.push(youtubeId);
+									songsAdded.push(song);
+									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();
-									})
-									.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
-								}
-							}
-						});
-					}
+									} 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(
-						"SUCCESS",
+						"ERROR",
 						"PLAYLIST_ADD_SONGS",
-						`Successfully added songs to playlist "${playlistId}" for user "${
+						`Adding songs to playlist "${playlistId}" failed for user "${
 							session.userId
-						}". Stats: successful:${successful.length}, existing:${existing.length}, failed:${
+						}". "${err}". Stats: successful:${successful.length}, existing:${existing.length}, failed:${
 							Object.keys(failed).length
-						}, youtubeIds length:${youtubeIds ? youtubeIds.length : null}`
+						}, last youtubeId:${lastYoutubeId}, 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,
+						status: "error",
+						message: err,
 						data: {
-							songs: playlist.songs,
 							stats: {
 								successful,
 								existing,
@@ -1457,9 +1421,60 @@ export default {
 						}
 					});
 				}
-			);
-		}
-	),
+
+				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}`
+				);
+
+				await async.eachLimit(songsAdded, 1, (song, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "playlist.addSong",
+						value: {
+							playlistId: playlist._id,
+							song,
+							createdBy: playlist.createdBy,
+							privacy: playlist.privacy
+						}
+					});
+					next();
+				});
+
+				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

+ 126 - 4
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { ref } from "vue";
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useLongJobsStore } from "@/stores/longJobs";
@@ -19,7 +20,10 @@ const { setJob } = useLongJobsStore();
 
 const { youtubeSearch } = useSearchYoutube();
 
-const importPlaylist = () => {
+const importMusarePlaylistFileInput = ref();
+const importMusarePlaylistFileContents = ref(null);
+
+const importYoutubePlaylist = () => {
 	let id;
 	let title;
 
@@ -64,11 +68,89 @@ const importPlaylist = () => {
 		}
 	);
 };
+
+const onMusarePlaylistFileChange = () => {
+	const reader = new FileReader();
+	const fileInput = importMusarePlaylistFileInput.value as HTMLInputElement;
+	const file = fileInput.files.item(0);
+
+	reader.readAsText(file, "UTF-8");
+	reader.onload = ({ target }) => {
+		const { result } = target;
+
+		try {
+			const parsed = JSON.parse(result.toString());
+
+			if (!parsed)
+				new Toast(
+					"An error occured whilst parsing the playlist file. Is it valid?"
+				);
+			else importMusarePlaylistFileContents.value = parsed;
+		} catch (err) {
+			new Toast(
+				"An error occured whilst parsing the playlist file. Is it valid?"
+			);
+		}
+	};
+
+	reader.onerror = evt => {
+		console.log(evt);
+		new Toast(
+			"An error occured whilst reading the playlist file. Is it valid?"
+		);
+	};
+};
+
+const importMusarePlaylistFile = () => {
+	let id;
+	let title;
+
+	let youtubeIds = [];
+
+	if (!importMusarePlaylistFileContents.value)
+		return new Toast("Please choose a Musare playlist file first.");
+
+	if (importMusarePlaylistFileContents.value.playlist) {
+		youtubeIds = importMusarePlaylistFileContents.value.playlist.songs.map(
+			song => song.youtubeId
+		);
+	} else if (importMusarePlaylistFileContents.value.songs) {
+		youtubeIds = importMusarePlaylistFileContents.value.songs.map(
+			song => song.youtubeId
+		);
+	}
+
+	if (youtubeIds.length === 0) return new Toast("No songs to import.");
+
+	return socket.dispatch(
+		"playlists.addSongsToPlaylist",
+		playlist.value._id,
+		youtubeIds,
+		{
+			cb: res => {
+				new Toast(res.message);
+			},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
 </script>
 
 <template>
 	<div class="youtube-tab section">
-		<label class="label"> Search for a playlist from YouTube </label>
+		<label class="label"> Import songs from YouTube playlist </label>
 		<div class="control is-grouped input-with-button">
 			<p class="control is-expanded">
 				<input
@@ -76,7 +158,7 @@ const importPlaylist = () => {
 					type="text"
 					placeholder="Enter YouTube Playlist URL here..."
 					v-model="youtubeSearch.playlist.query"
-					@keyup.enter="importPlaylist()"
+					@keyup.enter="importYoutubePlaylist()"
 				/>
 			</p>
 			<p class="control has-addons">
@@ -90,7 +172,29 @@ const importPlaylist = () => {
 				</span>
 				<button
 					class="button is-info"
-					@click.prevent="importPlaylist()"
+					@click.prevent="importYoutubePlaylist()"
+				>
+					<i class="material-icons icon-with-button">publish</i>Import
+				</button>
+			</p>
+		</div>
+
+		<label class="label"> Import songs from a Musare playlist file </label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="file"
+					placeholder="Enter YouTube Playlist URL here..."
+					@change="onMusarePlaylistFileChange"
+					ref="importMusarePlaylistFileInput"
+					@keyup.enter="importMusarePlaylistFile()"
+				/>
+			</p>
+			<p class="control">
+				<button
+					class="button is-info"
+					@click.prevent="importMusarePlaylistFile()"
 				>
 					<i class="material-icons icon-with-button">publish</i>Import
 				</button>
@@ -104,6 +208,24 @@ const importPlaylist = () => {
 	border-radius: 0;
 }
 
+input[type="file"] {
+	padding-left: 0;
+}
+
+input[type="file"]::file-selector-button {
+	background: var(--light-grey);
+	border: none;
+	height: 100%;
+	border-right: 1px solid var(--light-grey-3);
+	margin-right: 8px;
+	padding: 0 8px;
+	cursor: pointer;
+}
+
+input[type="file"]::file-selector-button:hover {
+	background: var(--light-grey-2);
+}
+
 @media screen and (max-width: 1300px) {
 	.youtube-tab #song-query-results,
 	.section {

+ 1 - 1
frontend/src/components/modals/EditPlaylist/index.vue

@@ -364,7 +364,7 @@ onBeforeUnmount(() => {
 							@click="showTab('import-playlists')"
 							v-if="isEditable('playlists.songs.add')"
 						>
-							Import Playlists
+							Import Songs
 						</button>
 					</div>
 					<settings