Browse Source

feat: worked on artist Spotify converting

Kristian Vos 2 years ago
parent
commit
1594e83f9d

+ 68 - 0
backend/logic/actions/apis.js

@@ -604,6 +604,74 @@ export default {
 		}
 	),
 
+	/**
+	 *
+	 *
+	 * @param session
+	 * @param trackId - the trackId
+	 * @param {Function} cb
+	 */
+	getAlternativeArtistSourcesForArtists: useHasPermission(
+		"admin.view.spotify",
+		function getAlternativeArtistSourcesForArtists(session, artistIds, collectAlternativeArtistSourcesOrigins, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!artistIds) {
+							next("Invalid artistIds provided.");
+							return;
+						}
+
+						next();
+					},
+
+					async () => {
+						this.keepLongJob();
+						this.publishProgress({
+							status: "started",
+							title: "Getting alternative artist sources for Spotify artists",
+							message: "Starting up",
+							id: this.toString()
+						});
+						console.log("KRIS@4", this.toString());
+						// await CacheModule.runJob(
+						// 	"RPUSH",
+						// 	{ key: `longJobs.${session.userId}`, value: this.toString() },
+						// 	this
+						// );
+
+						SpotifyModule.runJob(
+							"GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTISTS",
+							{ artistIds, collectAlternativeArtistSourcesOrigins },
+							this
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"APIS_GET_ALTERNATIVE_ARTIST_SOURCES",
+							`Getting alternative artist sources failed for "${artistIds.join(", ")}". "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
+					this.log(
+						"SUCCESS",
+						"APIS_GET_ALTERNATIVE_ARTIST_SOURCES",
+						`User "${session.userId}" started getting alternative artist spirces for "${artistIds.join(
+							", "
+						)}".`
+					);
+					return cb({
+						status: "success"
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Joins a room
 	 *

+ 22 - 1
backend/logic/actions/spotify.js

@@ -58,5 +58,26 @@ export default {
 				this.log("ERROR", "SPOTIFY_GET_ALBUMS_FROM_IDS", `Getting albums from ids failed. "${err}"`);
 				return cb({ status: "error", message: err });
 			});
-	})
+	}),
+
+	/**
+	 * Fetches artists from ids
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	getArtistsFromIds: useHasPermission(
+		"admin.view.spotify",
+		function getTracksFromMediaSources(session, artistIds, cb) {
+			SpotifyModule.runJob("GET_ARTISTS_FROM_IDS", { artistIds }, this)
+				.then(artists => {
+					this.log("SUCCESS", "SPOTIFY_GET_ARTISTS_FROM_IDS", `Getting artists from ids was successful.`);
+					return cb({ status: "success", data: { artists } });
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SPOTIFY_GET_ARTISTS_FROM_IDS", `Getting artists from ids failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				});
+		}
+	)
 };

+ 5 - 1
backend/logic/db/index.js

@@ -23,6 +23,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	soundcloudTrack: 1,
 	spotifyTrack: 1,
 	spotifyAlbum: 1,
+	spotifyArtist: 1,
 	genericApiRequest: 1
 };
 
@@ -85,6 +86,7 @@ class _DBModule extends CoreClass {
 						soundcloudTrack: {},
 						spotifyTrack: {},
 						spotifyAlbum: {},
+						spotifyArtist: {},
 						genericApiRequest: {}
 					};
 
@@ -114,6 +116,7 @@ class _DBModule extends CoreClass {
 					await importSchema("soundcloudTrack");
 					await importSchema("spotifyTrack");
 					await importSchema("spotifyAlbum");
+					await importSchema("spotifyArtist");
 					await importSchema("genericApiRequest");
 
 					this.models = {
@@ -135,6 +138,7 @@ class _DBModule extends CoreClass {
 						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack),
 						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack),
 						spotifyAlbum: mongoose.model("spotifyAlbum", this.schemas.spotifyAlbum),
+						spotifyArtist: mongoose.model("spotifyArtist", this.schemas.spotifyArtist),
 						genericApiRequest: mongoose.model("genericApiRequest", this.schemas.genericApiRequest)
 					};
 
@@ -291,7 +295,7 @@ class _DBModule extends CoreClass {
 					this.models.stationHistory.syncIndexes();
 					this.models.soundcloudTrack.syncIndexes();
 					this.models.spotifyTrack.syncIndexes();
-					this.models.spotifyAlbum.syncIndexes();
+					this.models.spotifyArtist.syncIndexes();
 					this.models.genericApiRequest.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();

+ 8 - 0
backend/logic/db/schemas/spotifyArtist.js

@@ -0,0 +1,8 @@
+export default {
+	artistId: { type: String, unique: true },
+
+	rawData: { type: Object },
+
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 215 - 0
backend/logic/spotify.js

@@ -99,6 +99,9 @@ class _SpotifyModule extends CoreClass {
 		this.spotifyAlbumModel = this.SpotifyAlbumModel = await DBModule.runJob("GET_MODEL", {
 			modelName: "spotifyAlbum"
 		});
+		this.spotifyArtistModel = this.SpotifyArtistModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "spotifyArtist"
+		});
 
 		return new Promise((resolve, reject) => {
 			if (!config.has("apis.spotify") || !config.get("apis.spotify.enabled")) {
@@ -216,6 +219,36 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Perform Spotify API get artists request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.artistIds - the artist ids to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_ARTISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const { artistIds } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api.spotify.com/v1/artists`,
+					params: {
+						ids: artistIds.join(",")
+					}
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
 	/**
 	 * Perform Spotify API get track request
 	 *
@@ -403,6 +436,36 @@ class _SpotifyModule extends CoreClass {
 		return existingAlbums.concat(newSpotifyAlbums);
 	}
 
+	/**
+	 * Create Spotify artists
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.spotifyArtists - the Spotify artists
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async CREATE_ARTISTS(payload) {
+		const { spotifyArtists } = payload;
+
+		if (!Array.isArray(spotifyArtists)) throw new Error("Invalid spotifyArtists type");
+
+		const artistIds = spotifyArtists.map(spotifyArtist => spotifyArtist.artistId);
+
+		const existingArtists = (await SpotifyModule.spotifyArtistModel.find({ artistId: artistIds })).map(
+			artist => artist._doc
+		);
+		const existingArtistIds = existingArtists.map(existingArtist => existingArtist.artistId);
+
+		const newSpotifyArtists = spotifyArtists.filter(
+			spotifyArtist => existingArtistIds.indexOf(spotifyArtist.artistId) === -1
+		);
+
+		if (newSpotifyArtists.length === 0) return existingArtists;
+
+		await SpotifyModule.spotifyArtistModel.insertMany(newSpotifyArtists);
+
+		return existingArtists.concat(newSpotifyArtists);
+	}
+
 	/**
 	 * Gets tracks from media sources
 	 *
@@ -501,6 +564,58 @@ class _SpotifyModule extends CoreClass {
 		return existingAlbums.concat(newAlbums);
 	}
 
+	/**
+	 * Gets artists from ids
+	 *
+	 * @param {object} payload
+	 * @returns {Promise}
+	 */
+	async GET_ARTISTS_FROM_IDS(payload) {
+		const { artistIds } = payload;
+
+		console.log(artistIds);
+
+		const existingArtists = (await SpotifyModule.spotifyArtistModel.find({ artistId: artistIds })).map(
+			artist => artist._doc
+		);
+		const existingArtistIds = existingArtists.map(existingArtist => existingArtist.artistId);
+
+		console.log(existingArtists);
+
+		const missingArtistIds = artistIds.filter(artistId => existingArtistIds.indexOf(artistId) === -1);
+
+		if (missingArtistIds.length === 0) return existingArtists;
+
+		console.log(missingArtistIds);
+
+		const jobsToRun = [];
+
+		const chunkSize = 2;
+		while (missingArtistIds.length > 0) {
+			const chunkedMissingArtistIds = missingArtistIds.splice(0, chunkSize);
+
+			jobsToRun.push(SpotifyModule.runJob("API_GET_ARTISTS", { artistIds: chunkedMissingArtistIds }, this));
+		}
+
+		const jobResponses = await Promise.all(jobsToRun);
+
+		console.log(jobResponses);
+
+		const newArtists = jobResponses
+			.map(jobResponse => jobResponse.response.data.artists)
+			.flat()
+			.map(artist => ({
+				artistId: artist.id,
+				rawData: artist
+			}));
+
+		console.log(newArtists);
+
+		await SpotifyModule.runJob("CREATE_ARTISTS", { spotifyArtists: newArtists }, this);
+
+		return existingArtists.concat(newArtists);
+	}
+
 	/**
 	 * Get Spotify track
 	 *
@@ -673,6 +788,106 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
+	/**
+	 *
+	 * @param {*} payload
+	 * @returns
+	 */
+	async GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTISTS(payload) {
+		const { artistIds, collectAlternativeArtistSourcesOrigins } = payload;
+
+		await async.eachLimit(artistIds, 1, async artistId => {
+			try {
+				const result = await SpotifyModule.runJob(
+					"GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTIST",
+					{ artistId, collectAlternativeArtistSourcesOrigins },
+					this
+				);
+				this.publishProgress({
+					status: "working",
+					message: `Got alternative artist source for ${artistId}`,
+					data: {
+						artistId,
+						status: "success",
+						result
+					}
+				});
+			} catch (err) {
+				console.log("ERROR", err);
+				this.publishProgress({
+					status: "working",
+					message: `Failed to get alternative artist source for ${artistId}`,
+					data: {
+						artistId,
+						status: "error"
+					}
+				});
+			}
+		});
+
+		console.log("Done!");
+
+		this.publishProgress({
+			status: "finished",
+			message: `Finished getting alternative artist sources`
+		});
+	}
+
+	/**
+	 *
+	 * @param {*} payload
+	 * @returns
+	 */
+	async GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTIST(payload) {
+		const { artistId, collectAlternativeArtistSourcesOrigins } = payload;
+
+		if (!artistId) throw new Error("Artist id provided is not valid.");
+
+		// const artist = await SpotifyModule.runJob(
+		// 	"GET_ARTIST",
+		// 	{
+		// 		identifier: artistId
+		// 	},
+		// 	this
+		// );
+
+		const wikiDataResponse = await WikiDataModule.runJob(
+			"API_GET_DATA_FROM_SPOTIFY_ARTIST",
+			{ spotifyArtistId: artistId },
+			this
+		);
+
+		const youtubeChannelIds = Array.from(
+			new Set(
+				wikiDataResponse.results.bindings
+					.filter(binding => !!binding.YouTube_channel_ID)
+					.map(binding => binding.YouTube_channel_ID.value)
+			)
+		);
+
+		const soundcloudIds = Array.from(
+			new Set(
+				wikiDataResponse.results.bindings
+					.filter(binding => !!binding.SoundCloud_ID)
+					.map(binding => binding.SoundCloud_ID.value)
+			)
+		);
+
+		const musicbrainzArtistIds = Array.from(
+			new Set(
+				wikiDataResponse.results.bindings
+					.filter(binding => !!binding.MusicBrainz_artist_ID)
+					.map(binding => binding.MusicBrainz_artist_ID.value)
+			)
+		);
+
+		console.log("Youtube channel ids", youtubeChannelIds);
+		console.log("Soundcloud ids", soundcloudIds);
+		console.log("Musicbrainz artist ids", musicbrainzArtistIds);
+
+		return youtubeChannelIds;
+	}
+
 	/**
 	 *
 	 * @param {*} payload

+ 41 - 0
backend/logic/wikidata.js

@@ -295,6 +295,47 @@ class _WikiDataModule extends CoreClass {
 		);
 	}
 
+	/**
+	 * Get WikiData data from Spotify artist id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.spotifyArtistId - Spotify artist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_SPOTIFY_ARTIST(payload) {
+		const { spotifyArtistId } = payload;
+
+		if (!spotifyArtistId) throw new Error("Invalid Spotify artist ID provided.");
+
+		const sparqlQuery =
+			`SELECT DISTINCT ?item ?itemLabel ?YouTube_channel_ID ?SoundCloud_ID ?MusicBrainz_artist_ID WHERE {
+				SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }
+				{
+					SELECT DISTINCT ?item WHERE {
+						?item p:P1902 ?statement0.
+						?statement0 ps:P1902 "${spotifyArtistId}".
+					}
+					LIMIT 100
+				}
+				OPTIONAL { ?item wdt:P2397 ?YouTube_channel_ID. }
+				OPTIONAL { ?item wdt:P3040 ?SoundCloud_ID. }
+				OPTIONAL { ?item wdt:P434 ?MusicBrainz_artist_ID. }
+			}`
+				.replaceAll("\n", "")
+				.replaceAll("\t", "");
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
 	/**
 	 * Perform WikiData API call
 	 *

+ 446 - 3
frontend/src/components/modals/ConvertSpotifySongs.vue

@@ -48,6 +48,9 @@ const loadedSpotifyTracks = ref(false);
 const loadingSpotifyAlbums = ref(false);
 const loadedSpotifyAlbums = ref(false);
 
+const loadingSpotifyArtists = ref(false);
+const loadedSpotifyArtists = ref(false);
+
 const gettingAllAlternativeMediaPerTrack = ref(false);
 const gotAllAlternativeMediaPerTrack = ref(false);
 const alternativeMediaPerTrack = reactive({});
@@ -56,6 +59,10 @@ const gettingAllAlternativeAlbums = ref(false);
 const gotAllAlternativeAlbums = ref(false);
 const alternativeAlbumsPerAlbum = reactive({});
 
+const gettingAllAlternativeArtists = ref(false);
+const gotAllAlternativeArtists = ref(false);
+const alternativeArtistsPerArtist = reactive({});
+
 const alternativeMediaMap = reactive({});
 const alternativeMediaFailedMap = reactive({});
 
@@ -63,8 +70,8 @@ const gettingMissingAlternativeMedia = ref(false);
 
 const replacingAllSpotifySongs = ref(false);
 
-const currentConvertType = ref<"track" | "album" | "artist">("album");
-const showReplaceButtonPerAlternative = ref(false);
+const currentConvertType = ref<"track" | "album" | "artist">("track");
+const showReplaceButtonPerAlternative = ref(true);
 const hideSpotifySongsWithNoAlternativesFound = ref(false);
 
 const preferredAlternativeSongMode = ref<
@@ -77,9 +84,13 @@ const showExtra = ref(false);
 const collectAlternativeMediaSourcesOrigins = ref(false);
 
 const minimumSongsPerAlbum = ref(2);
+const minimumSongsPerArtist = ref(2);
 const sortAlbumMode = ref<
 	"SONG_COUNT_ASC" | "SONG_COUNT_DESC" | "NAME_DESC" | "NAME_ASC"
 >("SONG_COUNT_ASC");
+const sortArtistMode = ref<
+	"SONG_COUNT_ASC" | "SONG_COUNT_DESC" | "NAME_DESC" | "NAME_ASC"
+>("SONG_COUNT_ASC");
 
 const showDontConvertButton = ref(true);
 
@@ -92,6 +103,7 @@ const youtubeVideoUrlRegex =
 const youtubeVideoIdRegex = /^([\w-]{11})$/;
 
 const youtubePlaylistUrlRegex = /[\\?&]list=([^&#]*)/;
+const youtubeChannelUrlRegex = /channel\/([A-Za-z0-9]+)\/?/;
 
 const filteredSpotifySongs = computed(() =>
 	hideSpotifySongsWithNoAlternativesFound.value
@@ -106,6 +118,44 @@ const filteredSpotifySongs = computed(() =>
 		: spotifySongs.value
 );
 
+const filteredSpotifyArtists = computed(() => {
+	let artists = Object.values(spotifyArtists);
+
+	artists = artists.filter(
+		artist => artist.songs.length >= minimumSongsPerArtist.value
+	);
+
+	let sortFn = null;
+	if (sortArtistMode.value === "SONG_COUNT_ASC")
+		sortFn = (artistA, artistB) =>
+			artistA.songs.length - artistB.songs.length;
+	else if (sortArtistMode.value === "SONG_COUNT_DESC")
+		sortFn = (artistA, artistB) =>
+			artistB.songs.length - artistA.songs.length;
+	else if (loadedSpotifyArtists.value && sortArtistMode.value === "NAME_ASC")
+		sortFn = (artistA, artistB) => {
+			const nameA = artistA.rawData?.name?.toLowerCase();
+			const nameB = artistB.rawData?.name?.toLowerCase();
+
+			if (nameA === nameB) return 0;
+			if (nameA < nameB) return -1;
+			if (nameA > nameB) return 1;
+		};
+	else if (loadedSpotifyArtists.value && sortArtistMode.value === "NAME_DESC")
+		sortFn = (artistA, artistB) => {
+			const nameA = artistA.rawData?.name?.toLowerCase();
+			const nameB = artistB.rawData?.name?.toLowerCase();
+
+			if (nameA === nameB) return 0;
+			if (nameA > nameB) return -1;
+			if (nameA < nameB) return 1;
+		};
+
+	if (sortFn) artists = artists.sort(sortFn);
+
+	return artists;
+});
+
 const filteredSpotifyAlbums = computed(() => {
 	let albums = Object.values(spotifyAlbums);
 
@@ -354,6 +404,44 @@ const openReplaceAlbumModalFromUrl = spotifyAlbumId => {
 	openReplaceAlbumModal(spotifyAlbumId, youtubePlaylistId);
 };
 
+const openReplaceArtistModal = (spotifyArtistId, youtubeChannelUrl) => {
+	console.log(spotifyArtistId, youtubeChannelUrl);
+
+	if (
+		!spotifyArtists[spotifyArtistId] ||
+		!spotifyArtists[spotifyArtistId].rawData
+	)
+		return new Toast("Artist hasn't loaded yet.");
+
+	openModal({
+		modal: "replaceSpotifySongs",
+		props: {
+			playlistId: props.playlistId,
+			youtubeChannelUrl,
+			spotifyTracks: spotifyArtists[spotifyArtistId].songs.map(
+				mediaSource => spotifyTracks[mediaSource]
+			)
+		}
+	});
+};
+
+const openReplaceArtistModalFromUrl = spotifyArtistId => {
+	const replacementUrl = replaceSongUrlMap[`artist:${spotifyArtistId}`];
+
+	console.log(spotifyArtistId, replacementUrl);
+
+	// let youtubeChannelId = null;
+
+	// const youtubeChannelUrlRegexMatches =
+	// 	youtubeChannelUrlRegex.exec(replacementUrl);
+	// if (youtubeChannelUrlRegexMatches)
+	// 	youtubeChannelId = youtubeChannelUrlRegexMatches[0];
+
+	console.log("Open modal for ", replacementUrl);
+
+	openReplaceArtistModal(spotifyArtistId, replacementUrl);
+};
+
 const replaceSongFromUrl = spotifyMediaSource => {
 	const replacementUrl = replaceSongUrlMap[spotifyMediaSource];
 
@@ -407,6 +495,52 @@ const getMissingAlternativeMedia = () => {
 	);
 };
 
+const getAlternativeArtists = () => {
+	if (gettingAllAlternativeArtists.value || gotAllAlternativeArtists.value)
+		return;
+
+	gettingAllAlternativeArtists.value = true;
+
+	const artistIds = filteredSpotifyArtists.value.map(
+		artist => artist.artistId
+	);
+
+	socket.dispatch(
+		"apis.getAlternativeArtistSourcesForArtists",
+		artistIds,
+		collectAlternativeMediaSourcesOrigins.value,
+		{
+			cb: res => {
+				console.log(
+					"apis.getAlternativeArtistSourcesForArtists response",
+					res
+				);
+			},
+			onProgress: data => {
+				console.log(
+					"apis.getAlternativeArtistSourcesForArtists onProgress",
+					data
+				);
+
+				if (data.status === "working") {
+					if (data.data.status === "success") {
+						const { artistId, result } = data.data;
+
+						if (!spotifyArtists[artistId]) return;
+
+						alternativeArtistsPerArtist[artistId] = {
+							youtubeChannelIds: result
+						};
+					}
+				} else if (data.status === "finished") {
+					gotAllAlternativeArtists.value = true;
+					gettingAllAlternativeArtists.value = false;
+				}
+			}
+		}
+	);
+};
+
 const getAlternativeAlbums = () => {
 	if (gettingAllAlternativeAlbums.value || gotAllAlternativeAlbums.value)
 		return;
@@ -490,12 +624,48 @@ const getAlternativeMedia = () => {
 				} else if (data.status === "finished") {
 					gotAllAlternativeMediaPerTrack.value = true;
 					gettingAllAlternativeMediaPerTrack.value = false;
+
+					getMissingAlternativeMedia();
 				}
 			}
 		}
 	);
 };
 
+const loadSpotifyArtists = () =>
+	new Promise<void>(resolve => {
+		console.debug(TAG, "Loading Spotify artists");
+
+		loadingSpotifyArtists.value = true;
+
+		const artistIds = filteredSpotifyArtists.value.map(
+			artist => artist.artistId
+		);
+
+		socket.dispatch("spotify.getArtistsFromIds", artistIds, res => {
+			console.debug(TAG, "Get artists response", res);
+
+			if (res.status !== "success") {
+				new Toast(res.message);
+				closeCurrentModal();
+				return;
+			}
+
+			const { artists } = res.data;
+
+			artists.forEach(artist => {
+				spotifyArtists[artist.artistId].rawData = artist.rawData;
+			});
+
+			console.debug(TAG, "Loaded Spotify artists");
+
+			loadedSpotifyArtists.value = true;
+			loadingSpotifyArtists.value = false;
+
+			resolve();
+		});
+	});
+
 const loadSpotifyAlbums = () =>
 	new Promise<void>(resolve => {
 		console.debug(TAG, "Loading Spotify albums");
@@ -811,6 +981,32 @@ onMounted(() => {
 							>
 								Get alternative albums
 							</button>
+
+							<button
+								v-if="
+									loadedSpotifyTracks &&
+									!loadingSpotifyArtists &&
+									!loadedSpotifyArtists &&
+									currentConvertType === 'artist'
+								"
+								class="button is-primary"
+								@click="loadSpotifyArtists()"
+							>
+								Get Spotify artists
+							</button>
+							<button
+								v-if="
+									loadedSpotifyTracks &&
+									loadedSpotifyArtists &&
+									!gettingAllAlternativeArtists &&
+									!gotAllAlternativeArtists &&
+									currentConvertType === 'artist'
+								"
+								class="button is-primary"
+								@click="getAlternativeArtists()"
+							>
+								Get alternative artists
+							</button>
 						</div>
 
 						<div class="options">
@@ -992,7 +1188,27 @@ onMounted(() => {
 								</div>
 							</div>
 
-							<div class="control">
+							<div
+								class="small-section"
+								v-if="currentConvertType === 'artist'"
+							>
+								<label class="label"
+									>Minimum songs per artist</label
+								>
+								<div class="control is-expanded">
+									<input
+										class="input"
+										type="number"
+										min="1"
+										v-model="minimumSongsPerArtist"
+									/>
+								</div>
+							</div>
+
+							<div
+								class="control"
+								v-if="currentConvertType === 'album'"
+							>
 								<label class="label">Sort album mode</label>
 								<p class="control is-expanded select">
 									<select v-model="sortAlbumMode">
@@ -1011,6 +1227,29 @@ onMounted(() => {
 									</select>
 								</p>
 							</div>
+
+							<div
+								class="control"
+								v-if="currentConvertType === 'artist'"
+							>
+								<label class="label">Sort artist mode</label>
+								<p class="control is-expanded select">
+									<select v-model="sortArtistMode">
+										<option value="SONG_COUNT_ASC">
+											Song count (ascending)
+										</option>
+										<option value="SONG_COUNT_DESC">
+											Song count (descending)
+										</option>
+										<option value="NAME_ASC">
+											Name (ascending)
+										</option>
+										<option value="NAME_DESC">
+											Name (descending)
+										</option>
+									</select>
+								</p>
+							</div>
 						</div>
 
 						<div class="info">
@@ -1587,6 +1826,210 @@ onMounted(() => {
 							</div>
 						</template>
 					</div>
+
+					<div
+						class="convert-table convert-song-by-artist"
+						v-if="currentConvertType === 'artist'"
+					>
+						<h4>Spotify artists</h4>
+						<h4>Alternative artists (channels)</h4>
+
+						<template
+							v-for="spotifyArtist in filteredSpotifyArtists"
+							:key="spotifyArtist"
+						>
+							<div
+								class="convert-table-cell convert-table-cell-left"
+							>
+								<p>Artist ID: {{ spotifyArtist.artistId }}</p>
+								<p v-if="loadingSpotifyArtists">
+									Loading artist info...
+								</p>
+								<p
+									v-else-if="
+										loadedSpotifyArtists &&
+										!spotifyArtist.rawData
+									"
+								>
+									Failed to load artist info...
+								</p>
+								<template v-else-if="loadedSpotifyArtists">
+									<p>
+										Name: {{ spotifyArtist.rawData.name }}
+									</p>
+									<!-- <p>
+										Label: {{ spotifyArtist.rawData.label }}
+									</p>
+									<p>
+										Popularity:
+										{{ spotifyArtist.rawData.popularity }}
+									</p>
+									<p>
+										Release date:
+										{{ spotifyArtist.rawData.release_date }}
+									</p>
+									<p>
+										Artists:
+										{{
+											spotifyArtist.rawData.artists
+												.map(artist => artist.name)
+												.join(", ")
+										}}
+									</p>
+									<p>
+										UPC:
+										{{
+											spotifyArtist.rawData.external_ids
+												.upc
+										}}
+									</p> -->
+								</template>
+								<song-item
+									v-for="spotifyMediaSource in spotifyArtist.songs"
+									:key="
+										spotifyArtist.artistId +
+										spotifyMediaSource
+									"
+									:song="{
+										mediaSource: spotifyMediaSource,
+										title: spotifyTracks[spotifyMediaSource]
+											.name,
+										artists:
+											spotifyTracks[spotifyMediaSource]
+												.artists,
+										duration:
+											spotifyTracks[spotifyMediaSource]
+												.duration,
+										thumbnail:
+											spotifyTracks[spotifyMediaSource]
+												.albumImageUrl
+									}"
+								>
+									<template #leftIcon>
+										<a
+											:href="`https://open.spotify.com/track/${
+												spotifyMediaSource.split(':')[1]
+											}`"
+											target="_blank"
+										>
+											<div
+												class="spotify-icon left-icon"
+											></div>
+										</a>
+									</template>
+								</song-item>
+							</div>
+							<div
+								class="convert-table-cell convert-table-cell-right"
+							>
+								<p
+									v-if="
+										!alternativeArtistsPerArtist[
+											spotifyArtist.artistId
+										]
+									"
+								>
+									No alternatives loaded
+								</p>
+								<div
+									class="alternative-artist-items"
+									v-if="
+										alternativeArtistsPerArtist[
+											spotifyArtist.artistId
+										]
+									"
+								>
+									<p
+										v-if="
+											alternativeArtistsPerArtist[
+												spotifyArtist.artistId
+											].youtubeChannelIds.length === 0
+										"
+									>
+										No alternative channels were found
+									</p>
+									<div
+										class="alternative-artist-item"
+										v-for="youtubeChannelId in alternativeArtistsPerArtist[
+											spotifyArtist.artistId
+										].youtubeChannelIds"
+										:key="
+											spotifyArtist.artistId +
+											youtubeChannelId
+										"
+									>
+										<p>
+											YouTube channel
+											{{ youtubeChannelId }} has been
+											automatically found
+										</p>
+										<button
+											class="button is-primary is-fullwidth"
+											@click="
+												openReplaceArtistModal(
+													spotifyArtist.artistId,
+													`https://youtube.com/channel/${youtubeChannelId}`
+												)
+											"
+										>
+											Open replace modal
+										</button>
+									</div>
+								</div>
+
+								<div
+									v-if="
+										showReplacementInputs ||
+										(alternativeArtistsPerArtist[
+											spotifyArtist.artistId
+										] &&
+											alternativeArtistsPerArtist[
+												spotifyArtist.artistId
+											].youtubeChannelIds.length === 0)
+									"
+								>
+									<div>
+										<label class="label">
+											Enter replacement YouTube channel
+											URL
+										</label>
+										<div
+											class="control is-grouped input-with-button"
+										>
+											<p class="control is-expanded">
+												<input
+													class="input"
+													type="text"
+													placeholder="Enter your channel URL here..."
+													v-model="
+														replaceSongUrlMap[
+															`artist:${spotifyArtist.artistId}`
+														]
+													"
+													@keyup.enter="
+														openReplaceArtistModalFromUrl(
+															spotifyArtist.artistId
+														)
+													"
+												/>
+											</p>
+											<p class="control">
+												<a
+													class="button is-info"
+													@click="
+														openReplaceArtistModalFromUrl(
+															spotifyArtist.artistId
+														)
+													"
+													>Open replace modal</a
+												>
+											</p>
+										</div>
+									</div>
+								</div>
+							</div>
+						</template>
+					</div>
 				</template>
 			</template>
 		</modal>

+ 60 - 2
frontend/src/components/modals/ReplaceSpotifySongs.vue

@@ -15,7 +15,8 @@ const props = defineProps({
 	spotifyAlbum: { type: Object, default: () => ({}) },
 	spotifyTracks: { type: Array, default: () => [] },
 	playlistId: { type: String },
-	youtubePlaylistId: { type: String }
+	youtubePlaylistId: { type: String },
+	youtubeChannelUrl: { type: String }
 });
 
 const { socket } = useWebsocketsStore();
@@ -142,6 +143,62 @@ const tryToAutoMove = () => {
 	});
 };
 
+const importChannel = () => {
+	if (hasImportedPlaylist.value)
+		return new Toast("A playlist has already imported.");
+	if (isImportingPlaylist.value)
+		return new Toast("A playlist is already importing.");
+
+	isImportingPlaylist.value = true;
+
+	// don't give starting import message instantly in case of instant error
+	setTimeout(() => {
+		if (isImportingPlaylist.value) {
+			new Toast(
+				"Starting to import your channel. This can take some time to do."
+			);
+		}
+	}, 750);
+
+	return socket.dispatch(
+		"youtube.requestSet",
+		props.youtubeChannelUrl,
+		false,
+		true,
+		res => {
+			const mediaSources = res.videos.map(
+				video => `youtube:${video.youtubeId}`
+			);
+
+			socket.dispatch(
+				"songs.getSongsFromMediaSources",
+				mediaSources,
+				res => {
+					if (res.status === "success") {
+						const { songs } = res.data;
+
+						playlistSongs.value = songs;
+
+						songs.forEach(() => {
+							trackSongs.value.push([]);
+						});
+
+						hasImportedPlaylist.value = true;
+						isImportingPlaylist.value = false;
+
+						tryToAutoMove();
+						return;
+					}
+
+					new Toast("Could not get songs.");
+				}
+			);
+
+			return new Toast({ content: res.message, timeout: 20000 });
+		}
+	);
+};
+
 const importPlaylist = () => {
 	if (hasImportedPlaylist.value)
 		return new Toast("A playlist has already imported.");
@@ -201,7 +258,8 @@ const importPlaylist = () => {
 onMounted(() => {
 	localSpotifyTracks.value = props.spotifyTracks;
 
-	importPlaylist();
+	if (props.youtubePlaylistId) importPlaylist();
+	else if (props.youtubeChannelUrl) importChannel();
 });
 
 onBeforeUnmount(() => {});