Browse Source

feat: added initial Spotify support

Kristian Vos 2 years ago
parent
commit
0b964a1190

+ 7 - 1
backend/config/template.json

@@ -32,8 +32,14 @@
 				}
 			]
 		},
+		"spotify": {
+			"clientId": "",
+			"clientSecret": "",
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2
+		},
 		"soundcloud": {
-			"key": "",
 			"rateLimit": 500,
 			"requestTimeout": 5000,
 			"retryAmount": 2

+ 1 - 0
backend/index.js

@@ -269,6 +269,7 @@ if (!config.get("migration")) {
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");
 	moduleManager.addModule("soundcloud");
+	moduleManager.addModule("spotify");
 } else {
 	moduleManager.addModule("migration");
 }

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

@@ -15,6 +15,7 @@ const CacheModule = moduleManager.modules.cache;
 const PlaylistsModule = moduleManager.modules.playlists;
 const YouTubeModule = moduleManager.modules.youtube;
 const SoundcloudModule = moduleManager.modules.soundcloud;
+const SpotifyModule = moduleManager.modules.spotify;
 const ActivitiesModule = moduleManager.modules.activities;
 const MediaModule = moduleManager.modules.media;
 
@@ -2063,6 +2064,194 @@ export default {
 		);
 	}),
 
