Browse Source

feat: continued adding support for SoundCloud

Kristian Vos 1 year ago
parent
commit
3bffb57c54

+ 220 - 6
backend/logic/actions/playlists.js

@@ -14,6 +14,7 @@ const SongsModule = moduleManager.modules.songs;
 const CacheModule = moduleManager.modules.cache;
 const PlaylistsModule = moduleManager.modules.playlists;
 const YouTubeModule = moduleManager.modules.youtube;
+const SoundcloudModule = moduleManager.modules.soundcloud;
 const ActivitiesModule = moduleManager.modules.activities;
 const MediaModule = moduleManager.modules.media;
 
@@ -1164,25 +1165,39 @@ export default {
 				},
 
 				(playlist, next) => {
+					MediaModule.runJob("GET_MEDIA", { mediaSource }, this)
+						.then(res =>
+							next(null, playlist, {
+								_id: res.song._id,
+								title: res.song.title,
+								thumbnail: res.song.thumbnail,
+								artists: res.song.artists,
+								mediaSource: res.song.mediaSource
+							})
+						)
+						.catch(next);
+				},
+
+				(playlist, song, next) => {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
 						const oppositeType = playlist.type === "user-liked" ? "user-disliked" : "user-liked";
 						const oppositePlaylistName = oppositeType === "user-liked" ? "Liked Songs" : "Disliked Songs";
 						playlistModel.count(
-							{ type: oppositeType, createdBy: session.userId, "songs.mediaSource": mediaSource },
+							{ type: oppositeType, createdBy: session.userId, "songs.mediaSource": song.mediaSource },
 							(err, results) => {
 								if (err) next(err);
 								else if (results > 0)
 									next(
 										`That song is already in your ${oppositePlaylistName} playlist. A song cannot be in both the Liked Songs playlist and the Disliked Songs playlist at the same time.`
 									);
-								else next();
+								else next(null, song);
 							}
 						);
-					} else next();
+					} else next(null, song);
 				},
 
