Kaynağa Gözat

Merge branch 'polishing' of github.com:Musare/MusareNode into polishing

Jonathan 3 yıl önce
ebeveyn
işleme
6d755ad61c

+ 23 - 12
backend/logic/actions/songs.js

@@ -656,11 +656,12 @@ export default {
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} youtubeId - the youtube id of the song that gets requested
+	 * @param {string} returnSong - returns the simple song
 	 * @param {Function} cb - gets called with the result
 	 */
-	request: isLoginRequired(async function add(session, youtubeId, cb) {
+	request: isLoginRequired(async function add(session, youtubeId, returnSong, cb) {
 		SongsModule.runJob("REQUEST_SONG", { youtubeId, userId: session.userId }, this)
-			.then(() => {
+			.then(response => {
 				this.log(
 					"SUCCESS",
 					"SONGS_REQUEST",
@@ -668,17 +669,18 @@ export default {
 				);
 				return cb({
 					status: "success",
-					message: "Successfully requested that song"
+					message: "Successfully requested that song",
+					song: returnSong ? response.song : null
 				});
 			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+			.catch(async _err => {
+				const err = await UtilsModule.runJob("GET_ERROR", { error: _err }, this);
 				this.log(
 					"ERROR",
 					"SONGS_REQUEST",
 					`Requesting song "${youtubeId}" failed for user ${session.userId}. "${err}"`
 				);
-				return cb({ status: "error", message: err });
+				return cb({ status: "error", message: err, song: returnSong && _err.data ? _err.data.song : null });
 			});
 	}),
 
@@ -884,7 +886,7 @@ export default {
 	 * @param {boolean} musicOnly - whether to only get music from the playlist
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, cb) {
+	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, returnSongs, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -903,22 +905,23 @@ export default {
 				},
 				(youtubeIds, next) => {
 					let successful = 0;
+					let songs = {};
 					let failed = 0;
 					let alreadyInDatabase = 0;
 
 					if (youtubeIds.length === 0) next();
 
-					async.eachLimit(
+					async.eachOfLimit(
 						youtubeIds,
 						1,
-						(youtubeId, next) => {
+						(youtubeId, index, next) => {
 							WSModule.runJob(
 								"RUN_ACTION2",
 								{
 									session,
 									namespace: "songs",
 									action: "request",
-									args: [youtubeId]
+									args: [youtubeId, returnSongs]
 								},
 								this
 							)
@@ -926,6 +929,8 @@ export default {
 									if (res.status === "success") successful += 1;
 									else failed += 1;
 									if (res.message === "This song is already in the database.") alreadyInDatabase += 1;
+									if (res.song) songs[index] = res.song;
+									else songs[index] = null;
 								})
 								.catch(() => {
 									failed += 1;
@@ -935,7 +940,12 @@ export default {
 								});
 						},
 						() => {
-							next(null, { successful, failed, alreadyInDatabase });
+							if (returnSongs)
+								songs = Object.keys(songs)
+									.sort()
+									.map(key => songs[key]);
+
+							next(null, { successful, failed, alreadyInDatabase, songs });
 						}
 					);
 				}
@@ -957,7 +967,8 @@ export default {
 				);
 				return cb({
 					status: "success",
-					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
+					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+					songs: returnSongs ? response.songs : null
 				});
 			}
 		);

+ 24 - 3
backend/logic/songs.js

@@ -11,6 +11,13 @@ let YouTubeModule;
 let StationsModule;
 let PlaylistsModule;
 
+class ErrorWithData extends Error {
+	constructor(message, data) {
+		super(message);
+		this.data = data;
+	}
+}
+
 class _SongsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
@@ -907,7 +914,7 @@ class _SongsModule extends CoreClass {
 
 					// Get YouTube data from id
 					(user, song, next) => {
-						if (song) return next("This song is already in the database.");
+						if (song) return next("This song is already in the database.", song);
 						// TODO Add err object as first param of callback
 
 						const requestedBy = user.preferences.anonymousSongRequests ? null : userId;
@@ -953,7 +960,21 @@ class _SongsModule extends CoreClass {
 					}
 				],
 				async (err, song) => {
-					if (err) reject(err);
+					if (err && err !== "This song is already in the database.") return reject(err);
+
+					const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+					const trimmedSong = {
+						_id,
+						youtubeId,
+						title,
+						artists,
+						thumbnail,
+						duration,
+						status
+					};
+
+					if (err && err === "This song is already in the database.")
+						return reject(new ErrorWithData(err, { song: trimmedSong }));
 
 					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
@@ -962,7 +983,7 @@ class _SongsModule extends CoreClass {
 						value: song._id
 					});
 
-					resolve();
+					return resolve({ song: trimmedSong });
 				}
 			);
 		});

+ 7 - 1
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -109,6 +109,12 @@
 						Data quality:
 						<span>{{ result.dataQuality }}</span>
 					</p>
+					<button
+						class="button is-primary"
+						@click="importAlbum(result)"
+					>
+						Import album
+					</button>
 					<div class="tracks">
 						<div
 							class="track"
@@ -358,7 +364,7 @@ export default {
 		}
 
 		.bottom-container-field:last-of-type {
-			margin-bottom: 0;
+			margin-bottom: 8px;
 		}
 	}
 

+ 53 - 37
frontend/src/components/modals/EditSong/index.vue

@@ -332,12 +332,28 @@
 				</div>
 			</template>
 			<template #footer>
-				<save-button ref="saveButton" @clicked="save(song, false)" />
+				<save-button
+					ref="saveButton"
+					@clicked="save(song, false, false)"
+				/>
 				<save-button
 					ref="saveAndCloseButton"
 					type="save-and-close"
-					@clicked="save(song, true)"
+					@clicked="save(song, false, true)"
 				/>
+				<button
+					class="button is-primary"
+					@click="save(song, true, true)"
+				>
+					Save, verify and close
+				</button>
+				<button
+					class="button is-danger"
+					@click="stopEditingSongs()"
+					v-if="modals.importAlbum && editingSongs"
+				>
+					Stop editing songs
+				</button>
 				<div class="right">
 					<button
 						v-if="song.status !== 'verified'"
@@ -434,6 +450,8 @@ export default {
 	components: { Modal, FloatingBox, SaveButton, Confirm, Discogs, Reports },
 	props: {
 		youtubeId: { type: String, default: null },
+		songId: { type: String, default: null },
+		discogsAlbum: { type: Object, default: null },
 		// songType: { type: String, default: null },
 		sector: { type: String, default: "admin" }
 	},
@@ -504,6 +522,9 @@ export default {
 			originalSong: state => state.originalSong,
 			reports: state => state.reports
 		}),
+		...mapState("modals/importAlbum", {
+			editingSongs: state => state.editingSongs
+		}),
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
@@ -541,7 +562,9 @@ export default {
 				// this.song = { ...song };
 				// if (this.song.discogs === undefined)
 				// 	this.song.discogs = null;
-				this.editSong(song);
+				if (this.song.discogs)
+					this.editSong({ ...song, discogs: this.song.discogs });
+				else this.editSong(song);
 
 				this.songDataLoaded = true;
 
@@ -728,7 +751,9 @@ export default {
 		this.socket.on(
 			"event:admin.hiddenSong.created",
 			res => {
-				this.song.status = res.data.song.status;
+				if (res.data.songId === this.song._id) {
+					this.song.status = res.data.song.status;
+				}
 			},
 			{ modal: "editSong" }
 		);
@@ -736,7 +761,9 @@ export default {
 		this.socket.on(
 			"event:admin.unverifiedSong.created",
 			res => {
-				this.song.status = res.data.song.status;
+				if (res.data.songId === this.song._id) {
+					this.song.status = res.data.song.status;
+				}
 			},
 			{ modal: "editSong" }
 		);
@@ -744,38 +771,12 @@ export default {
 		this.socket.on(
 			"event:admin.verifiedSong.created",
 			res => {
-				this.song.status = res.data.song.status;
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.hiddenSong.deleted",
-			() => {
-				new Toast("The song you were editing was removed");
-				this.closeModal("editSong");
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.unverifiedSong.deleted",
-			() => {
-				new Toast("The song you were editing was removed");
-				this.closeModal("editSong");
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.verifiedSong.deleted",
-			() => {
-				new Toast("The song you were editing was removed");
-				this.closeModal("editSong");
+				if (res.data.songId === this.song._id) {
+					this.song.status = res.data.song.status;
+				}
 			},
 			{ modal: "editSong" }
 		);
-
 		keyboardShortcuts.registerShortcut("editSong.pauseResumeVideo", {
 			keyCode: 101,
 			preventDefault: true,
@@ -857,7 +858,7 @@ export default {
 			ctrl: true,
 			preventDefault: true,
 			handler: () => {
-				this.save(this.song, false);
+				this.save(this.song, false, false);
 			}
 		});
 
@@ -939,6 +940,7 @@ export default {
 		*/
 	},
 	beforeUnmount() {
+		this.video.player.stopVideo();
 		this.playerReady = false;
 		clearInterval(this.interval);
 		clearInterval(this.activityWatchVideoDataInterval);
@@ -967,7 +969,16 @@ export default {
 		});
 	},
 	methods: {
-		save(songToCopy, close) {
+		stopEditingSongs() {
+			this.updateEditingSongs(false);
+			this.closeModal("editSong");
+		},
+		importAlbum(result) {
+			this.selectDiscogsAlbum(result);
+			this.openModal("importAlbum");
+			this.closeModal("editSong");
+		},
+		save(songToCopy, verify, close) {
 			const song = JSON.parse(JSON.stringify(songToCopy));
 
 			let saveButtonRef = this.$refs.saveButton;
@@ -1124,6 +1135,7 @@ export default {
 					saveButtonRef.handleSuccessfulSave();
 				else saveButtonRef.handleFailedSave();
 
+				if (verify) this.verify(this.song._id);
 				if (close) this.closeModal("editSong");
 			});
 		},
@@ -1397,6 +1409,10 @@ export default {
 		// 		new Toast(res.message);
 		// 	});
 		// },
+		...mapActions("modals/importAlbum", [
+			"selectDiscogsAlbum",
+			"updateEditingSongs"
+		]),
 		...mapActions({
 			showTab(dispatch, payload) {
 				this.$refs[`${payload}-tab`].scrollIntoView();
@@ -1412,7 +1428,7 @@ export default {
 			"updateSongField",
 			"updateReports"
 		]),
-		...mapActions("modalVisibility", ["closeModal"])
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };
 </script>

+ 932 - 0
frontend/src/components/modals/ImportAlbum.vue

@@ -0,0 +1,932 @@
+<template>
+	<div>
+		<modal title="Import Album" class="import-album-modal">
+			<template #body>
+				<div class="search-discogs-album">
+					<p class="control is-expanded">
+						<label class="label">Search query</label>
+						<input
+							class="input"
+							type="text"
+							ref="discogs-input"
+							v-model="discogsQuery"
+							@keyup.enter="searchDiscogsForPage(1)"
+							@change="onDiscogsQueryChange"
+							v-focus
+						/>
+					</p>
+					<button
+						class="button is-fullwidth is-info"
+						@click="searchDiscogsForPage(1)"
+					>
+						Search
+					</button>
+					<button
+						class="button is-fullwidth is-danger"
+						@click="clearDiscogsResults()"
+					>
+						Clear
+					</button>
+					<label class="label" v-if="discogs.apiResults.length > 0"
+						>API results</label
+					>
+					<div
+						class="api-results-container"
+						v-if="discogs.apiResults.length > 0"
+					>
+						<div
+							class="api-result"
+							v-for="(result, index) in discogs.apiResults"
+							:key="result.album.id"
+							tabindex="0"
+							@keydown.space.prevent
+							@keyup.enter="toggleAPIResult(index)"
+						>
+							<div class="top-container">
+								<img :src="result.album.albumArt" />
+								<div class="right-container">
+									<p class="album-title">
+										{{ result.album.title }}
+									</p>
+									<div class="bottom-row">
+										<img
+											src="/assets/arrow_up.svg"
+											v-if="result.expanded"
+											@click="toggleAPIResult(index)"
+										/>
+										<img
+											src="/assets/arrow_down.svg"
+											v-if="!result.expanded"
+											@click="toggleAPIResult(index)"
+										/>
+										<p class="type-year">
+											<span>{{ result.album.type }}</span>
+											•
+											<span>{{ result.album.year }}</span>
+										</p>
+									</div>
+								</div>
+							</div>
+							<div
+								class="bottom-container"
+								v-if="result.expanded"
+							>
+								<p class="bottom-container-field">
+									Artists:
+									<span>{{
+										result.album.artists.join(", ")
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Genres:
+									<span>{{
+										result.album.genres.join(", ")
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Data quality:
+									<span>{{ result.dataQuality }}</span>
+								</p>
+								<button
+									class="button is-primary"
+									@click="selectAlbum(result)"
+								>
+									Import album
+								</button>
+								<div class="tracks">
+									<div
+										class="track"
+										v-for="track in result.tracks"
+										:key="
+											`${track.position}-${track.title}`
+										"
+									>
+										<span>{{ track.position }}.</span>
+										<p>{{ track.title }}</p>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+					<button
+						v-if="
+							discogs.apiResults.length > 0 &&
+								!discogs.disableLoadMore &&
+								discogs.page < discogs.pages
+						"
+						class="button is-fullwidth is-info discogs-load-more"
+						@click="loadNextDiscogsPage()"
+					>
+						Load more...
+					</button>
+				</div>
+				<div
+					class="discogs-album"
+					v-if="discogsAlbum && discogsAlbum.album"
+				>
+					<div class="top-container">
+						<img :src="discogsAlbum.album.albumArt" />
+						<div class="right-container">
+							<p class="album-title">
+								{{ discogsAlbum.album.title }}
+							</p>
+							<div class="bottom-row">
+								<img
+									src="/assets/arrow_up.svg"
+									v-if="discogsAlbum.expanded"
+									@click="toggleDiscogsAlbum()"
+								/>
+								<img
+									src="/assets/arrow_down.svg"
+									v-if="!discogsAlbum.expanded"
+									@click="toggleDiscogsAlbum()"
+								/>
+								<p class="type-year">
+									<span>{{ discogsAlbum.album.type }}</span>
+									•
+									<span>{{ discogsAlbum.album.year }}</span>
+								</p>
+							</div>
+						</div>
+					</div>
+					<div class="bottom-container" v-if="discogsAlbum.expanded">
+						<p class="bottom-container-field">
+							Artists:
+							<span>{{
+								discogsAlbum.album.artists.join(", ")
+							}}</span>
+						</p>
+						<p class="bottom-container-field">
+							Genres:
+							<span>{{
+								discogsAlbum.album.genres.join(", ")
+							}}</span>
+						</p>
+						<p class="bottom-container-field">
+							Data quality:
+							<span>{{ discogsAlbum.dataQuality }}</span>
+						</p>
+						<div class="tracks">
+							<div
+								class="track"
+								tabindex="0"
+								v-for="track in discogsAlbum.tracks"
+								:key="`${track.position}-${track.title}`"
+							>
+								<span>{{ track.position }}.</span>
+								<p>{{ track.title }}</p>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="break"></div>
+				<div
+					class="import-youtube-playlist"
+					v-if="discogsAlbum && discogsAlbum.album"
+				>
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="Enter YouTube Playlist URL here..."
+							v-model="search.playlist.query"
+							@keyup.enter="importPlaylist()"
+						/>
+					</p>
+					<button
+						class="button is-fullwidth is-info"
+						@click="importPlaylist()"
+					>
+						<i class="material-icons icon-with-button">publish</i
+						>Import
+					</button>
+					<button
+						class="button is-fullwidth is-danger"
+						@click="resetTrackSongs()"
+					>
+						Reset
+					</button>
+					<draggable
+						v-if="playlistSongs.length > 0"
+						group="songs"
+						v-model="playlistSongs"
+						item-key="_id"
+						@start="drag = true"
+						@end="drag = false"
+						@change="log"
+					>
+						<template #item="{element}">
+							<song-item
+								:key="`playlist-song-${element._id}`"
+								:song="element"
+							>
+							</song-item>
+						</template>
+					</draggable>
+				</div>
+				<div
+					class="track-boxes"
+					v-if="discogsAlbum && discogsAlbum.album"
+				>
+					<div
+						class="track-box"
+						v-for="(track, index) in discogsAlbum.tracks"
+						:key="`${track.position}-${track.title}`"
+					>
+						<div class="track-position-title">
+							<span>{{ track.position }}.</span>
+							<p>{{ track.title }}</p>
+						</div>
+						<draggable
+							class="track-box-songs-drag-area"
+							group="songs"
+							v-model="trackSongs[index]"
+							item-key="_id"
+							@start="drag = true"
+							@end="drag = false"
+							@change="log"
+						>
+							<template #item="{element}">
+								<song-item
+									:key="`track-song-${element._id}`"
+									:song="element"
+								>
+								</song-item>
+							</template>
+						</draggable>
+					</div>
+				</div>
+			</template>
+			<template #footer>
+				<button class="button is-primary" @click="tryToAutoMove()">
+					Try to auto move
+				</button>
+				<button class="button is-primary" @click="editSongs()">
+					Edit songs
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from "vuex";
+
+import draggable from "vuedraggable";
+import Toast from "toasters";
+
+import Modal from "../Modal.vue";
+
+import SongItem from "../SongItem.vue";
+
+export default {
+	components: { Modal, SongItem, draggable },
+	props: {
+		// songType: { type: String, default: null },
+		sector: { type: String, default: "admin" }
+	},
+	data() {
+		return {
+			stuff: false,
+			isImportingPlaylist: false,
+			trackSongs: [],
+			songsToEdit: [],
+			currentEditSongIndex: 0,
+			search: {
+				playlist: {
+					query: ""
+				}
+			},
+			discogsQuery: "",
+			discogs: {
+				apiResults: [],
+				page: 1,
+				pages: 1,
+				disableLoadMore: false
+			}
+		};
+	},
+	computed: {
+		playlistSongs: {
+			get() {
+				return this.$store.state.modals.importAlbum.playlistSongs;
+			},
+			set(playlistSongs) {
+				this.$store.commit(
+					"modals/importAlbum/updatePlaylistSongs",
+					playlistSongs
+				);
+			}
+		},
+		...mapState("modals/importAlbum", {
+			discogsAlbum: state => state.discogsAlbum,
+			editingSongs: state => state.editingSongs
+		}),
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	watch: {
+		/* eslint-disable */
+		"modals.editSong": function(value) {
+			if (!value) this.editNextSong();
+		}
+		/* eslint-enable */
+	},
+	beforeUnmount() {
+		this.selectDiscogsAlbum({});
+		this.setPlaylistSongs([]);
+	},
+	methods: {
+		editSongs() {
+			this.updateEditingSongs(true);
+			this.songsToEdit = [];
+			this.trackSongs.forEach((songs, index) => {
+				songs.forEach(song => {
+					const discogsAlbum = JSON.parse(
+						JSON.stringify(this.discogsAlbum)
+					);
+					discogsAlbum.track = discogsAlbum.tracks[index];
+					delete discogsAlbum.tracks;
+					delete discogsAlbum.expanded;
+					delete discogsAlbum.gotMoreInfo;
+
+					this.songsToEdit.push({
+						songId: song._id,
+						discogs: discogsAlbum
+					});
+				});
+			});
+			this.editNextSong();
+		},
+		editNextSong() {
+			if (this.editingSongs) {
+				setTimeout(() => {
+					this.editSong({
+						_id: this.songsToEdit[this.currentEditSongIndex].songId,
+						discogs: this.songsToEdit[this.currentEditSongIndex]
+							.discogs
+					});
+					this.currentEditSongIndex += 1;
+					this.openModal("editSong");
+				}, 500);
+			}
+		},
+		log(evt) {
+			window.console.log(evt);
+		},
+		importPlaylist() {
+			if (this.isImportingPlaylist)
+				return new Toast("A playlist is already importing.");
+			this.isImportingPlaylist = true;
+
+			// import query is blank
+			if (!this.search.playlist.query)
+				return new Toast("Please enter a YouTube playlist URL.");
+
+			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const splitQuery = regex.exec(this.search.playlist.query);
+
+			if (!splitQuery) {
+				return new Toast({
+					content: "Please enter a valid YouTube playlist URL.",
+					timeout: 4000
+				});
+			}
+
+			// don't give starting import message instantly in case of instant error
+			setTimeout(() => {
+				if (this.isImportingPlaylist) {
+					new Toast(
+						"Starting to import your playlist. This can take some time to do."
+					);
+				}
+			}, 750);
+
+			return this.socket.dispatch(
+				"songs.requestSet",
+				this.search.playlist.query,
+				false,
+				true,
+				res => {
+					this.isImportingPlaylist = false;
+					const songs = res.songs.filter(
+						song => song.status !== "verified"
+					);
+					const songsAlreadyVerified =
+						res.songs.length - songs.length;
+					this.setPlaylistSongs(songs);
+					this.trackSongs = this.discogsAlbum.tracks.map(() => []);
+					this.tryToAutoMove();
+					if (songsAlreadyVerified > 0)
+						new Toast(
+							`${songsAlreadyVerified} songs were already verified, skipping those.`
+						);
+					return new Toast({ content: res.message, timeout: 20000 });
+				}
+			);
+		},
+		tryToAutoMove() {
+			const { tracks } = this.discogsAlbum;
+			const { trackSongs } = this;
+			const playlistSongs = JSON.parse(
+				JSON.stringify(this.playlistSongs)
+			);
+
+			tracks.forEach((track, index) => {
+				playlistSongs.forEach(playlistSong => {
+					if (
+						playlistSong.title
+							.toLowerCase()
+							.trim()
+							.indexOf(track.title.toLowerCase().trim()) !== -1
+					) {
+						playlistSongs.splice(
+							playlistSongs.indexOf(playlistSong),
+							1
+						);
+						trackSongs[index].push(playlistSong);
+					}
+				});
+			});
+
+			this.updatePlaylistSongs(playlistSongs);
+		},
+		resetTrackSongs() {
+			this.resetPlaylistSongs();
+			this.trackSongs = this.discogsAlbum.tracks.map(() => []);
+		},
+		selectAlbum(result) {
+			this.selectDiscogsAlbum(result);
+			this.clearDiscogsResults();
+		},
+		toggleAPIResult(index) {
+			const apiResult = this.discogs.apiResults[index];
+			if (apiResult.expanded === true) apiResult.expanded = false;
+			else if (apiResult.gotMoreInfo === true) apiResult.expanded = true;
+			else {
+				fetch(apiResult.album.resourceUrl)
+					.then(response => {
+						return response.json();
+					})
+					.then(data => {
+						apiResult.album.artists = [];
+						apiResult.album.artistIds = [];
+						const artistRegex = new RegExp(" \\([0-9]+\\)$");
+
+						apiResult.dataQuality = data.data_quality;
+						data.artists.forEach(artist => {
+							apiResult.album.artists.push(
+								artist.name.replace(artistRegex, "")
+							);
+							apiResult.album.artistIds.push(artist.id);
+						});
+						apiResult.tracks = data.tracklist.map(track => {
+							return {
+								position: track.position,
+								title: track.title
+							};
+						});
+						apiResult.expanded = true;
+						apiResult.gotMoreInfo = true;
+					});
+			}
+		},
+		clearDiscogsResults() {
+			this.discogs.apiResults = [];
+			this.discogs.page = 1;
+			this.discogs.pages = 1;
+			this.discogs.disableLoadMore = false;
+		},
+		searchDiscogsForPage(page) {
+			const query = this.discogsQuery;
+
+			this.socket.dispatch("apis.searchDiscogs", query, page, res => {
+				if (res.status === "success") {
+					if (page === 1)
+						new Toast(
+							`Successfully searched. Got ${res.data.results.length} results.`
+						);
+					else
+						new Toast(
+							`Successfully got ${res.data.results.length} more results.`
+						);
+
+					if (page === 1) {
+						this.discogs.apiResults = [];
+					}
+
+					this.discogs.pages = res.data.pages;
+
+					this.discogs.apiResults = this.discogs.apiResults.concat(
+						res.data.results.map(result => {
+							const type =
+								result.type.charAt(0).toUpperCase() +
+								result.type.slice(1);
+
+							return {
+								expanded: false,
+								gotMoreInfo: false,
+								album: {
+									id: result.id,
+									title: result.title,
+									type,
+									year: result.year,
+									genres: result.genre,
+									albumArt: result.cover_image,
+									resourceUrl: result.resource_url
+								}
+							};
+						})
+					);
+
+					this.discogs.page = page;
+					this.discogs.disableLoadMore = false;
+				} else new Toast(res.message);
+			});
+		},
+		loadNextDiscogsPage() {
+			this.discogs.disableLoadMore = true;
+			this.searchDiscogsForPage(this.discogs.page + 1);
+		},
+		onDiscogsQueryChange() {
+			this.discogs.page = 1;
+			this.discogs.pages = 1;
+			this.discogs.apiResults = [];
+			this.discogs.disableLoadMore = false;
+		},
+		...mapActions("modals/importAlbum", [
+			"toggleDiscogsAlbum",
+			"setPlaylistSongs",
+			"updatePlaylistSongs",
+			"selectDiscogsAlbum",
+			"updateEditingSongs",
+			"resetPlaylistSongs"
+		]),
+		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
+	}
+};
+</script>
+
+<style lang="scss">
+.import-album-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
+	}
+
+	// .import-album-modal-body {
+	// 	display: flex;
+	// 	flex-direction: row;
+	// 	flex-wrap: wrap;
+	// 	justify-content: space-evenly;
+	// }
+
+	.modal-card {
+		width: 100%;
+		height: 100%;
+
+		.modal-card-body {
+			padding: 16px;
+			display: flex;
+			flex-direction: row;
+			flex-wrap: wrap;
+			justify-content: space-evenly;
+		}
+
+		.modal-card-foot {
+			.button {
+				margin: 0;
+			}
+
+			div div {
+				margin-right: 5px;
+			}
+			.right {
+				display: flex;
+				margin-left: auto;
+				margin-right: 0;
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.break {
+	flex-basis: 100%;
+	height: 0;
+	border: 1px solid var(--dark-grey);
+	margin-top: 16px;
+	margin-bottom: 16px;
+}
+
+.search-discogs-album {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	> label {
+		margin-top: 12px;
+	}
+
+	.top-container {
+		display: flex;
+
+		img {
+			height: 85px;
+			width: 85px;
+		}
+
+		.right-container {
+			padding: 8px;
+			display: flex;
+			flex-direction: column;
+			flex: 1;
+
+			.album-title {
+				flex: 1;
+				font-weight: 600;
+			}
+
+			.bottom-row {
+				display: flex;
+				flex-flow: row;
+				line-height: 15px;
+
+				img {
+					height: 15px;
+					align-self: end;
+					flex: 1;
+					user-select: none;
+					-moz-user-select: none;
+					-ms-user-select: none;
+					-webkit-user-select: none;
+					cursor: pointer;
+				}
+
+				p {
+					text-align: right;
+				}
+
+				.type-year {
+					font-size: 13px;
+					align-self: end;
+				}
+			}
+		}
+	}
+
+	.bottom-container {
+		padding: 12px;
+
+		.bottom-container-field {
+			line-height: 16px;
+			margin-bottom: 8px;
+			font-weight: 600;
+
+			span {
+				font-weight: 400;
+			}
+		}
+
+		.bottom-container-field:last-of-type {
+			margin-bottom: 8px;
+		}
+	}
+
+	.api-result {
+		background-color: var(--white);
+		border: 0.5px solid var(--primary-color);
+		border-radius: 5px;
+		margin-bottom: 16px;
+	}
+
+	button {
+		&:focus,
+		&:hover {
+			filter: contrast(0.75);
+		}
+	}
+
+	.tracks {
+		margin-top: 12px;
+
+		.track:first-child {
+			margin-top: 0;
+			border-radius: 3px 3px 0 0;
+		}
+
+		.track:last-child {
+			border-radius: 0 0 3px 3px;
+		}
+
+		.track {
+			border: 0.5px solid var(--black);
+			margin-top: -1px;
+			line-height: 16px;
+			display: flex;
+
+			span {
+				font-weight: 600;
+				display: inline-block;
+				margin-top: 7px;
+				margin-bottom: 7px;
+				margin-left: 7px;
+			}
+
+			p {
+				display: inline-block;
+				margin: 7px;
+				flex: 1;
+			}
+		}
+	}
+
+	.discogs-load-more {
+		margin-bottom: 8px;
+	}
+}
+
+.discogs-album {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	.top-container {
+		display: flex;
+
+		img {
+			height: 85px;
+			width: 85px;
+		}
+
+		.right-container {
+			padding: 8px;
+			display: flex;
+			flex-direction: column;
+			flex: 1;
+
+			.album-title {
+				flex: 1;
+				font-weight: 600;
+			}
+
+			.bottom-row {
+				display: flex;
+				flex-flow: row;
+				line-height: 15px;
+
+				img {
+					height: 15px;
+					align-self: end;
+					flex: 1;
+					user-select: none;
+					-moz-user-select: none;
+					-ms-user-select: none;
+					-webkit-user-select: none;
+					cursor: pointer;
+				}
+
+				p {
+					text-align: right;
+				}
+
+				.type-year {
+					font-size: 13px;
+					align-self: end;
+				}
+			}
+		}
+	}
+
+	.bottom-container {
+		padding: 12px;
+
+		.bottom-container-field {
+			line-height: 16px;
+			margin-bottom: 8px;
+			font-weight: 600;
+
+			span {
+				font-weight: 400;
+			}
+		}
+
+		.bottom-container-field:last-of-type {
+			margin-bottom: 0;
+		}
+
+		.tracks {
+			margin-top: 12px;
+
+			.track:first-child {
+				margin-top: 0;
+				border-radius: 3px 3px 0 0;
+			}
+
+			.track:last-child {
+				border-radius: 0 0 3px 3px;
+			}
+
+			.track {
+				border: 0.5px solid var(--black);
+				margin-top: -1px;
+				line-height: 16px;
+				display: flex;
+
+				span {
+					font-weight: 600;
+					display: inline-block;
+					margin-top: 7px;
+					margin-bottom: 7px;
+					margin-left: 7px;
+				}
+
+				p {
+					display: inline-block;
+					margin: 7px;
+					flex: 1;
+				}
+			}
+
+			.track:hover,
+			.track:focus {
+				background-color: var(--light-grey);
+			}
+		}
+	}
+}
+
+.import-youtube-playlist {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+}
+
+.track-boxes {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	.track-box:first-child {
+		margin-top: 0;
+		border-radius: 3px 3px 0 0;
+	}
+
+	.track-box:last-child {
+		border-radius: 0 0 3px 3px;
+	}
+
+	.track-box {
+		border: 0.5px solid var(--black);
+		margin-top: -1px;
+		line-height: 16px;
+		display: flex;
+		flex-flow: column;
+
+		.track-position-title {
+			display: flex;
+
+			span {
+				font-weight: 600;
+				display: inline-block;
+				margin-top: 7px;
+				margin-bottom: 7px;
+				margin-left: 7px;
+			}
+
+			p {
+				display: inline-block;
+				margin: 7px;
+				flex: 1;
+			}
+		}
+
+		.track-box-songs-drag-area {
+			flex: 1;
+			min-height: 100px;
+		}
+	}
+}
+</style>

+ 2 - 1
frontend/src/components/modals/RequestSong.vue

@@ -181,7 +181,7 @@ export default {
 					}
 				);
 			} else {
-				this.socket.dispatch("songs.request", youtubeId, res => {
+				this.socket.dispatch("songs.request", youtubeId, false, res => {
 					if (res.status !== "success")
 						new Toast(`Error: ${res.message}`);
 					else {
@@ -222,6 +222,7 @@ export default {
 				"songs.requestSet",
 				this.search.playlist.query,
 				this.search.playlist.isImportingOnlyMusic,
+				false,
 				res => {
 					isImportingPlaylist = false;
 					return new Toast({ content: res.message, timeout: 20000 });

+ 5 - 7
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -107,6 +107,7 @@
 				</tbody>
 			</table>
 		</div>
+		<import-album v-if="modals.importAlbum" />
 		<edit-song v-if="modals.editSong" />
 		<floating-box
 			id="keyboardShortcutsHelper"
@@ -188,6 +189,9 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
 		UserIdToUsername,
 		FloatingBox
 	},
@@ -216,12 +220,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		// eslint-disable-next-line func-names
-		"modals.editSong": function(value) {
-			if (value === false) this.stopVideo();
-		}
-	},
 	mounted() {
 		this.socket.on("event:admin.hiddenSong.created", res => {
 			this.addSong(res.data.song);
@@ -308,7 +306,7 @@ export default {
 			"removeSong",
 			"updateSong"
 		]),
-		...mapActions("modals/editSong", ["editSong", "stopVideo"]),
+		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };

+ 5 - 7
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -116,6 +116,7 @@
 				</tbody>
 			</table>
 		</div>
+		<import-album v-if="modals.importAlbum" />
 		<edit-song v-if="modals.editSong" />
 		<floating-box
 			id="keyboardShortcutsHelper"
@@ -200,6 +201,9 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
 		UserIdToUsername,
 		FloatingBox,
 		Confirm
@@ -229,12 +233,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		// eslint-disable-next-line func-names
-		"modals.editSong": function(value) {
-			if (value === false) this.stopVideo();
-		}
-	},
 	mounted() {
 		this.socket.on("event:admin.unverifiedSong.created", res => {
 			this.addSong(res.data.song);
@@ -329,7 +327,7 @@ export default {
 			"removeSong",
 			"updateSong"
 		]),
-		...mapActions("modals/editSong", ["editSong", "stopVideo"]),
+		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };

+ 8 - 7
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -27,6 +27,9 @@
 			>
 				Keyboard shortcuts helper
 			</button>
+			<button class="button is-primary" @click="openModal('importAlbum')">
+				Import album
+			</button>
 			<confirm placement="bottom" @confirm="updateAllSongs()">
 				<button
 					class="button is-danger"
@@ -149,6 +152,7 @@
 				</tbody>
 			</table>
 		</div>
+		<import-album v-if="modals.importAlbum" />
 		<edit-song v-if="modals.editSong" song-type="songs" />
 		<floating-box
 			id="keyboardShortcutsHelper"
@@ -259,6 +263,9 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
 		UserIdToUsername,
 		FloatingBox,
 		Confirm
@@ -350,12 +357,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		// eslint-disable-next-line func-names
-		"modals.editSong": function(val) {
-			if (!val) this.stopVideo();
-		}
-	},
 	mounted() {
 		this.socket.on("event:admin.verifiedSong.created", res => {
 			this.addSong(res.data.song);
@@ -505,7 +506,7 @@ export default {
 			"removeSong",
 			"updateSong"
 		]),
-		...mapActions("modals/editSong", ["editSong", "stopVideo"]),
+		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal", "closeModal"])
 	}
 };

+ 2 - 0
frontend/src/store/index.js

@@ -10,6 +10,7 @@ import station from "./modules/station";
 import admin from "./modules/admin";
 
 import editSongModal from "./modules/modals/editSong";
+import importAlbumModal from "./modules/modals/importAlbum";
 import editPlaylistModal from "./modules/modals/editPlaylist";
 import manageStationModal from "./modules/modals/manageStation";
 import editUserModal from "./modules/modals/editUser";
@@ -28,6 +29,7 @@ export default createStore({
 			namespaced: true,
 			modules: {
 				editSong: editSongModal,
+				importAlbum: importAlbumModal,
 				editPlaylist: editPlaylistModal,
 				manageStation: manageStationModal,
 				editUser: editUserModal,

+ 2 - 1
frontend/src/store/modules/modalVisibility.js

@@ -16,6 +16,7 @@ const state = {
 		editNews: false,
 		editUser: false,
 		editSong: false,
+		importAlbum: false,
 		viewReport: false,
 		viewPunishment: false
 	},
@@ -32,7 +33,6 @@ const actions = {
 			});
 
 		commit("closeModal", modal);
-		commit("closeCurrentModal");
 	},
 	openModal: ({ commit }, modal) => {
 		commit("openModal", modal);
@@ -52,6 +52,7 @@ const mutations = {
 	},
 	closeCurrentModal(state) {
 		// remove any websocket listeners for the modal
+		console.log(`Closing current modal (${state.currentlyActive[0]})`);
 		ws.destroyModalListeners(state.currentlyActive[0]);
 
 		state.modals[state.currentlyActive[0]] = false;

+ 66 - 0
frontend/src/store/modules/modals/importAlbum.js

@@ -0,0 +1,66 @@
+/* eslint no-param-reassign: 0 */
+
+// import Vue from "vue";
+// import admin from "@/api/admin/index";
+
+export default {
+	namespaced: true,
+	state: {
+		discogsAlbum: {
+			// album: {
+			// 	genres: [],
+			// 	artists: [],
+			// 	artistIds: []
+			// },
+			// tracks: []
+		},
+		originalPlaylistSongs: [],
+		playlistSongs: [],
+		editingSongs: false
+	},
+	getters: {},
+	actions: {
+		selectDiscogsAlbum: ({ commit }, discogsAlbum) =>
+			commit("selectDiscogsAlbum", discogsAlbum),
+		toggleDiscogsAlbum: ({ commit }) => {
+			commit("toggleDiscogsAlbum");
+		},
+		setPlaylistSongs: ({ commit }, playlistSongs) =>
+			commit("setPlaylistSongs", playlistSongs),
+		updatePlaylistSongs: ({ commit }, playlistSongs) =>
+			commit("updatePlaylistSongs", playlistSongs),
+		updateEditingSongs: ({ commit }, editingSongs) =>
+			commit("updateEditingSongs", editingSongs),
+		resetPlaylistSongs: ({ commit }) => commit("resetPlaylistSongs")
+	},
+	mutations: {
+		selectDiscogsAlbum(state, discogsAlbum) {
+			state.discogsAlbum = JSON.parse(JSON.stringify(discogsAlbum));
+			if (state.discogsAlbum && state.discogsAlbum.tracks) {
+				state.tracks = state.discogsAlbum.tracks.map(track => {
+					return { ...track, songs: [] };
+				});
+			}
+		},
+		toggleDiscogsAlbum(state) {
+			state.discogsAlbum.expanded = !state.discogsAlbum.expanded;
+		},
+		setPlaylistSongs(state, playlistSongs) {
+			state.originalPlaylistSongs = JSON.parse(
+				JSON.stringify(playlistSongs)
+			);
+			state.playlistSongs = JSON.parse(JSON.stringify(playlistSongs));
+		},
+		updatePlaylistSongs(state, playlistSongs) {
+			state.playlistSongs = JSON.parse(JSON.stringify(playlistSongs));
+		},
+		updateEditingSongs(state, editingSongs) {
+			state.editingSongs = editingSongs;
+		},
+		resetPlaylistSongs(state) {
+			state.playlistSongs = JSON.parse(
+				JSON.stringify(state.originalPlaylistSongs)
+			);
+		}
+	}
+};