+	/**
+	 * Adds a set of Spotify songs to a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the Spotify 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
+	 */
+	addSpotifySetToPlaylist: isLoginRequired(async function addSpotifySetToPlaylist(session, url, playlistId, cb) {
+		let songsInPlaylistTotal = 0;
+		let addSongsStats = null;
+
+		const addedSongs = [];
+
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Import Spotify playlist",
+			message: "Importing Spotify 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 Spotify playlist (stage 1)` });
+					SpotifyModule.runJob(
+						"GET_PLAYLIST",
+						{
+							url
+						},
+						this
+					)
+						.then(res => {
+							songsInPlaylistTotal = res.songs.length;
+							const mediaSources = res.songs.map(song => `spotify:${song}`);
+							next(null, mediaSources);
+						})
+						.catch(next);
+				},
+				(mediaSources, next) => {
+					this.publishProgress({ status: "update", message: `Importing Spotify 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 Spotify playlist (stage 3)` });
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					this.publishProgress({ status: "update", message: `Importing Spotify 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 Spotify 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 Spotify 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
 	 *

+ 11 - 3
backend/logic/cache/index.js

@@ -119,20 +119,28 @@ class _CacheModule extends CoreClass {
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the key to set
 	 * @param {*} payload.value - the value we want to set
+	 * @param {number} payload.ttl -  ttl of the key in seconds
 	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	SET(payload) {
 		return new Promise((resolve, reject) => {
-			let { key } = payload;
-			let { value } = payload;
+			let { key, value } = payload;
+			const { ttl } = payload;
 
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 			// automatically stringify objects and arrays into JSON
 			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
+			let options = null;
+			if (ttl) {
+				options = {
+					EX: ttl
+				};
+			}
+
 			CacheModule.client
-				.SET(key, value)
+				.SET(key, value, options)
 				.then(() => {
 					let parsed = value;
 					try {

+ 9 - 3
backend/logic/db/index.js

@@ -20,7 +20,8 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	ratings: 2,
 	importJob: 1,
 	stationHistory: 2,
-	soundcloudTrack: 1
+	soundcloudTrack: 1,
+	spotifyTrack: 1
 };
 
 const regex = {
@@ -79,7 +80,8 @@ class _DBModule extends CoreClass {
 						youtubeVideo: {},
 						ratings: {},
 						stationHistory: {},
-						soundcloudTrack: {}
+						soundcloudTrack: {},
+						spotifyTrack: {}
 					};
 
 					const importSchema = schemaName =>
@@ -106,6 +108,7 @@ class _DBModule extends CoreClass {
 					await importSchema("importJob");
 					await importSchema("stationHistory");
 					await importSchema("soundcloudTrack");
+					await importSchema("spotifyTrack");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -123,7 +126,8 @@ class _DBModule extends CoreClass {
 						ratings: mongoose.model("ratings", this.schemas.ratings),
 						importJob: mongoose.model("importJob", this.schemas.importJob),
 						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),
-						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack)
+						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack),
+						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -206,6 +210,7 @@ class _DBModule extends CoreClass {
 					this.schemas.song.path("mediaSource").validate(mediaSource => {
 						if (mediaSource.startsWith("youtube:")) return true;
 						if (mediaSource.startsWith("soundcloud:")) return true;
+						if (mediaSource.startsWith("spotify:")) return true;
 						return false;
 					});
 
@@ -277,6 +282,7 @@ class _DBModule extends CoreClass {
 					this.models.importJob.syncIndexes();
 					this.models.stationHistory.syncIndexes();
 					this.models.soundcloudTrack.syncIndexes();
+					this.models.spotifyTrack.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 17 - 0
backend/logic/db/schemas/spotifyTrack.js

@@ -0,0 +1,17 @@
+export default {
+	trackId: { type: String, unique: true },
+	name: { type: String },
+
+	albumId: { type: String },
+	albumTitle: { type: String },
+	albumImageUrl: { type: String },
+	artists: [{ type: String }],
+	artistIds: [{ type: String }],
+	duration: { type: Number },
+	explicit: { type: Boolean },
+	externalIds: { type: Object },
+	popularity: { type: Number },
+
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 18 - 1
backend/logic/media.js

@@ -7,6 +7,7 @@ let DBModule;
 let UtilsModule;
 let YouTubeModule;
 let SoundCloudModule;
+let SpotifyModule;
 let SongsModule;
 let WSModule;
 
@@ -31,6 +32,7 @@ class _MediaModule extends CoreClass {
 		UtilsModule = this.moduleManager.modules.utils;
 		YouTubeModule = this.moduleManager.modules.youtube;
 		SoundCloudModule = this.moduleManager.modules.soundcloud;
+		SpotifyModule = this.moduleManager.modules.spotify;
 		SongsModule = this.moduleManager.modules.songs;
 		WSModule = this.moduleManager.modules.ws;
 
@@ -432,7 +434,22 @@ class _MediaModule extends CoreClass {
 								.catch(next);
 						}
 
-						// TODO handle Spotify here
+						if (payload.mediaSource.startsWith("spotify:")) {
+							const trackId = payload.mediaSource.split(":")[1];
+
+							return SpotifyModule.runJob("GET_TRACK", { identifier: trackId, createMissing: true }, this)
+								.then(response => {
+									const { trackId, name, artists, albumImageUrl, duration } = response.track;
+									next(null, song, {
+										mediaSource: `spotify:${trackId}`,
+										title: name,
+										artists,
+										thumbnail: albumImageUrl,
+										duration
+									});
+								})
+								.catch(next);
+						}
 
 						return next("Invalid media source provided.");
 					},

+ 502 - 0
backend/logic/spotify.js

@@ -0,0 +1,502 @@
+import mongoose from "mongoose";
+import async from "async";
+import config from "config";
+
+import * as rax from "retry-axios";
+import axios from "axios";
+import url from "url";
+
+import CoreClass from "../core";
+
+let SpotifyModule;
+let DBModule;
+let CacheModule;
+let MediaModule;
+
+const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => {
+	return {
+		trackId: spotifyTrackObject.id,
+		name: spotifyTrackObject.name,
+		albumId: spotifyTrackObject.album.id,
+		albumTitle: spotifyTrackObject.album.title,
+		albumImageUrl: spotifyTrackObject.album.images[0].url,
+		artists: spotifyTrackObject.artists.map(artist => artist.name),
+		artistIds: spotifyTrackObject.artists.map(artist => artist.id),
+		duration: spotifyTrackObject.duration_ms / 1000,
+		explicit: spotifyTrackObject.explicit,
+		externalIds: spotifyTrackObject.external_ids,
+		popularity: spotifyTrackObject.popularity
+	};
+};
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed YouTube request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
+	 *
+	 * @returns {Promise} - promise that gets resolved when the rate limit allows it
+	 */
+	continue() {
+		return new Promise(resolve => {
+			if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
+			else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
+		});
+	}
+
+	/**
+	 * Restart the rate limit timer
+	 */
+	restart() {
+		this.dateStarted = Date.now();
+	}
+}
+
+class _SpotifyModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("spotify");
+
+		SpotifyModule = this;
+	}
+
+	/**
+	 * Initialises the spotify module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		DBModule = this.moduleManager.modules.db;
+		CacheModule = this.moduleManager.modules.cache;
+		MediaModule = this.moduleManager.modules.media;
+
+		// this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
+		// 	modelName: "youtubeApiRequest"
+		// });
+
+		this.spotifyTrackModel = this.SpotifyTrackModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "spotifyTrack"
+		});
+
+		return new Promise((resolve, reject) => {
+			this.rateLimiter = new RateLimitter(config.get("apis.spotify.rateLimit"));
+			this.requestTimeout = config.get("apis.spotify.requestTimeout");
+
+			this.axios = axios.create();
+			this.axios.defaults.raxConfig = {
+				instance: this.axios,
+				retry: config.get("apis.spotify.retryAmount"),
+				noResponseRetries: config.get("apis.spotify.retryAmount")
+			};
+			rax.attach(this.axios);
+
+			resolve();
+		});
+	}
+
+	/**
+	 *
+	 * @returns
+	 */
+	GET_API_TOKEN() {
+		return new Promise((resolve, reject) => {
+			CacheModule.runJob("GET", { key: "spotifyApiKey" }, this).then(spotifyApiKey => {
+				if (spotifyApiKey) {
+					resolve(spotifyApiKey);
+					return;
+				}
+
+				this.log("INFO", `No Spotify API token stored in cache, requesting new token.`);
+
+				const clientId = config.get("apis.spotify.clientId");
+				const clientSecret = config.get("apis.spotify.clientSecret");
+				const unencoded = `${clientId}:${clientSecret}`;
+				const encoded = Buffer.from(unencoded).toString("base64");
+
+				const params = new url.URLSearchParams({ grant_type: "client_credentials" });
+
+				SpotifyModule.axios
+					.post("https://accounts.spotify.com/api/token", params.toString(), {
+						headers: {
+							Authorization: `Basic ${encoded}`,
+							"Content-Type": "application/x-www-form-urlencoded"
+						}
+					})
+					.then(res => {
+						const { access_token: accessToken, expires_in: expiresIn } = res.data;
+
+						// TODO TTL can be later if stuck in queue
+						CacheModule.runJob(
+							"SET",
+							{ key: "spotifyApiKey", value: accessToken, ttl: expiresIn - 30 },
+							this
+						)
+							.then(spotifyApiKey => {
+								this.log(
+									"SUCCESS",
+									`Stored new Spotify API token in cache. Expires in ${expiresIn - 30}`
+								);
+								resolve(spotifyApiKey);
+							})
+							.catch(err => {
+								this.log(
+									"ERROR",
+									`Failed to store new Spotify API token in cache.`,
+									typeof err === "string" ? err : err.message
+								);
+								reject(err);
+							});
+					})
+					.catch(err => {
+						this.log(
+							"ERROR",
+							`Failed to get new Spotify API token.`,
+							typeof err === "string" ? err : err.message
+						);
+						reject(err);
+					});
+			});
+		});
+	}
+
+	/**
+	 * Perform Spotify API get track request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			const { trackId } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api.spotify.com/v1/tracks/${trackId}`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform Spotify API get playlist request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId, nextUrl } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: nextUrl || `https://api.spotify.com/v1/playlists/${playlistId}/tracks`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform Spotify API call
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.url - request url
+	 * @param {object} payload.params - request parameters
+	 * @param {object} payload.quotaCost - request quotaCost
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_CALL(payload) {
+		return new Promise((resolve, reject) => {
+			// const { url, params, quotaCost } = payload;
+			const { url } = payload;
+
+			SpotifyModule.runJob("GET_API_TOKEN", {}, this)
+				.then(spotifyApiToken => {
+					SpotifyModule.axios
+						.get(url, {
+							headers: {
+								Authorization: `Bearer ${spotifyApiToken}`
+							},
+							timeout: SpotifyModule.requestTimeout
+						})
+						.then(response => {
+							if (response.data.error) {
+								reject(new Error(response.data.error));
+							} else {
+								resolve({ response });
+							}
+						})
+						.catch(err => {
+							reject(err);
+						});
+				})
+				.catch(err => {
+					this.log(
+						"ERROR",
+						`Spotify API call failed as an error occured whilst getting the API token`,
+						typeof err === "string" ? err : err.message
+					);
+					resolve(err);
+				});
+		});
+	}
+
+	/**
+	 * Create Spotify track
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.spotifyTracks - the spotifyTracks
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CREATE_TRACKS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const { spotifyTracks } = payload;
+						if (!Array.isArray(spotifyTracks)) next("Invalid spotifyTracks type");
+						else {
+							const trackIds = spotifyTracks.map(spotifyTrack => spotifyTrack.trackId);
+
+							SpotifyModule.spotifyTrackModel.find({ trackId: trackIds }, (err, existingTracks) => {
+								if (err) return next(err);
+
+								const existingTrackIds = existingTracks.map(existingTrack => existingTrack.trackId);
+
+								const newSpotifyTracks = spotifyTracks.filter(
+									spotifyTrack => existingTrackIds.indexOf(spotifyTrack.trackId) === -1
+								);
+
+								SpotifyModule.spotifyTrackModel.insertMany(newSpotifyTracks, next);
+							});
+						}
+					},
+
+					(spotifyTracks, next) => {
+						const mediaSources = spotifyTracks.map(spotifyTrack => `spotify:${spotifyTrack.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, spotifyTracks);
+							}
+						);
+					}
+				],
+				(err, spotifyTracks) => {
+					if (err) reject(new Error(err));
+					else resolve({ spotifyTracks });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Get Spotify track
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.identifier - the spotify track ObjectId or track id
+	 * @param {string} payload.createMissing - attempt to fetch and create track if not in db
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const query = mongoose.isObjectIdOrHexString(payload.identifier)
+							? { _id: payload.identifier }
+							: { trackId: payload.identifier };
+
+						return SpotifyModule.spotifyTrackModel.findOne(query, next);
+					},
+
+					(track, next) => {
+						if (track) return next(null, track, false);
+						if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
+							return next("Spotify track not found.");
+						return SpotifyModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
+							.then(({ response }) => {
+								const { data } = response;
+
+								if (!data || !data.id)
+									return next("The specified track does not exist or cannot be publicly accessed.");
+
+								const spotifyTrack = spotifyTrackObjectToMusareTrackObject(data);
+
+								return next(null, false, spotifyTrack);
+							})
+							.catch(next);
+					},
+					(track, spotifyTrack, next) => {
+						if (track) return next(null, track, true);
+						return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks: [spotifyTrack] }, this)
+							.then(res => {
+								if (res.spotifyTracks.length === 1) next(null, res.spotifyTracks[0], false);
+								else next("Spotify track not found.");
+							})
+							.catch(next);
+					}
+				],
+				(err, track, existing) => {
+					if (err) reject(new Error(err));
+					else resolve({ track, existing });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a Spotify playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.url - the id of the Spotify playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const spotifyPlaylistUrlRegex = /.+open\.spotify\.com\/playlist\/(?<playlistId>[A-Za-z0-9]+)/;
+			const match = spotifyPlaylistUrlRegex.exec(payload.url);
+
+			if (!match || !match.groups) {
+				SpotifyModule.log("ERROR", "GET_PLAYLIST", "Invalid Spotify playlist URL query.");
+				reject(new Error("Invalid playlist URL."));
+				return;
+			}
+
+			const { playlistId } = match.groups;
+
+			async.waterfall(
+				[
+					next => {
+						let spotifyTracks = [];
+						let total = -1;
+						let nextUrl = "";
+
+						async.whilst(
+							next => {
+								SpotifyModule.log(
+									"INFO",
+									`Getting playlist progress for job (${this.toString()}): ${
+										spotifyTracks.length
+									} tracks gotten so far. Total tracks: ${total}.`
+								);
+								next(null, nextUrl !== null);
+							},
+							next => {
+								// Add 250ms delay between each job request
+								setTimeout(() => {
+									SpotifyModule.runJob("API_GET_PLAYLIST", { playlistId, nextUrl }, this)
+										.then(({ response }) => {
+											const { data } = response;
+
+											if (!data)
+												return next("The provided URL does not exist or cannot be accessed.");
+
+											total = data.total;
+											nextUrl = data.next;
+
+											const { items } = data;
+											const trackObjects = items.map(item => item.track);
+											const newSpotifyTracks = trackObjects.map(trackObject =>
+												spotifyTrackObjectToMusareTrackObject(trackObject)
+											);
+
+											spotifyTracks = spotifyTracks.concat(newSpotifyTracks);
+											next();
+										})
+										.catch(err => next(err));
+								}, 1000);
+							},
+							err => {
+								if (err) next(err);
+								else {
+									return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks }, this)
+										.then(() => {
+											next(
+												null,
+												spotifyTracks.map(spotifyTrack => spotifyTrack.trackId)
+											);
+										})
+										.catch(next);
+								}
+							}
+						);
+					}
+				],
+				(err, soundcloudTrackIds) => {
+					if (err && err !== true) {
+						SpotifyModule.log(
+							"ERROR",
+							"GET_PLAYLIST",
+							"Some error has occurred.",
+							typeof err === "string" ? err : err.message
+						);
+						reject(new Error(typeof err === "string" ? err : 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 _SpotifyModule();

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

@@ -5,6 +5,7 @@ import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import { useSearchMusare } from "@/composables/useSearchMusare";
 import { useYoutubeDirect } from "@/composables/useYoutubeDirect";
 import { useSoundcloudDirect } from "@/composables/useSoundcloudDirect";
+import { useSpotifyDirect } from "@/composables/useSpotifyDirect";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
 
 const SongItem = defineAsyncComponent(
@@ -42,6 +43,8 @@ const {
 const { youtubeDirect, addToPlaylist } = useYoutubeDirect();
 const { soundcloudDirect, addToPlaylist: soundcloudAddToPlaylist } =
 	useSoundcloudDirect();
+const { spotifyDirect, addToPlaylist: spotifyAddToPlaylist } =
+	useSpotifyDirect();
 
 watch(
 	() => youtubeSearch.value.songs.results,
@@ -308,6 +311,26 @@ onMounted(async () => {
 				>
 			</p>
 		</div>
+
+		<label class="label"> Add a Spotify 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 Spotify song URL here..."
+					v-model="spotifyDirect"
+					@keyup.enter="spotifyAddToPlaylist(playlist._id)"
+				/>
+			</p>
+			<p class="control">
+				<a
+					class="button is-info"
+					@click="spotifyAddToPlaylist(playlist._id)"
+					><i class="material-icons icon-with-button">add</i>Add</a
+				>
+			</p>
+		</div>
 	</div>
 </template>
 

+ 52 - 0
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -4,6 +4,7 @@ import { storeToRefs } from "pinia";
 import { ref } from "vue";
 import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import { useSearchSoundcloud } from "@/composables/useSearchSoundcloud";
+import { useSearchSpotify } from "@/composables/useSearchSpotify";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useLongJobsStore } from "@/stores/longJobs";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
@@ -21,6 +22,7 @@ const { setJob } = useLongJobsStore();
 
 const { youtubeSearch } = useSearchYoutube();
 const { soundcloudSearch } = useSearchSoundcloud();
+const { spotifySearch } = useSearchSpotify();
 
 const importMusarePlaylistFileInput = ref();
 const importMusarePlaylistFileContents = ref(null);
@@ -100,6 +102,35 @@ const importSoundcloudPlaylist = () => {
 	);
 };
 
+const importSpotifyPlaylist = () => {
+	let id;
+	let title;
+	// import query is blank
+	if (!spotifySearch.value.playlist.query)
+		return new Toast("Please enter a Spotify playlist URL.");
+
+	return socket.dispatch(
+		"playlists.addSpotifySetToPlaylist",
+		spotifySearch.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;
@@ -232,6 +263,27 @@ const importMusarePlaylistFile = () => {
 			</p>
 		</div>
 
+		<label class="label"> Import songs from Spotify playlist </label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					placeholder="Enter Spotify Playlist URL here..."
+					v-model="spotifySearch.playlist.query"
+					@keyup.enter="importSpotifyPlaylist()"
+				/>
+			</p>
+			<p class="control has-addons">
+				<button
+					class="button is-info"
+					@click.prevent="importSpotifyPlaylist()"
+				>
+					<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">

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

@@ -0,0 +1,97 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+export const useSearchSpotify = () => {
+	const spotifySearch = ref({
+		songs: {
+			results: [],
+			query: "",
+			nextPageToken: ""
+		},
+		playlist: {
+			query: ""
+		}
+	});
+
+	const { socket } = useWebsocketsStore();
+
+	const searchForSongs = () => {
+		// let { query } = spotifySearch.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.searchSpotify", query, res => {
+		// 	if (res.status === "success") {
+		// 		spotifySearch.value.songs.nextPageToken =
+		// 			res.data.nextPageToken;
+		// 		spotifySearch.value.songs.results = [];
+		// 		res.data.items.forEach(result => {
+		// 			spotifySearch.value.songs.results.push({
+		// 				id: result.id.videoId,
+		// 				url: `https://www.spotify.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.searchSpotifyForPage",
+		// 	spotifySearch.value.songs.query,
+		// 	spotifySearch.value.songs.nextPageToken,
+		// 	res => {
+		// 		if (res.status === "success") {
+		// 			spotifySearch.value.songs.nextPageToken =
+		// 				res.data.nextPageToken;
+		// 			res.data.items.forEach(result => {
+		// 				spotifySearch.value.songs.results.push({
+		// 					id: result.id.videoId,
+		// 					url: `https://www.spotify.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 addSpotifySongToPlaylist = (playlistId, id, index) => {
+		socket.dispatch(
+			"playlists.addSongToPlaylist",
+			false,
+			id,
+			playlistId,
+			res => {
+				new Toast(res.message);
+				if (res.status === "success")
+					spotifySearch.value.songs.results[index].isAddedToQueue =
+						true;
+			}
+		);
+	};
+
+	return {
+		spotifySearch,
+		// searchForSongs,
+		// loadMoreSongs,
+		addSpotifySongToPlaylist
+	};
+};

+ 72 - 0
frontend/src/composables/useSpotifyDirect.ts

@@ -0,0 +1,72 @@
+import { ref } from "vue";
+import Toast from "toasters";
+import { AddSongToPlaylistResponse } from "@musare_types/actions/PlaylistsActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+const spotifyTrackUrlRegex =
+	/.+open\.spotify\.com\/track\/(?<trackId>[A-Za-z0-9]+)/;
+
+export const useSpotifyDirect = () => {
+	const spotifyDirect = ref("");
+
+	const { socket } = useWebsocketsStore();
+
+	const getSpotifyTrackId = () => {
+		const match = spotifyTrackUrlRegex.exec(spotifyDirect.value.trim());
+		if (!match || !match.groups) return null;
+
+		const { trackId } = match.groups;
+
+		return trackId;
+	};
+
+	const addToPlaylist = (playlistId: string) => {
+		const spotifyTrackId = getSpotifyTrackId();
+
+		if (!spotifyTrackId)
+			new Toast(
+				`Could not determine the Spotify track id from the provided URL.`
+			);
+		else {
+			socket.dispatch(
+				"playlists.addSongToPlaylist",
+				false,
+				`spotify:${spotifyTrackId}`,
+				playlistId,
+				(res: AddSongToPlaylistResponse) => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else {
+						new Toast(res.message);
+						spotifyDirect.value = "";
+					}
+				}
+			);
+		}
+	};
+
+	// const addToQueue = (stationId: string) => {
+	// 	const url = spotifyDirect.value.trim();
+
+	// 	socket.dispatch(
+	// 		"stations.addToQueue",
+	// 		stationId,
+	// 		url,
+	// 		"manual",
+	// 		res => {
+	// 			if (res.status !== "success")
+	// 				new Toast(`Error: ${res.message}`);
+	// 			else {
+	// 				new Toast(res.message);
+	// 				spotifyDirect.value = "";
+	// 			}
+	// 		}
+	// 	);
+	// };
+
+	return {
+		spotifyDirect,
+		addToPlaylist
+		// addToQueue
+	};
+};