-				next => {
-					PlaylistsModule.runJob("ADD_SONG_TO_PLAYLIST", { playlistId, mediaSource }, this)
+				(_song, next) => {
+					PlaylistsModule.runJob("ADD_SONG_TO_PLAYLIST", { playlistId, mediaSource: _song.mediaSource }, this)
 						.then(res => {
 							const { playlist, song, ratings } = res;
 							next(null, playlist, song, ratings);
@@ -1655,7 +1670,13 @@ export default {
 	 * @param {boolean} musicOnly - whether to only add music to the playlist
 	 * @param {Function} cb - gets called with the result
 	 */
-	addSetToPlaylist: isLoginRequired(async function addSetToPlaylist(session, url, playlistId, musicOnly, cb) {
+	addYoutubeSetToPlaylist: isLoginRequired(async function addYoutubeSetToPlaylist(
+		session,
+		url,
+		playlistId,
+		musicOnly,
+		cb
+	) {
 		let videosInPlaylistTotal = 0;
 		let songsInPlaylistTotal = 0;
 		let addSongsStats = null;
@@ -1849,6 +1870,199 @@ export default {
 		);
 	}),
 
+	/**
+	 * Adds a set of Soundcloud songs to a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the SoundCloud playlist
+	 * @param {string} playlistId - the id of the playlist we are adding the set of songs to
+	 * @param {Function} cb - gets called with the result
+	 */
+	addSoundcloudSetToPlaylist: isLoginRequired(async function addSoundcloudSetToPlaylist(
+		session,
+		url,
+		playlistId,
+		cb
+	) {
+		let songsInPlaylistTotal = 0;
+		let addSongsStats = null;
+
+		const addedSongs = [];
+
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Import SoundCloud playlist",
+			message: "Importing SoundCloud 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 => {
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+						userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (user && user.role === "admin") return next(null, true);
+							return next(null, false);
+						});
+					});
+				},
+
+				(isAdmin, next) => {
+					this.publishProgress({ status: "update", message: `Importing SoundCloud playlist (stage 1)` });
+					SoundcloudModule.runJob(
+						"GET_PLAYLIST",
+						{
+							url
+						},
+						this
+					)
+						.then(res => {
+							songsInPlaylistTotal = res.songs.length;
+							const mediaSources = res.songs.map(song => `soundcloud:${song}`);
+							next(null, mediaSources);
+						})
+						.catch(next);
+				},
+				(mediaSources, next) => {
+					this.publishProgress({ status: "update", message: `Importing SoundCloud playlist (stage 2)` });
+					let successful = 0;
+					let failed = 0;
+					let alreadyInPlaylist = 0;
+					let alreadyInLikedPlaylist = 0;
+					let alreadyInDislikedPlaylist = 0;
+
+					if (mediaSources.length === 0) next();
+
+					async.eachLimit(
+						mediaSources,
+						1,
+						(mediaSource, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "playlists",
+									action: "addSongToPlaylist",
+									args: [true, mediaSource, playlistId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "success") {
+										successful += 1;
+										addedSongs.push(mediaSource);
+									} else failed += 1;
+									if (res.message === "That song is already in the playlist") alreadyInPlaylist += 1;
+									else if (
+										res.message ===
+										"That song is already in your Liked Songs playlist. " +
+											"A song cannot be in both the Liked Songs playlist" +
+											" and the Disliked Songs playlist at the same time."
+									)
+										alreadyInLikedPlaylist += 1;
+									else if (
+										res.message ===
+										"That song is already in your Disliked Songs playlist. " +
+											"A song cannot be in both the Liked Songs playlist " +
+											"and the Disliked Songs playlist at the same time."
+									)
+										alreadyInDislikedPlaylist += 1;
+								})
+								.catch(() => {
+									failed += 1;
+								})
+								.finally(() => next());
+						},
+						() => {
+							addSongsStats = {
+								successful,
+								failed,
+								alreadyInPlaylist,
+								alreadyInLikedPlaylist,
+								alreadyInDislikedPlaylist
+							};
+							next(null);
+						}
+					);
+				},
+
+				next => {
+					this.publishProgress({ status: "update", message: `Importing SoundCloud playlist (stage 3)` });
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					this.publishProgress({ status: "update", message: `Importing SoundCloud playlist (stage 4)` });
+					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);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_IMPORT",
+						`Importing a SoundCloud playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
+					return cb({ status: "error", message: err });
+				}
+
+				if (playlist.privacy === "public")
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "playlist__import_playlist",
+						payload: {
+							message: `Imported ${addSongsStats.successful} songs to playlist <playlistId>${playlist.displayName}</playlistId>`,
+							playlistId
+						}
+					});
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_IMPORT",
+					`Successfully imported a SoundCloud playlist to private playlist "${playlistId}" for user "${session.userId}". Songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}, already in liked ${addSongsStats.alreadyInLikedPlaylist}, already in disliked ${addSongsStats.alreadyInDislikedPlaylist}.`
+				);
+				this.publishProgress({
+					status: "success",
+					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`
+				});
+				return cb({
+					status: "success",
+					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`,
+					data: {
+						songs: playlist.songs,
+						stats: {
+							songsInPlaylistTotal,
+							alreadyInLikedPlaylist: addSongsStats.alreadyInLikedPlaylist,
+							alreadyInDislikedPlaylist: addSongsStats.alreadyInDislikedPlaylist
+						}
+					}
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Removes a song from a private playlist
 	 *

+ 4 - 1
backend/logic/db/schemas/soundcloudTrack.js

@@ -1,5 +1,5 @@
 export default {
-	trackId: { type: Number },
+	trackId: { type: Number, unique: true },
 	title: { type: String },
 	artworkUrl: { type: String },
 	soundcloudCreatedAt: { type: Date },
@@ -13,6 +13,9 @@ export default {
 	tagList: { type: String },
 	userId: { type: Number },
 	username: { type: String },
+	userPermalink: { type: String },
+	trackFormat: { type: String },
+	permalink: { type: String },
 	createdAt: { type: Date, default: Date.now, required: true },
 	documentVersion: { type: Number, default: 1, required: true }
 };

+ 19 - 1
backend/logic/media.js

@@ -122,7 +122,6 @@ class _MediaModule extends CoreClass {
 				],
 				async err => {
 					if (err) {
-						console.log(345345, err);
 						err = await UtilsModule.runJob("GET_ERROR", { error: err });
 						reject(new Error(err));
 					} else resolve();
@@ -414,6 +413,25 @@ class _MediaModule extends CoreClass {
 								.catch(next);
 						}
 
+						if (payload.mediaSource.indexOf("soundcloud.com") !== -1) {
+							return SoundCloudModule.runJob(
+								"GET_TRACK_FROM_URL",
+								{ identifier: payload.mediaSource, createMissing: true },
+								this
+							)
+								.then(response => {
+									const { trackId, title, username, artworkUrl, duration } = response.track;
+									next(null, song, {
+										mediaSource: `soundcloud:${trackId}`,
+										title,
+										artists: [username],
+										thumbnail: artworkUrl,
+										duration
+									});
+								})
+								.catch(next);
+						}
+
 						// TODO handle Spotify here
 
 						return next("Invalid media source provided.");

+ 205 - 54
backend/logic/soundcloud.js

@@ -8,13 +8,49 @@ import axios from "axios";
 import CoreClass from "../core";
 
 let SoundCloudModule;
-let CacheModule;
 let DBModule;
 let MediaModule;
-let SongsModule;
-let StationsModule;
-let PlaylistsModule;
-let WSModule;
+
+const soundcloudTrackObjectToMusareTrackObject = soundcloudTrackObject => {
+	const {
+		id,
+		title,
+		artwork_url: artworkUrl,
+		created_at: createdAt,
+		duration,
+		genre,
+		kind,
+		license,
+		likes_count: likesCount,
+		playback_count: playbackCount,
+		public: _public,
+		tag_list: tagList,
+		user_id: userId,
+		user,
+		track_format: trackFormat,
+		permalink
+	} = soundcloudTrackObject;
+
+	return {
+		trackId: id,
+		title,
+		artworkUrl,
+		soundcloudCreatedAt: new Date(createdAt),
+		duration: duration / 1000,
+		genre,
+		kind,
+		license,
+		likesCount,
+		playbackCount,
+		public: _public,
+		tagList,
+		userId,
+		username: user.username,
+		userPermalink: user.permalink,
+		trackFormat,
+		permalink
+	};
+};
 
 class RateLimitter {
 	/**
@@ -89,13 +125,13 @@ class _SoundCloudModule extends CoreClass {
 			};
 			rax.attach(this.axios);
 
-			SoundCloudModule.runJob("GET_TRACK", { identifier: 469902882, createMissing: false })
-				.then(res => {
-					console.log(57567, res);
-				})
-				.catch(err => {
-					console.log(78768, err);
-				});
+			// SoundCloudModule.runJob("GET_TRACK", { identifier: 469902882, createMissing: false })
+			// 	.then(res => {
+			// 		console.log(57567, res);
+			// 	})
+			// 	.catch(err => {
+			// 		console.log(78768, err);
+			// 	});
 
 			resolve();
 		});
@@ -184,17 +220,28 @@ class _SoundCloudModule extends CoreClass {
 						}
 					},
 
-					(soundcloudTrack, next) => {
-						const mediaSource = `soundcloud:${soundcloudTrack.trackId}`;
-
-						MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
-							.then(() => next(null, soundcloudTrack))
-							.catch(next);
+					(soundcloudTracks, next) => {
+						const mediaSources = soundcloudTracks.map(
+							soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`
+						);
+						async.eachLimit(
+							mediaSources,
+							2,
+							(mediaSource, next) => {
+								MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
+									.then(() => next())
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else next(null, soundcloudTracks);
+							}
+						);
 					}
 				],
