Bläddra i källkod

feat: changes from weeks ago, started working on ImportArtist modal

Kristian Vos 1 år sedan
förälder
incheckning
e6f5289899

+ 1 - 1
backend/logic/actions/playlists.js

@@ -1952,7 +1952,7 @@ export default {
 
 					if (playlistRegex.exec(url) || channelRegex.exec(url))
 						YouTubeModule.runJob(
-							playlistRegex.exec(url) ? "GET_PLAYLIST" : "GET_CHANNEL",
+							playlistRegex.exec(url) ? "GET_PLAYLIST" : "GET_CHANNEL_VIDEOS",
 							{
 								url,
 								musicOnly,

+ 27 - 0
backend/logic/actions/soundcloud.js

@@ -52,6 +52,33 @@ export default {
 			});
 	}),
 
+	/**
+	 * Get a Soundcloud artist from ID
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	getArtist: useHasPermission("youtube.removeVideos", function getArtist(session, userPermalink, cb) {
+		return SoundcloudModule.runJob("GET_ARTISTS_FROM_PERMALINKS", { userPermalinks: [userPermalink] }, this)
+			.then(res => {
+				if (res.artists.length === 0) {
+					this.log("ERROR", "SOUNDCLOUD_GET_ARTISTS_FROM_PERMALINKS", `Fetching artist failed.`);
+					return cb({ status: "error", message: "Failed to get artist" });
+				}
+
+				this.log("SUCCESS", "SOUNDCLOUD_GET_ARTISTS_FROM_PERMALINKS", `Fetching artist was successful.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched Soundcloud artist",
+					data: res.artists[0]
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SOUNDCLOUD_GET_ARTISTS_FROM_PERMALINKS", `Fetching artist failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	// /**
 	//  * Returns details about the YouTube quota usage
 	//  *

+ 27 - 0
backend/logic/actions/youtube.js

@@ -449,6 +449,33 @@ export default {
 			});
 	}),
 
+	/**
+	 * Get a YouTube channel from ID
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	getChannel: useHasPermission("youtube.removeVideos", function getChannel(session, channelId, cb) {
+		return YouTubeModule.runJob("GET_CHANNELS_FROM_IDS", { channelIds: [channelId] }, this)
+			.then(res => {
+				if (res.channels.length === 0) {
+					this.log("ERROR", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channel failed.`);
+					return cb({ status: "error", message: "Failed to get channel" });
+				}
+
+				this.log("SUCCESS", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channel was successful.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched YouTube channel",
+					data: res.channels[0]
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching video failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	/**
 	 * Remove YouTube videos
 	 *

+ 62 - 0
backend/logic/soundcloud.js

@@ -509,6 +509,68 @@ class _SoundCloudModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Get Soundcloud artists
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.userPermalinks - an array of Soundcloud user permalinks
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_ARTISTS_FROM_PERMALINKS(payload) {
+		const getArtists = async userPermalinks => {
+			const jobsToRun = [];
+
+			userPermalinks.forEach(userPermalink => {
+				const url = `https://soundcloud.com/${userPermalink}`;
+
+				jobsToRun.push(SoundCloudModule.runJob("API_RESOLVE", { url }, this));
+			});
+
+			const jobResponses = await Promise.all(jobsToRun);
+
+			console.log(jobResponses.map(jobResponse => jobResponse.response.data));
+
+			return jobResponses
+				.map(jobResponse => jobResponse.response.data)
+				.map(artist => ({
+					artistId: artist.id,
+					username: artist.username,
+					avatarUrl: artist.avatar_url,
+					permalink: artist.permalink,
+					rawData: artist
+				}));
+		};
+
+		const { userPermalinks } = payload;
+		console.log(userPermalinks);
+
+		// const existingArtists = (
+		// 	await SoundcloudModule.soundcloudArtistsModel.find({ userPermalink: userPermalinks })
+		// ).map(artists => artists._doc);
+		// console.log(existingArtists);
+		const existingArtists = [];
+
+		const existingUserPermalinks = existingArtists.map(existingArtists => existingArtists.userPermalink);
+		const existingArtistsObjectIds = existingArtists.map(existingArtists => existingArtists._id.toString());
+		console.log(existingUserPermalinks, existingArtistsObjectIds);
+
+		if (userPermalinks.length === existingArtists.length) return { artists: existingArtists };
+
+		const missingUserPermalinks = userPermalinks.filter(
+			userPermalink => existingUserPermalinks.indexOf(userPermalink) === -1
+		);
+
+		console.log(missingUserPermalinks);
+
+		if (missingUserPermalinks.length === 0) return { videos: existingArtists };
+
+		const newArtists = await getArtists(missingUserPermalinks);
+
+		// await SoundcloudModule.soundcloudArtistsModel.insertMany(newArtists);
+
+		return { artists: existingArtists.concat(newArtists) };
+	}
+
 	/**
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.url - the url of the SoundCloud resource

+ 3 - 3
backend/logic/youtube.js

@@ -689,7 +689,7 @@ class _YouTubeModule extends CoreClass {
 	}
 
 	/**
-	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL.
+	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL_VIDEOS.
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {boolean} payload.playlistId - the playlist id to get videos from
@@ -797,7 +797,7 @@ class _YouTubeModule extends CoreClass {
 	 * @param {string} payload.url - the url of the YouTube channel
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	GET_CHANNEL(payload) {
+	GET_CHANNEL_VIDEOS(payload) {
 		return new Promise((resolve, reject) => {
 			const regex =
 				/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
@@ -1782,7 +1782,7 @@ class _YouTubeModule extends CoreClass {
 							/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
 						if (playlistRegex.exec(payload.url) || channelRegex.exec(payload.url))
 							YouTubeModule.runJob(
-								playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL",
+								playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL_VIDEOS",
 								{
 									url: payload.url,
 									musicOnly: payload.musicOnly

+ 73 - 0
frontend/src/components/ArtistItem.vue

@@ -0,0 +1,73 @@
+<script setup lang="ts">
+import { computed } from "vue";
+
+const props = defineProps({
+	type: { type: String, required: true },
+	data: {
+		type: Object,
+		required: true
+	}
+});
+
+const imageUrl = computed(() => {
+	if (props.type === "youtube")
+		return props.data.rawData.snippet.thumbnails.default.url;
+	if (props.type === "soundcloud") return props.data.avatarUrl;
+	if (props.type === "spotify") return props.data.rawData.images[0].url;
+	return null;
+});
+
+const artistUrl = computed(() => {
+	if (props.type === "youtube")
+		return `https://youtube.com/channel/${props.data.channelId}`;
+	if (props.type === "soundcloud")
+		return `https://soundcloud.com/${props.data.permalink}`;
+	if (props.type === "spotify")
+		return `https://open.spotify.com/artist/${props.data.artistId}`;
+	return null;
+});
+
+const artistName = computed(() => {
+	if (props.type === "youtube") return props.data.title;
+	if (props.type === "soundcloud") return props.data.username;
+	if (props.type === "spotify") return props.data.rawData.name;
+	return null;
+});
+</script>
+
+<template>
+	<div class="artist-item">
+		<img v-if="imageUrl" :src="imageUrl" alt="Artist image" />
+		<a :href="artistUrl" target="_blank"
+			><div :class="`${type}-icon`"></div
+		></a>
+		<span>
+			{{ artistName }}
+		</span>
+	</div>
+</template>
+
+<style lang="less">
+.artist-item {
+	display: flex;
+	flex-direction: row;
+	gap: 8px;
+	align-items: center;
+	outline: 1px solid white;
+	border-radius: 4px;
+
+	img {
+		max-height: 88px;
+		max-width: 88px;
+	}
+
+	.youtube-icon,
+	.soundcloud-icon,
+	.spotify-icon {
+		width: 30px;
+		max-width: 30px;
+		height: 30px;
+		max-height: 30px;
+	}
+}
+</style>

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

@@ -24,6 +24,7 @@ const modalComponents = shallowRef(
 		viewPunishment: "ViewPunishment.vue",
 		removeAccount: "RemoveAccount.vue",
 		importAlbum: "ImportAlbum.vue",
+		importArtist: "ImportArtist.vue",
 		confirm: "Confirm.vue",
 		editSong: "EditSong/index.vue",
 		viewYoutubeVideo: "ViewYoutubeVideo.vue",

+ 494 - 0
frontend/src/components/modals/ImportArtist.vue

@@ -0,0 +1,494 @@
+<script setup lang="ts">
+import {
+	defineAsyncComponent,
+	ref,
+	reactive,
+	onMounted,
+	onBeforeUnmount
+} from "vue";
+import Toast from "toasters";
+import { DraggableList } from "vue-draggable-list";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+
+import { useYoutubeChannel } from "@/composables/useYoutubeChannel";
+import { useSoundcloudArtist } from "@/composables/useSoundcloudArtist";
+import { useSpotifyArtist } from "@/composables/useSpotifyArtist";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SongItem = defineAsyncComponent(
+	() => import("@/components/SongItem.vue")
+);
+const ArtistItem = defineAsyncComponent(
+	() => import("@/components/ArtistItem.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true }
+});
+
+const { socket } = useWebsocketsStore();
+const { closeCurrentModal } = useModalsStore();
+
+const { youtubeChannelURLOrID, getYoutubeChannel, getYoutubeChannelVideos } =
+	useYoutubeChannel();
+const {
+	soundcloudArtistURLOrPermalink,
+	getSoundcloudArtist,
+	getSoundcloudArtistTracks
+} = useSoundcloudArtist();
+const { spotifyArtistURLOrID, getSpotifyArtist } = useSpotifyArtist();
+
+const youtubeChannels = reactive([]);
+const soundcloudArtists = reactive([]);
+const spotifyArtists = reactive([]);
+
+const selectYoutubeChannel = async () => {
+	const youtubeChannel = await getYoutubeChannel();
+	youtubeChannels.push(youtubeChannel);
+};
+
+const selectSoundcloudArtist = async () => {
+	const soundcloudArtist = await getSoundcloudArtist();
+	soundcloudArtists.push(soundcloudArtist);
+};
+
+const selectSpotifyArtist = async () => {
+	const spotifyArtist = await getSpotifyArtist();
+	spotifyArtists.push(spotifyArtist);
+};
+
+const songs = reactive([]);
+
+const importSongs = async () => {
+	console.log(32111);
+	const promises = [];
+	youtubeChannels.forEach(youtubeChannel => {
+		promises.push(async () => {
+			const videos = await getYoutubeChannelVideos(
+				youtubeChannel.channelId
+			);
+			console.log(321, videos);
+			videos.forEach(video => {
+				songs.push({ ...video, type: "youtube" });
+			});
+		});
+	});
+	soundcloudArtists.forEach(soundcloudArtist => {
+		promises.push(
+			new Promise<void>(resolve => {
+				console.log(555, soundcloudArtist);
+				getSoundcloudArtistTracks(soundcloudArtist.artistId)
+					.then(tracks => {
+						console.log(123, tracks);
+						tracks.forEach(track => {
+							songs.push({ ...track, type: "soundcloud" });
+						});
+					})
+					.finally(() => {
+						resolve();
+					});
+			})
+		);
+	});
+
+	console.log(promises.length);
+
+	await Promise.allSettled(promises);
+};
+
+onMounted(() => {
+	// localSpotifyTracks.value = props.spotifyTracks;
+	// if (props.youtubePlaylistId) importPlaylist();
+	// else if (props.youtubeChannelUrl) importChannel();
+});
+
+onBeforeUnmount(() => {});
+</script>
+
+<template>
+	<div>
+		<modal title="Import artist" class="import-artist-modal" size="wide">
+			<template #body>
+				<main>
+					<div class="artist-row">
+						<div class="artist-source-container">
+							<label class="label"
+								>YouTube channel URL or ID</label
+							>
+							<p class="control is-grouped">
+								<span class="control is-expanded">
+									<input
+										v-model="youtubeChannelURLOrID"
+										class="input"
+										type="text"
+										placeholder="Enter YouTube channel URL or ID..."
+										@keyup.enter="selectYoutubeChannel()"
+									/>
+								</span>
+								<span class="control">
+									<a
+										class="button is-info"
+										@click="selectYoutubeChannel()"
+										>Select</a
+									>
+								</span>
+							</p>
+							<label class="label"
+								>Soundcloud artist URL or permalink</label
+							>
+							<p class="control is-grouped">
+								<span class="control is-expanded">
+									<input
+										v-model="soundcloudArtistURLOrPermalink"
+										class="input"
+										type="text"
+										placeholder="Enter Soundcloud channel URL or permalink..."
+										@keyup.enter="selectSoundcloudArtist()"
+									/>
+								</span>
+								<span class="control">
+									<a
+										class="button is-info"
+										@click="selectSoundcloudArtist()"
+										>Select</a
+									>
+								</span>
+							</p>
+							<div
+								class="youtube-channels"
+								v-if="youtubeChannels.length > 0"
+							>
+								<artist-item
+									v-for="youtubeChannel in youtubeChannels"
+									:key="youtubeChannel.channelId"
+									type="youtube"
+									:data="youtubeChannel"
+								>
+								</artist-item>
+							</div>
+							<div
+								class="soundcloud-artists"
+								v-if="soundcloudArtists.length > 0"
+							>
+								<artist-item
+									v-for="soundcloudArtist in soundcloudArtists"
+									:key="soundcloudArtist.artistId"
+									type="soundcloud"
+									:data="soundcloudArtist"
+								></artist-item>
+							</div>
+							<button @click="importSongs()">Import songs</button>
+						</div>
+						<div class="artist-data-container">
+							<label class="label"
+								>Spotify artist URL or ID</label
+							>
+							<p class="control is-grouped">
+								<span class="control is-expanded">
+									<input
+										v-model="spotifyArtistURLOrID"
+										class="input"
+										type="text"
+										placeholder="Enter Spotify channel URL or ID..."
+										@keyup.enter="selectSpotifyArtist()"
+									/>
+								</span>
+								<span class="control">
+									<a
+										class="button is-info"
+										@click="selectSpotifyArtist()"
+										>Select</a
+									>
+								</span>
+							</p>
+							<div
+								class="spotify-artists"
+								v-if="spotifyArtists.length > 0"
+							>
+								<artist-item
+									v-for="spotifyArtist in spotifyArtists"
+									:key="spotifyArtist.artistId"
+									type="spotify"
+									:data="spotifyArtist"
+								></artist-item>
+							</div>
+						</div>
+					</div>
+					<div class="song-row">
+						<div class="song-source-container">
+							<!-- <div>
+								Showing all songs / showing songs for channel X
+							</div> -->
+							<div class="song-source-settings">
+								<label for="">Search</label>
+								<input type="text" />
+							</div>
+							<div class="songs"></div>
+						</div>
+						<div class="song-data-container"></div>
+					</div>
+				</main>
+
+				<!-- <div class="playlist-songs">
+					<h4>YouTube songs</h4>
+					<p v-if="isImportingPlaylist">Importing playlist...</p>
+					<draggable-list
+						v-if="playlistSongs.length > 0"
+						v-model:list="playlistSongs"
+						item-key="mediaSource"
+						:group="`replace-spotify-album-${modalUuid}-songs`"
+					>
+						<template #item="{ element }">
+							<song-item
+								:key="`playlist-song-${element.mediaSource}`"
+								:song="element"
+							>
+							</song-item>
+						</template>
+					</draggable-list>
+				</div> -->
+				<!-- <div class="track-boxes">
+					<div
+						class="track-box"
+						v-for="(track, index) in localSpotifyTracks"
+						:key="track.trackId"
+					>
+						<div class="track-position-title">
+							<p>
+								{{ track.name }} -
+								{{ track.artists.join(", ") }}
+							</p>
+						</div>
+						<div class="track-box-songs-drag-area">
+							<draggable-list
+								v-model:list="trackSongs[index]"
+								item-key="mediaSource"
+								:group="`replace-spotify-album-${modalUuid}-songs`"
+							>
+								<template #item="{ element }">
+									<song-item
+										:key="`track-song-${element.mediaSource}`"
+										:song="element"
+									>
+									</song-item>
+									<button
+										class="button is-primary is-fullwidth"
+										@click="
+											replaceSpotifySong(
+												`spotify:${track.trackId}`,
+												element.mediaSource
+											)
+										"
+									>
+										Replace Spotify song with this song
+									</button>
+								</template>
+							</draggable-list>
+						</div>
+					</div>
+				</div> -->
+			</template>
+			<template #footer>
+				<!-- <button class="button is-primary" @click="tryToAutoMove()">
+					Try to auto move
+				</button>
+				<button
+					class="button is-primary"
+					@click="replaceAllSpotifySongs()"
+				>
+					Replace all songs
+				</button> -->
+			</template>
+		</modal>
+	</div>
+</template>
+
+<style lang="less">
+// .night-mode {
+// 	.spotify-album-container,
+// 	.playlist-songs,
+// 	.track-boxes {
+// 		background-color: var(--dark-grey-3) !important;
+// 		border: 0 !important;
+// 		.tab {
+// 			border: 0 !important;
+// 		}
+// 	}
+
+// 	.api-result {
+// 		background-color: var(--dark-grey-3) !important;
+// 	}
+
+// 	.api-result .tracks .track:hover,
+// 	.api-result .tracks .track:focus,
+// 	.discogs-album .tracks .track:hover,
+// 	.discogs-album .tracks .track:focus {
+// 		background-color: var(--dark-grey-2) !important;
+// 	}
+
+// 	.api-result .bottom-row img,
+// 	.discogs-album .bottom-row img {
+// 		filter: invert(100%);
+// 	}
+
+// 	.label,
+// 	p,
+// 	strong {
+// 		color: var(--light-grey-2);
+// 	}
+// }
+
+// .replace-spotify-songs-modal {
+// 	.modal-card-title {
+// 		text-align: center;
+// 		margin-left: 24px;
+// 	}
+
+// 	.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;
+// 				&:not(:first-of-type) {
+// 					margin-left: 5px;
+// 				}
+// 			}
+
+// 			div div {
+// 				margin-right: 5px;
+// 			}
+// 			.right {
+// 				display: flex;
+// 				margin-left: auto;
+// 				margin-right: 0;
+// 			}
+// 		}
+// 	}
+// }
+//
+</style>
+
+//
+<style lang="less" scoped>
+main {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.artist-row,
+.song-row {
+	display: flex;
+	gap: 8px;
+
+	.artist-source-container,
+	.artist-data-container,
+	.song-source-container,
+	.song-data-container {
+		flex: 1;
+		display: flex;
+		flex-direction: column;
+		border: 1px solid white;
+		padding: 8px;
+		border-radius: 4px;
+		gap: 8px;
+	}
+}
+
+.youtube-channels {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+// .break {
+// 	flex-basis: 100%;
+// 	height: 0;
+// 	border: 1px solid var(--dark-grey);
+// 	margin-top: 16px;
+// 	margin-bottom: 16px;
+// }
+
+// .spotify-album-container,
+// .playlist-songs {
+// 	width: 500px;
+// 	background-color: var(--light-grey);
+// 	border: 1px rgba(163, 224, 255, 0.75) solid;
+// 	border-radius: @border-radius;
+// 	padding: 16px;
+// 	overflow: auto;
+// 	height: 100%;
+
+// 	h4 {
+// 		margin: 0;
+// 		margin-bottom: 16px;
+// 	}
+
+// 	button {
+// 		margin: 5px 0;
+// 	}
+// }
+
+// .track-boxes {
+// 	width: 500px;
+// 	background-color: var(--light-grey);
+// 	border: 1px rgba(163, 224, 255, 0.75) solid;
+// 	border-radius: @border-radius;
+// 	padding: 16px;
+// 	overflow: auto;
+// 	height: 100%;
+
+// 	.track-box:first-child {
+// 		margin-top: 0;
+// 		border-radius: @border-radius @border-radius 0 0;
+// 	}
+
+// 	.track-box:last-child {
+// 		border-radius: 0 0 @border-radius @border-radius;
+// 	}
+
+// 	.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;
+// 			display: flex;
+// 			flex-direction: column;
+// 		}
+// 	}
+// }
+</style>

+ 79 - 0
frontend/src/composables/useSoundcloudArtist.ts

@@ -0,0 +1,79 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+const soundcloudArtistPermalinkRegex =
+	/^(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)$/;
+const soundcloudArtistURLRegex =
+	/soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)/;
+
+export const useSoundcloudArtist = () => {
+	const soundcloudArtistURLOrPermalink = ref("");
+
+	const { socket } = useWebsocketsStore();
+
+	const getSoundcloudArtist = async () => {
+		const soundcloudArtistURLOrPermalinkTrimmed =
+			soundcloudArtistURLOrPermalink.value.trim();
+
+		if (soundcloudArtistURLOrPermalinkTrimmed.length === 0)
+			return new Toast(
+				"No Soundcloud URL or permalink found in the supplied value."
+			);
+
+		const soundcloudArtistPermalinkRegexResult =
+			soundcloudArtistPermalinkRegex.exec(
+				soundcloudArtistURLOrPermalinkTrimmed
+			);
+		const soundcloudArtistURLRegexResult = soundcloudArtistURLRegex.exec(
+			soundcloudArtistURLOrPermalinkTrimmed
+		);
+
+		if (
+			!soundcloudArtistPermalinkRegexResult &&
+			!soundcloudArtistURLRegexResult
+		)
+			return new Toast(
+				"No Soundcloud URL or permalink found in the supplied value."
+			);
+
+		const userPermalink = soundcloudArtistPermalinkRegexResult
+			? soundcloudArtistPermalinkRegexResult.groups.userPermalink
+			: soundcloudArtistURLRegexResult.groups.userPermalink;
+
+		return new Promise((resolve, reject) => {
+			socket.dispatch("soundcloud.getArtist", userPermalink, res => {
+				if (res.status === "success") {
+					const { data } = res;
+
+					resolve(data);
+
+					soundcloudArtistURLOrPermalink.value = "";
+				} else if (res.status === "error") {
+					new Toast(res.message);
+					reject();
+				}
+			});
+		});
+	};
+
+	const getSoundcloudArtistTracks = artistId =>
+		new Promise((resolve, reject) => {
+			socket.dispatch("soundcloud.getArtistTracks", artistId, res => {
+				if (res.status === "success") {
+					const { data } = res;
+
+					resolve(data);
+				} else if (res.status === "error") {
+					new Toast(res.message);
+					reject();
+				}
+			});
+		});
+
+	return {
+		soundcloudArtistURLOrPermalink,
+		getSoundcloudArtist,
+		getSoundcloudArtistTracks
+	};
+};

+ 133 - 0
frontend/src/composables/useSpotifyArtist.ts

@@ -0,0 +1,133 @@
+import { ref, reactive } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+// type SpotifyArtist = {
+// 	_id: string;
+// 	channelId: string;
+// 	title: string;
+// 	customUrl?: string;
+// 	rawData: {
+// 		kind: string;
+// 		etag: string;
+// 		id: string;
+// 		snippet: {
+// 			title: string;
+// 			description: string;
+// 			customUrl?: string;
+// 			publishedAt: string;
+// 			thumbnails: {
+// 				default: {
+// 					url: string;
+// 					width: number;
+// 					height: number;
+// 				};
+// 				medium: {
+// 					url: string;
+// 					width: number;
+// 					height: number;
+// 				};
+// 				high: {
+// 					url: string;
+// 					width: number;
+// 					height: number;
+// 				};
+// 			};
+// 			defaultLanguage?: string;
+// 			localized: {
+// 				title: string;
+// 				description: string;
+// 			};
+// 		};
+// 		contentDetails: {
+// 			relatedPlaylists: {
+// 				likes: string;
+// 				uploads: string;
+// 			};
+// 		};
+// 		statistics: {
+// 			viewCount: string;
+// 			subscriberCount: string;
+// 			hiddenSubscriberCount: boolean;
+// 			videoCount: string;
+// 		};
+// 		topicDetails: {
+// 			topicIds: string[];
+// 			topicCategories: string[];
+// 		};
+// 		status: {
+// 			privacyStatus: string;
+// 			isLinked: boolean;
+// 			longUploadsStatus: string;
+// 			madeForKids?: boolean;
+// 		};
+// 		brandingSettings: {
+// 			channel: {
+// 				title: string;
+// 				keywords?: string;
+// 				description?: string;
+// 				unsubscribedTrailer?: string;
+// 				defaultLanguage?: string;
+// 			};
+// 			image?: {
+// 				bannerExternalUrl: string;
+// 			};
+// 		};
+// 	};
+// 	documentVersion: number;
+// 	createdAt: Date;
+// 	updatedAt: Date;
+// };
+
+const spotifyArtistIDRegex = /(?<artistId>[A-Za-z0-9]{22})/;
+const spotifyArtistURLRegex =
+	/open\.spotify\.com\/artist\/(?<artistId>[A-Za-z0-9]{22})/;
+
+export const useSpotifyArtist = () => {
+	const spotifyArtistURLOrID = ref("");
+
+	const { socket } = useWebsocketsStore();
+
+	const getSpotifyArtist = async () => {
+		const spotifyArtistURLOrIDTrimmed = spotifyArtistURLOrID.value.trim();
+		if (spotifyArtistURLOrIDTrimmed.length === 0)
+			return new Toast(
+				"No Spotify artist ID found in the supplied value."
+			);
+
+		const spotifyArtistIDRegexResult = spotifyArtistIDRegex.exec(
+			spotifyArtistURLOrIDTrimmed
+		);
+		const spotifyArtistURLRegexResult = spotifyArtistURLRegex.exec(
+			spotifyArtistURLOrIDTrimmed
+		);
+
+		if (!spotifyArtistIDRegexResult && !spotifyArtistURLRegexResult)
+			return new Toast(
+				"No Spotify artist ID found in the supplied value."
+			);
+
+		const artistId = spotifyArtistIDRegexResult
+			? spotifyArtistIDRegexResult.groups.artistId
+			: spotifyArtistURLRegexResult.groups.artistId;
+
+		return new Promise((resolve, reject) => {
+			socket.dispatch("spotify.getArtistsFromIds", [artistId], res => {
+				if (res.status === "success") {
+					const { data } = res;
+					const artist = data.artists[0];
+
+					resolve(artist);
+				} else if (res.status === "error") {
+					new Toast(res.message);
+					reject();
+				}
+			});
+		});
+	};
+
+	return {
+		spotifyArtistURLOrID,
+		getSpotifyArtist
+	};
+};

+ 143 - 0
frontend/src/composables/useYoutubeChannel.ts

@@ -0,0 +1,143 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+// type YoutubeChannel = {
+// 	_id: string;
+// 	channelId: string;
+// 	title: string;
+// 	customUrl?: string;
+// 	rawData: {
+// 		kind: string;
+// 		etag: string;
+// 		id: string;
+// 		snippet: {
+// 			title: string;
+// 			description: string;
+// 			customUrl?: string;
+// 			publishedAt: string;
+// 			thumbnails: {
+// 				default: {
+// 					url: string;
+// 					width: number;
+// 					height: number;
+// 				};
+// 				medium: {
+// 					url: string;
+// 					width: number;
+// 					height: number;
+// 				};
+// 				high: {
+// 					url: string;
+// 					width: number;
+// 					height: number;
+// 				};
+// 			};
+// 			defaultLanguage?: string;
+// 			localized: {
+// 				title: string;
+// 				description: string;
+// 			};
+// 		};
+// 		contentDetails: {
+// 			relatedPlaylists: {
+// 				likes: string;
+// 				uploads: string;
+// 			};
+// 		};
+// 		statistics: {
+// 			viewCount: string;
+// 			subscriberCount: string;
+// 			hiddenSubscriberCount: boolean;
+// 			videoCount: string;
+// 		};
+// 		topicDetails: {
+// 			topicIds: string[];
+// 			topicCategories: string[];
+// 		};
+// 		status: {
+// 			privacyStatus: string;
+// 			isLinked: boolean;
+// 			longUploadsStatus: string;
+// 			madeForKids?: boolean;
+// 		};
+// 		brandingSettings: {
+// 			channel: {
+// 				title: string;
+// 				keywords?: string;
+// 				description?: string;
+// 				unsubscribedTrailer?: string;
+// 				defaultLanguage?: string;
+// 			};
+// 			image?: {
+// 				bannerExternalUrl: string;
+// 			};
+// 		};
+// 	};
+// 	documentVersion: number;
+// 	createdAt: Date;
+// 	updatedAt: Date;
+// };
+
+const youtubeChannelIDRegex = /(?<youtubeChannelID>UC[A-Za-z0-9-_]+)/;
+
+export const useYoutubeChannel = () => {
+	const youtubeChannelURLOrID = ref("");
+
+	const { socket } = useWebsocketsStore();
+
+	const getYoutubeChannel = async () => {
+		const youtubeChannelURLOrIDTrimmed = youtubeChannelURLOrID.value.trim();
+		if (youtubeChannelURLOrIDTrimmed.length === 0)
+			return new Toast(
+				"No YouTube channel ID found in the supplied value."
+			);
+
+		const youtubeChannelIDRegexResult = youtubeChannelIDRegex.exec(
+			youtubeChannelURLOrIDTrimmed
+		);
+		if (!youtubeChannelIDRegexResult)
+			return new Toast(
+				"No YouTube channel ID found in the supplied value."
+			);
+
+		return new Promise((resolve, reject) => {
+			socket.dispatch(
+				"youtube.getChannel",
+				youtubeChannelIDRegexResult.groups.youtubeChannelID,
+				res => {
+					if (res.status === "success") {
+						const { data } = res;
+
+						resolve(data);
+
+						youtubeChannelURLOrID.value = "";
+					} else if (res.status === "error") {
+						new Toast(res.message);
+						reject();
+					}
+				}
+			);
+		});
+	};
+
+	const getYoutubeChannelVideos = async channelId =>
+		new Promise((resolve, reject) => {
+			socket.dispatch("youtube.getChannelVideos", channelId, res => {
+				if (res.status === "success") {
+					const { data } = res;
+
+					resolve(data);
+				} else if (res.status === "error") {
+					new Toast(res.message);
+					reject();
+				}
+			});
+		});
+
+	return {
+		youtubeChannelURLOrID,
+		getYoutubeChannel,
+		getYoutubeChannelVideos
+	};
+};

+ 143 - 0
frontend/src/composables/useYoutubeVideo.ts

@@ -0,0 +1,143 @@
+import { ref, reactive } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+type YoutubeVideo = {
+	_id: string;
+	youtubeId: string;
+	title: string;
+	author: string;
+	duration: number;
+	documentVersion: number;
+	createdAt: Date;
+	rawData: {
+		kind: "youtube#video";
+		etag: string;
+		id: string;
+		snippet: {
+			publishedAt: string;
+			channelId: string;
+			title: string;
+			description: string;
+			thumbnails: {
+				default: {
+					url: string;
+					width: number;
+					height: number;
+				};
+				medium: {
+					url: string;
+					width: number;
+					height: number;
+				};
+				high: {
+					url: string;
+					width: number;
+					height: number;
+				};
+				standard: {
+					url: string;
+					width: number;
+					height: number;
+				};
+				maxres: {
+					url: string;
+					width: number;
+					height: number;
+				};
+			};
+			channelTitle: string;
+			tags: string[];
+			categoryId: string;
+			liveBroadcastContent: string;
+			localized: {
+				title: string;
+				description: string;
+			};
+		};
+		contentDetails: {
+			duration: string;
+			dimension: string;
+			definition: string;
+			caption: string;
+			licensedContent: boolean;
+			regionRestriction: {
+				allowed: string[];
+			};
+			contentRating: object;
+			projection: string;
+		};
+		status: {
+			uploadStatus: string;
+			privacyStatus: string;
+			license: string;
+			embeddable: true;
+			publicStatsViewable: true;
+			madeForKids: false;
+		};
+		statistics: {
+			viewCount: string;
+			likeCount: string;
+			favoriteCount: string;
+			commentCount: string;
+		};
+	};
+	updatedAt: Date;
+	uploadedAt: Date;
+};
+
+const youtubeVideoURLRegex =
+	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
+const youtubeVideoIDRegex = /^([\w-]{11})$/;
+
+export const useYoutubeVideo = () => {
+	const youtubeVideoURLOrID = ref("");
+	const youtubeVideos = reactive<YoutubeVideo[]>([]);
+
+	const { socket } = useWebsocketsStore();
+
+	const selectYoutubeVideo = () => {
+		const youtubeVideoURLOrIDTrimmed = youtubeVideoURLOrID.value.trim();
+		if (youtubeVideoURLOrIDTrimmed.length === 0)
+			return new Toast(
+				"No YouTube video ID found in the supplied value."
+			);
+
+		const youtubeVideoIDRegexResult = youtubeVideoIDRegex.exec(
+			youtubeVideoURLOrIDTrimmed
+		);
+		if (!youtubeVideoIDRegexResult)
+			return new Toast(
+				"No YouTube video ID found in the supplied value."
+			);
+
+		return socket.dispatch(
+			"youtube.getVideo",
+			youtubeVideoIDRegexResult.groups.youtubeVideoID,
+			res => {
+				if (res.status === "success") {
+					const { data } = res;
+
+					if (
+						!youtubeVideos.find(
+							youtubeVideo =>
+								youtubeVideo.youtubeId === data.youtubeId
+						)
+					)
+						youtubeVideos.push(data);
+
+					youtubeVideoURLOrID.value = "";
+				} else if (res.status === "error") new Toast(res.message);
+			}
+		);
+	};
+
+	return {
+		youtubeVideoURLOrID,
+		youtubeVideos,
+		selectYoutubeVideo
+		// searchForSongs,
+		// loadMoreSongs,
+		// addYouTubeSongToPlaylist
+	};
+};

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

@@ -546,6 +546,16 @@ onMounted(() => {
 				>
 					Import album
 				</button>
+				<button
+					v-if="
+						hasPermission('songs.create') ||
+						hasPermission('songs.update')
+					"
+					class="button is-primary"
+					@click="openModal('importArtist')"
+				>
+					Import artist
+				</button>
 				<run-job-dropdown :jobs="jobs" />
 			</div>
 		</div>