-				(err, soundcloudTrack) => {
+				(err, soundcloudTracks) => {
 					if (err) reject(new Error(err));
-					else resolve({ soundcloudTrack });
+					else resolve({ soundcloudTracks });
 				}
 			);
 		});
@@ -229,39 +276,7 @@ class _SoundCloudModule extends CoreClass {
 								if (!data || !data.id)
 									return next("The specified track does not exist or cannot be publicly accessed.");
 
-								const {
-									id,
-									title,
-									artwork_url: artworkUrl,
-									created_at: createdAt,
-									duration,
-									genre,
-									kind,
-									license,
-									likes_count: likesCount,
-									playback_count: playbackCount,
-									public: _public,
-									tag_list: tagList,
-									user_id: userId,
-									user
-								} = data;
-
-								const soundcloudTrack = {
-									trackId: id,
-									title,
-									artworkUrl,
-									soundcloudCreatedAt: new Date(createdAt),
-									duration: duration / 1000,
-									genre,
-									kind,
-									license,
-									likesCount,
-									playbackCount,
-									public: _public,
-									tagList,
-									userId,
-									username: user.username
-								};
+								const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
 
 								return next(null, false, soundcloudTrack);
 							})
@@ -271,7 +286,7 @@ class _SoundCloudModule extends CoreClass {
 						if (track) return next(null, track, true);
 						return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
 							.then(res => {
-								if (res.soundcloudTrack.length === 1) next(null, res.soundcloudTrack, false);
+								if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);
 								else next("SoundCloud track not found.");
 							})
 							.catch(next);
@@ -284,6 +299,142 @@ class _SoundCloudModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets a track from a SoundCloud URL
+	 *
+	 * @param {*} payload
+	 * @returns {Promise}
+	 */
+	GET_TRACK_FROM_URL(payload) {
+		return new Promise((resolve, reject) => {
+			const scRegex =
+				/soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;
+
+			async.waterfall(
+				[
+					next => {
+						const match = scRegex.exec(payload.identifier);
+
+						if (!match || !match.groups) return next("Invalid SoundCloud URL.");
+
+						const { userPermalink, permalink } = match.groups;
+
+						SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);
+					},
+
+					(_dbTrack, next) => {
+						if (_dbTrack) return next(null, _dbTrack, true);
+
+						SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)
+							.then(({ response }) => {
+								const { data } = response;
+								if (!data || !data.id)
+									return next("The provided URL does not exist or cannot be accessed.");
+
+								if (data.kind !== "track") return next(`Invalid URL provided. Kind got: ${data.kind}.`);
+
+								// TODO get more data here
+
+								const { id: trackId } = data;
+
+								SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {
+									if (err) next(err);
+									else if (dbTrack) {
+										next(null, dbTrack, true);
+									} else {
+										const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
+
+										SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
+											.then(res => {
+												if (res.soundcloudTracks.length === 1)
+													next(null, res.soundcloudTracks[0], false);
+												else next("SoundCloud track not found.");
+											})
+											.catch(next);
+									}
+								});
+							})
+							.catch(next);
+					}
+				],
+				(err, track, existing) => {
+					if (err) reject(new Error(err));
+					else resolve({ track, existing });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a SoundCloud playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - the url of the SoundCloud playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)
+							.then(({ response }) => {
+								const { data } = response;
+								if (!data || !data.id)
+									return next("The provided URL does not exist or cannot be accessed.");
+
+								if (data.kind !== "playlist")
+									return next(`Invalid URL provided. Kind got: ${data.kind}.`);
+
+								const { tracks } = data;
+
+								// TODO get more data here
+
+								const soundcloudTrackIds = tracks.map(track => track.id);
+
+								return next(null, soundcloudTrackIds);
+							})
+							.catch(next);
+					}
+				],
+				(err, soundcloudTrackIds) => {
+					if (err && err !== true) {
+						SoundCloudModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
+						reject(new Error(err.message));
+					} else {
+						resolve({ songs: soundcloudTrackIds });
+					}
+				}
+			);
+
+			// kind;
+		});
+	}
+
+	/**
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - the url of the SoundCloud resource
+	 */
+	API_RESOLVE(payload) {
+		return new Promise((resolve, reject) => {
+			const { url } = payload;
+
+			SoundCloudModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
 }
 
 export default new _SoundCloudModule();

+ 23 - 0
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -4,6 +4,7 @@ import { storeToRefs } from "pinia";
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import { useSearchMusare } from "@/composables/useSearchMusare";
 import { useYoutubeDirect } from "@/composables/useYoutubeDirect";
+import { useSoundcloudDirect } from "@/composables/useSoundcloudDirect";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
 
 const SongItem = defineAsyncComponent(
@@ -39,6 +40,8 @@ const {
 } = useSearchMusare();
 
 const { youtubeDirect, addToPlaylist } = useYoutubeDirect();
+const { soundcloudDirect, addToPlaylist: soundcloudAddToPlaylist } =
+	useSoundcloudDirect();
 
 watch(
 	() => youtubeSearch.value.songs.results,
@@ -285,6 +288,26 @@ onMounted(async () => {
 				</button>
 			</div>
 		</div>
+
+		<label class="label"> Add a SoundCloud song from a URL </label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					placeholder="Enter your SoundCloud song URL here..."
+					v-model="soundcloudDirect"
+					@keyup.enter="soundcloudAddToPlaylist(playlist._id)"
+				/>
+			</p>
+			<p class="control">
+				<a
+					class="button is-info"
+					@click="soundcloudAddToPlaylist(playlist._id)"
+					><i class="material-icons icon-with-button">add</i>Add</a
+				>
+			</p>
+		</div>
 	</div>
 </template>
 

+ 55 - 3
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -3,6 +3,7 @@ import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { ref } from "vue";
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
+import { useSearchSoundcloud } from "@/composables/useSearchSoundcloud";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useLongJobsStore } from "@/stores/longJobs";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
@@ -19,6 +20,7 @@ const { playlist } = storeToRefs(editPlaylistStore);
 const { setJob } = useLongJobsStore();
 
 const { youtubeSearch } = useSearchYoutube();
+const { soundcloudSearch } = useSearchSoundcloud();
 
 const importMusarePlaylistFileInput = ref();
 const importMusarePlaylistFileContents = ref(null);
@@ -46,7 +48,7 @@ const importYoutubePlaylist = () => {
 	}
 
 	return socket.dispatch(
-		"playlists.addSetToPlaylist",
+		"playlists.addYoutubeSetToPlaylist",
 		youtubeSearch.value.playlist.query,
 		playlist.value._id,
 		youtubeSearch.value.playlist.isImportingOnlyMusic,
@@ -69,6 +71,35 @@ const importYoutubePlaylist = () => {
 	);
 };
 
+const importSoundcloudPlaylist = () => {
+	let id;
+	let title;
+	// import query is blank
+	if (!soundcloudSearch.value.playlist.query)
+		return new Toast("Please enter a SoundCloud playlist URL.");
+
+	return socket.dispatch(
+		"playlists.addSoundcloudSetToPlaylist",
+		soundcloudSearch.value.playlist.query,
+		playlist.value._id,
+		{
+			cb: () => {},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+				}
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+
 const onMusarePlaylistFileChange = () => {
 	const reader = new FileReader();
 	const fileInput = importMusarePlaylistFileInput.value as HTMLInputElement;
@@ -150,7 +181,7 @@ const importMusarePlaylistFile = () => {
 </script>
 
 <template>
-	<div class="youtube-tab section">
+	<div class="import-playlist-tab section">
 		<label class="label"> Import songs from YouTube playlist </label>
 		<div class="control is-grouped input-with-button">
 			<p class="control is-expanded">
@@ -180,6 +211,27 @@ const importMusarePlaylistFile = () => {
 			</p>
 		</div>
 
+		<label class="label"> Import songs from SoundCloud playlist </label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					placeholder="Enter SoundCloud Playlist URL here..."
+					v-model="soundcloudSearch.playlist.query"
+					@keyup.enter="importSoundcloudPlaylist()"
+				/>
+			</p>
+			<p class="control has-addons">
+				<button
+					class="button is-info"
+					@click.prevent="importSoundcloudPlaylist()"
+				>
+					<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">
@@ -228,7 +280,7 @@ input[type="file"]::file-selector-button:hover {
 }
 
 @media screen and (max-width: 1300px) {
-	.youtube-tab #song-query-results,
+	.import-playlist-tab #song-query-results,
 	.section {
 		max-width: 100% !important;
 	}

+ 97 - 0
frontend/src/composables/useSearchSoundcloud.ts

@@ -0,0 +1,97 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+export const useSearchSoundcloud = () => {
+	const soundcloudSearch = ref({
+		songs: {
+			results: [],
+			query: "",
+			nextPageToken: ""
+		},
+		playlist: {
+			query: ""
+		}
+	});
+
+	const { socket } = useWebsocketsStore();
+
+	const searchForSongs = () => {
+		// let { query } = soundcloudSearch.value.songs;
+		// if (query.indexOf("&index=") !== -1) {
+		// 	const splitQuery = query.split("&index=");
+		// 	splitQuery.pop();
+		// 	query = splitQuery.join("");
+		// }
+		// if (query.indexOf("&list=") !== -1) {
+		// 	const splitQuery = query.split("&list=");
+		// 	splitQuery.pop();
+		// 	query = splitQuery.join("");
+		// }
+		// socket.dispatch("apis.searchSoundcloud", query, res => {
+		// 	if (res.status === "success") {
+		// 		soundcloudSearch.value.songs.nextPageToken =
+		// 			res.data.nextPageToken;
+		// 		soundcloudSearch.value.songs.results = [];
+		// 		res.data.items.forEach(result => {
+		// 			soundcloudSearch.value.songs.results.push({
+		// 				id: result.id.videoId,
+		// 				url: `https://www.soundcloud.com/watch?v=${result.id.videoId}`,
+		// 				title: result.snippet.title,
+		// 				thumbnail: result.snippet.thumbnails.default.url,
+		// 				channelId: result.snippet.channelId,
+		// 				channelTitle: result.snippet.channelTitle,
+		// 				isAddedToQueue: false
+		// 			});
+		// 		});
+		// 	} else if (res.status === "error") new Toast(res.message);
+		// });
+	};
+
+	const loadMoreSongs = () => {
+		// socket.dispatch(
+		// 	"apis.searchSoundcloudForPage",
+		// 	soundcloudSearch.value.songs.query,
+		// 	soundcloudSearch.value.songs.nextPageToken,
+		// 	res => {
+		// 		if (res.status === "success") {
+		// 			soundcloudSearch.value.songs.nextPageToken =
+		// 				res.data.nextPageToken;
+		// 			res.data.items.forEach(result => {
+		// 				soundcloudSearch.value.songs.results.push({
+		// 					id: result.id.videoId,
+		// 					url: `https://www.soundcloud.com/watch?v=${result.id.videoId}`,
+		// 					title: result.snippet.title,
+		// 					thumbnail: result.snippet.thumbnails.default.url,
+		// 					channelId: result.snippet.channelId,
+		// 					channelTitle: result.snippet.channelTitle,
+		// 					isAddedToQueue: false
+		// 				});
+		// 			});
+		// 		} else if (res.status === "error") new Toast(res.message);
+		// 	}
+		// );
+	};
+
+	const addSoundcloudSongToPlaylist = (playlistId, id, index) => {
+		socket.dispatch(
+			"playlists.addSongToPlaylist",
+			false,
+			id,
+			playlistId,
+			res => {
+				new Toast(res.message);
+				if (res.status === "success")
+					soundcloudSearch.value.songs.results[index].isAddedToQueue =
+						true;
+			}
+		);
+	};
+
+	return {
+		soundcloudSearch,
+		searchForSongs,
+		loadMoreSongs,
+		addSoundcloudSongToPlaylist
+	};
+};

+ 54 - 0
frontend/src/composables/useSoundcloudDirect.ts

@@ -0,0 +1,54 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { AddSongToPlaylistResponse } from "@musare_types/actions/PlaylistsActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+export const useSoundcloudDirect = () => {
+	const soundcloudDirect = ref("");
+
+	const { socket } = useWebsocketsStore();
+
+	const addToPlaylist = (playlistId: string) => {
+		const url = soundcloudDirect.value.trim();
+
+		socket.dispatch(
+			"playlists.addSongToPlaylist",
+			false,
+			url,
+			playlistId,
+			(res: AddSongToPlaylistResponse) => {
+				if (res.status !== "success")
+					new Toast(`Error: ${res.message}`);
+				else {
+					new Toast(res.message);
+					soundcloudDirect.value = "";
+				}
+			}
+		);
+	};
+
+	const addToQueue = (stationId: string) => {
+		const url = soundcloudDirect.value.trim();
+
+		socket.dispatch(
+			"stations.addToQueue",
+			stationId,
+			url,
+			"manual",
+			res => {
+				if (res.status !== "success")
+					new Toast(`Error: ${res.message}`);
+				else {
+					new Toast(res.message);
+					soundcloudDirect.value = "";
+				}
+			}
+		);
+	};
+
+	return {
+		soundcloudDirect,
+		addToPlaylist,
+		addToQueue
+	};
+};