ソースを参照

feat: a lot more work on converting Spotify to alternative media sources

Kristian Vos 1 年間 前
コミット
3988b5b11f

+ 1 - 0
backend/index.js

@@ -271,6 +271,7 @@ if (!config.get("migration")) {
 	moduleManager.addModule("soundcloud");
 	moduleManager.addModule("spotify");
 	moduleManager.addModule("musicbrainz");
+	moduleManager.addModule("wikidata");
 } else {
 	moduleManager.addModule("migration");
 }

+ 35 - 29
backend/logic/actions/apis.js

@@ -404,17 +404,17 @@ export default {
 	// 					}`;
 	// 					// OPTIONAL { ?item wdt:P3040 ?SoundCloud_track_ID. }
 
-	// 					const options = {
-	// 						params: { query: sparqlQuery },
-	// 						headers: {
-	// 							Accept: "application/sparql-results+json"
-	// 						}
-	// 					};
+	// const options = {
+	// 	params: { query: sparqlQuery },
+	// 	headers: {
+	// 		Accept: "application/sparql-results+json"
+	// 	}
+	// };
 
-	// 					axios
-	// 						.get(endpointUrl, options)
-	// 						.then(res => next(null, res.data))
-	// 						.catch(err => next(err));
+	// axios
+	// 	.get(endpointUrl, options)
+	// 	.then(res => next(null, res.data))
+	// 	.catch(err => next(err));
 	// 				},
 
 	// 				(body, next) => {
@@ -477,14 +477,14 @@ export default {
 	 * @param trackId - the trackId
 	 * @param {Function} cb
 	 */
-	getAlternativeMediaSourcesForTrack: useHasPermission(
+	getAlternativeMediaSourcesForTracks: useHasPermission(
 		"admin.view.spotify",
-		function getAlternativeMediaSourcesForTrack(session, mediaSource, cb) {
+		function getAlternativeMediaSourcesForTracks(session, mediaSources, cb) {
 			async.waterfall(
 				[
 					next => {
-						if (!mediaSource) {
-							next("Invalid mediaSource provided.");
+						if (!mediaSources) {
+							next("Invalid mediaSources provided.");
 							return;
 						}
 
@@ -492,34 +492,40 @@ export default {
 					},
 
 					async () => {
-						const alternativeMediaSources = await SpotifyModule.runJob(
-							"GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",
-							{ mediaSource }
-						);
-
-						return alternativeMediaSources;
+						this.keepLongJob();
+						this.publishProgress({
+							status: "started",
+							title: "Getting alternative media sources for Spotify tracks",
+							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_MEDIA_SOURCES_FOR_TRACKS", { mediaSources }, this);
 					}
 				],
-				async (err, alternativeMediaSources) => {
+				async err => {
 					if (err) {
 						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 						this.log(
 							"ERROR",
-							"APIS_SEARCH_TODO",
-							`Searching MusicBrainz ISRC failed with ISRC "${mediaSource}". "${err}"`
+							"APIS_GET_ALTERNATIVE_SOURCES",
+							`Getting alternative sources failed for "${mediaSources.join(", ")}". "${err}"`
 						);
 						return cb({ status: "error", message: err });
 					}
 					this.log(
 						"SUCCESS",
-						"APIS_SEARCH_TODO",
-						`User "${session.userId}" searched MusicBrainz ISRC succesfully for ISRC "${mediaSource}".`
+						"APIS_GET_ALTERNATIVE_SOURCES",
+						`User "${session.userId}" started getting alternatives for "${mediaSources.join(", ")}".`
 					);
 					return cb({
-						status: "success",
-						data: {
-							alternativeMediaSources
-						}
+						status: "success"
 					});
 				}
 			);

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

@@ -1315,6 +1315,194 @@ export default {
 		);
 	}),
 
+	/**
+	 * Adds a song to a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} oldMediaSource -
+	 * @param {string} newMediaSource -
+	 * @param {string} playlistId -
+	 * @param {Function} cb - gets called with the result
+	 */
+	replaceSongInPlaylist: isLoginRequired(async function replaceSongInPlaylist(
+		session,
+		oldMediaSource,
+		newMediaSource,
+		playlistId,
+		cb
+	) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							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);
+						})
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					MediaModule.runJob("GET_MEDIA", { mediaSource: newMediaSource }, 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": 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(null, song);
+							}
+						);
+					} else next(null, song);
+				},
+
+				(_song, next) => {
+					PlaylistsModule.runJob(
+						"REPLACE_SONG_IN_PLAYLIST",
+						{ playlistId, oldMediaSource, newMediaSource },
+						this
+					)
+						.then(res => {
+							const { playlist, song, ratings } = res;
+							next(null, playlist, song, ratings);
+						})
+						.catch(next);
+				}
+			],
+			async (err, playlist, newSong, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_ADD_SONG",
+						`Replacing song "${oldMediaSource}" with "${newMediaSource}" in private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_ADD_SONG",
+					`Successfully replaced song "${oldMediaSource}" with "${newMediaSource}" in private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				// if (!isSet && playlist.type === "user" && playlist.privacy === "public") {
+				// 	const songName = newSong.artists
+				// 		? `${newSong.title} by ${newSong.artists.join(", ")}`
+				// 		: newSong.title;
+
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "playlist__add_song",
+				// 		payload: {
+				// 			message: `Added <mediaSource>${songName}</mediaSource> to playlist <playlistId>${playlist.displayName}</playlistId>`,
+				// 			thumbnail: newSong.thumbnail,
+				// 			playlistId,
+				// 			mediaSource
+				// 		}
+				// 	});
+				// }
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.replaceSong",
+					value: {
+						playlistId: playlist._id,
+						song: newSong,
+						oldMediaSource,
+						createdBy: playlist.createdBy,
+						privacy: playlist.privacy
+					}
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
+				// if (ratings && (playlist.type === "user-liked" || playlist.type === "user-disliked")) {
+				// 	const { _id, mediaSource, title, artists, thumbnail } = newSong;
+				// 	const { likes, dislikes } = ratings;
+
+				// 	if (_id) SongsModule.runJob("UPDATE_SONG", { songId: _id });
+
+				// 	if (playlist.type === "user-liked") {
+				// 		CacheModule.runJob("PUB", {
+				// 			channel: "ratings.like",
+				// 			value: JSON.stringify({
+				// 				mediaSource,
+				// 				userId: session.userId,
+				// 				likes,
+				// 				dislikes
+				// 			})
+				// 		});
+
+				// 		ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 			userId: session.userId,
+				// 			type: "song__like",
+				// 			payload: {
+				// 				message: `Liked song <mediaSource>${title} by ${artists.join(", ")}</mediaSource>`,
+				// 				mediaSource,
+				// 				thumbnail
+				// 			}
+				// 		});
+				// 	} else {
+				// 		CacheModule.runJob("PUB", {
+				// 			channel: "ratings.dislike",
+				// 			value: JSON.stringify({
+				// 				mediaSource,
+				// 				userId: session.userId,
+				// 				likes,
+				// 				dislikes
+				// 			})
+				// 		});
+
+				// 		ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 			userId: session.userId,
+				// 			type: "song__dislike",
+				// 			payload: {
+				// 				message: `Disliked song <mediaSource>${title} by ${artists.join(
+				// 					mediaSource
+				// 				)}</mediaSource>`,
+				// 				mediaSource,
+				// 				thumbnail
+				// 			}
+				// 		});
+				// 	}
+				// }
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully replaced in the playlist",
+					data: { songs: playlist.songs }
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Adds songs to a playlist
 	 *

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

@@ -11,6 +11,7 @@ export default {
 	explicit: { type: Boolean },
 	externalIds: { type: Object },
 	popularity: { type: Number },
+	isLocal: { type: Boolean },
 
 	createdAt: { type: Date, default: Date.now, required: true },
 	documentVersion: { type: Number, default: 1, required: true }

+ 116 - 0
backend/logic/playlists.js

@@ -506,6 +506,122 @@ class _PlaylistsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Replaces a song in a playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.newMediaSource - the new media source
+	 * @param {string} payload.oldMediaSource - the old media source
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REPLACE_SONG_IN_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId, newMediaSource, oldMediaSource } = payload;
+
+			console.log("KRISISISIS", payload, newMediaSource, oldMediaSource);
+
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) return next("Playlist not found.");
+						if (playlist.songs.find(song => song.mediaSource === newMediaSource))
+							return next("The new song is already in the playlist.");
+						return next();
+					},
+
+					next => {
+						MediaModule.runJob("GET_MEDIA", { mediaSource: newMediaSource }, this)
+							.then(response => {
+								const { song } = response;
+								const { _id, title, artists, thumbnail, duration, verified } = song;
+								next(null, {
+									_id,
+									mediaSource: newMediaSource,
+									title,
+									artists,
+									thumbnail,
+									duration,
+									verified
+								});
+							})
+							.catch(next);
+					},
+
+					(newSong, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: playlistId, "songs.mediaSource": oldMediaSource },
+							{ $set: { "songs.$": newSong } },
+							{ runValidators: true },
+							err => {
+								if (err) return next(err);
+								return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+									.then(playlist => next(null, playlist, newSong))
+									.catch(next);
+							}
+						);
+					},
+
+					(playlist, newSong, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
+							.then(response => {
+								async.each(
+									response.stationIds,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
+											.then()
+											.catch();
+										next();
+									},
+									err => {
+										if (err) next(err);
+										else next(null, playlist, newSong);
+									}
+								);
+							})
+							.catch(next);
+					},
+
+					(playlist, newSong, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							MediaModule.runJob("RECALCULATE_RATINGS", {
+								mediaSource: newSong.mediaSource
+							})
+								.then(ratings => next(null, playlist, newSong, ratings))
+								.catch(next);
+						} else {
+							next(null, playlist, newSong, null);
+						}
+					},
+
+					(playlist, newSong, newRatings, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							MediaModule.runJob("RECALCULATE_RATINGS", {
+								mediaSource: oldMediaSource
+							})
+								.then(ratings => next(null, playlist, newSong, newRatings, oldRatings))
+								.catch(next);
+						} else {
+							next(null, playlist, newSong, null, null);
+						}
+					}
+				],
+				(err, playlist, song, newRatings, oldRatings) => {
+					if (err) reject(err);
+					else resolve({ playlist, song, newRatings, oldRatings });
+				}
+			);
+		});
+	}
+
 	/**
 	 * Remove from playlist
 	 *

+ 1 - 1
backend/logic/soundcloud.js

@@ -32,7 +32,7 @@ const soundcloudTrackObjectToMusareTrackObject = soundcloudTrackObject => {
 		track_format: trackFormat,
 		permalink,
 		monetization_model: monetizationModel,
-		policy: policy,
+		policy,
 		streamable,
 		sharing,
 		state,

+ 130 - 2
backend/logic/spotify.js

@@ -14,6 +14,7 @@ let DBModule;
 let CacheModule;
 let MediaModule;
 let MusicBrainzModule;
+let WikiDataModule;
 
 const youtubeVideoUrlRegex =
 	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
@@ -31,7 +32,8 @@ const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => {
 		duration: spotifyTrackObject.duration_ms / 1000,
 		explicit: spotifyTrackObject.explicit,
 		externalIds: spotifyTrackObject.external_ids,
-		popularity: spotifyTrackObject.popularity
+		popularity: spotifyTrackObject.popularity,
+		isLocal: spotifyTrackObject.is_local
 	};
 };
 
@@ -85,6 +87,7 @@ class _SpotifyModule extends CoreClass {
 		MediaModule = this.moduleManager.modules.media;
 		MusicBrainzModule = this.moduleManager.modules.musicbrainz;
 		SoundcloudModule = this.moduleManager.modules.soundcloud;
+		WikiDataModule = this.moduleManager.modules.wikidata;
 
 		// this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
 		// 	modelName: "youtubeApiRequest"
@@ -430,6 +433,7 @@ class _SpotifyModule extends CoreClass {
 				],
 				(err, track, existing) => {
 					if (err) reject(new Error(err));
+					else if (track.isLocal) reject(new Error("Track is local."));
 					else resolve({ track, existing });
 				}
 			);
@@ -533,6 +537,54 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
+	/**
+	 *
+	 * @param {*} payload
+	 * @returns
+	 */
+	async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACKS(payload) {
+		const { mediaSources } = payload;
+
+		// console.log("KR*S94955", mediaSources);
+
+		// this.pub
+
+		await async.eachLimit(mediaSources, 1, async mediaSource => {
+			try {
+				const result = await SpotifyModule.runJob(
+					"GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",
+					{ mediaSource },
+					this
+				);
+				this.publishProgress({
+					status: "working",
+					message: `Got alternative media for ${mediaSource}`,
+					data: {
+						mediaSource,
+						status: "success",
+						result
+					}
+				});
+			} catch (err) {
+				this.publishProgress({
+					status: "working",
+					message: `Failed to get alternative media for ${mediaSource}`,
+					data: {
+						mediaSource,
+						status: "error"
+					}
+				});
+			}
+		});
+
+		console.log("Done!");
+
+		this.publishProgress({
+			status: "finished",
+			message: `Finished getting alternative media`
+		});
+	}
+
 	/**
 	 *
 	 * @param {*} payload
@@ -570,7 +622,7 @@ class _SpotifyModule extends CoreClass {
 			this
 		);
 
-		console.dir(ISRCApiResponse);
+		console.dir(ISRCApiResponse, { depth: 5 });
 
 		const mediaSources = new Set();
 		const mediaSourcesOrigins = {};
@@ -655,6 +707,82 @@ class _SpotifyModule extends CoreClass {
 				}
 
 				if (relation["target-type"] === "work") {
+					console.log(relation, "GET WORK HERE");
+
+					const promise = new Promise(resolve => {
+						WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_WORK", { workId: relation.work.id }, this)
+							.then(resultBody => {
+								console.log("KRISWORKSUCCESS", resultBody);
+
+								const youtubeIds = Array.from(
+									new Set(
+										resultBody.results.bindings
+											.filter(binding => !!binding.YouTube_video_ID)
+											.map(binding => binding.YouTube_video_ID.value)
+									)
+								);
+								const soundcloudIds = Array.from(
+									new Set(
+										resultBody.results.bindings
+											.filter(binding => !!binding["SoundCloud_track_ID"])
+											.map(binding => binding["SoundCloud_track_ID"].value)
+									)
+								);
+
+								youtubeIds.forEach(youtubeId => {
+									const mediaSource = `youtube:${youtubeId}`;
+									const mediaSourceOrigins = [
+										`Spotify track ${spotifyTrackId}`,
+										`ISRC ${ISRC}`,
+										`MusicBrainz recordings`,
+										`MusicBrainz recording ${recording.id}`,
+										`MusicBrainz relations`,
+										`MusicBrainz relation target-type work`,
+										`MusicBrainz relation work id ${relation.work.id}`,
+										`WikiData select from MusicBrainz work id ${relation.work.id}`,
+										`YouTube ID ${youtubeId}`
+									];
+
+									mediaSources.add(mediaSource);
+									if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+									mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+								});
+
+								soundcloudIds.forEach(soundcloudId => {
+									const mediaSource = `soundcloud:${soundcloudId}`;
+									const mediaSourceOrigins = [
+										`Spotify track ${spotifyTrackId}`,
+										`ISRC ${ISRC}`,
+										`MusicBrainz recordings`,
+										`MusicBrainz recording ${recording.id}`,
+										`MusicBrainz relations`,
+										`MusicBrainz relation target-type work`,
+										`MusicBrainz relation work id ${relation.work.id}`,
+										`WikiData select from MusicBrainz work id ${relation.work.id}`,
+										`SoundCloud ID ${soundcloudId}`
+									];
+
+									mediaSources.add(mediaSource);
+									if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+									mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
+								});
+
+								console.log("KRISWORKWOW", youtubeIds, soundcloudIds);
+
+								resolve();
+							})
+							.catch(err => {
+								console.log("KRISWORKERR", err);
+								resolve();
+							});
+					});
+
+					jobsToRun.push(promise);
+
+					//WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_WORK", { workId: relation.work.id }, this));
+
 					return;
 				}
 			});

+ 228 - 0
backend/logic/wikidata.js

@@ -0,0 +1,228 @@
+import config from "config";
+
+import axios from "axios";
+
+import CoreClass from "../core";
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed WikiData request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a WikiData 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();
+	}
+}
+
+let WikiDataModule;
+let CacheModule;
+let DBModule;
+let MediaModule;
+let SongsModule;
+let StationsModule;
+let PlaylistsModule;
+let WSModule;
+
+class _WikiDataModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("wikidata", {
+			concurrency: 10
+			// priorities: {
+			// 	GET_PLAYLIST: 11
+			// }
+		});
+
+		WikiDataModule = this;
+	}
+
+	/**
+	 * Initialises the activities module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		MediaModule = this.moduleManager.modules.media;
+		SongsModule = this.moduleManager.modules.songs;
+		StationsModule = this.moduleManager.modules.stations;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+		WSModule = this.moduleManager.modules.ws;
+
+		this.genericApiRequestModel = this.GenericApiRequestModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "genericApiRequest"
+		});
+
+		// this.youtubeVideoModel = this.YoutubeVideoModel = await DBModule.runJob("GET_MODEL", {
+		// 	modelName: "youtubeVideo"
+		// });
+
+		// return new Promise(resolve => {
+		// CacheModule.runJob("SUB", {
+		// 	channel: "youtube.removeYoutubeApiRequest",
+		// 	cb: requestId => {
+		// 		WSModule.runJob("EMIT_TO_ROOM", {
+		// 			room: `view-api-request.${requestId}`,
+		// 			args: ["event:youtubeApiRequest.removed"]
+		// 		});
+
+		// 		WSModule.runJob("EMIT_TO_ROOM", {
+		// 			room: "admin.youtube",
+		// 			args: ["event:admin.youtubeApiRequest.removed", { data: { requestId } }]
+		// 		});
+		// 	}
+		// });
+
+		// CacheModule.runJob("SUB", {
+		// 	channel: "youtube.removeVideos",
+		// 	cb: videoIds => {
+		// 		const videos = Array.isArray(videoIds) ? videoIds : [videoIds];
+		// 		videos.forEach(videoId => {
+		// 			WSModule.runJob("EMIT_TO_ROOM", {
+		// 				room: `view-youtube-video.${videoId}`,
+		// 				args: ["event:youtubeVideo.removed"]
+		// 			});
+
+		// 			WSModule.runJob("EMIT_TO_ROOM", {
+		// 				room: "admin.youtubeVideos",
+		// 				args: ["event:admin.youtubeVideo.removed", { data: { videoId } }]
+		// 			});
+
+		// 			WSModule.runJob("EMIT_TO_ROOMS", {
+		// 				rooms: ["import-album", "edit-songs"],
+		// 				args: ["event:admin.youtubeVideo.removed", { videoId }]
+		// 			});
+		// 		});
+		// 	}
+		// });
+
+		this.rateLimiter = new RateLimitter(1100);
+		// this.requestTimeout = config.get("apis.youtube.requestTimeout");
+		this.requestTimeout = 5000;
+
+		this.axios = axios.create();
+		// this.axios.defaults.raxConfig = {
+		// 	instance: this.axios,
+		// 	retry: config.get("apis.youtube.retryAmount"),
+		// 	noResponseRetries: config.get("apis.youtube.retryAmount")
+		// };
+		// rax.attach(this.axios);
+
+		// this.youtubeApiRequestModel
+		// 	.find(
+		// 		{ date: { $gte: new Date() - 2 * 24 * 60 * 60 * 1000 } },
+		// 		{ date: true, quotaCost: true, _id: false }
+		// 	)
+		// 	.sort({ date: 1 })
+		// 	.exec((err, youtubeApiRequests) => {
+		// 		if (err) console.log("Couldn't load YouTube API requests.");
+		// 		else {
+		// 			this.apiCalls = youtubeApiRequests;
+		// 			resolve();
+		// 		}
+		// 	});
+
+		// 	resolve();
+		// });
+	}
+
+	/**
+	 * Get WikiData data from work id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.workId - work id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_DATA_FROM_MUSICBRAINZ_WORK(payload) {
+		const { workId } = payload;
+
+		const sparqlQuery = `SELECT DISTINCT ?item ?itemLabel ?YouTube_video_ID WHERE {
+								SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }
+								{
+									SELECT DISTINCT ?item WHERE {
+									?item p:P435 ?statement0.
+									?statement0 ps:P435 "${workId}".
+									}
+									LIMIT 100
+								}
+								OPTIONAL { ?item wdt:P1651 ?YouTube_video_ID. }
+							}`;
+
+		return WikiDataModule.runJob(
+			"API_CALL",
+			{
+				url: "https://query.wikidata.org/sparql",
+				params: {
+					query: sparqlQuery
+				}
+			},
+			this
+		);
+	}
+
+	/**
+	 * Perform WikiData 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)
+	 */
+	async API_CALL(payload) {
+		const { url, params } = payload;
+
+		let genericApiRequest = await WikiDataModule.GenericApiRequestModel.findOne({
+			url,
+			params
+		});
+		if (genericApiRequest) return genericApiRequest._doc.responseData;
+
+		await WikiDataModule.rateLimiter.continue();
+		WikiDataModule.rateLimiter.restart();
+
+		const { data: responseData } = await WikiDataModule.axios.get(url, {
+			params,
+			headers: {
+				Accept: "application/sparql-results+json"
+			},
+			timeout: WikiDataModule.requestTimeout
+		});
+
+		if (responseData.error) throw new Error(responseData.error);
+
+		genericApiRequest = new WikiDataModule.GenericApiRequestModel({
+			url,
+			params,
+			responseData,
+			date: Date.now()
+		});
+		genericApiRequest.save();
+
+		return responseData;
+	}
+}
+
+export default new _WikiDataModule();

+ 157 - 70
frontend/src/components/modals/ConvertSpotifySongs.vue

@@ -37,6 +37,9 @@ const alternativeSongsMap = ref(new Map());
 
 const showExtra = ref(false);
 
+const preferTopic = ref(true);
+const singleMode = ref(true);
+
 // const ISRCMap = ref(new Map());
 // const WikidataSpotifyTrackMap = ref(new Map());
 // const WikidataMusicBrainzWorkMap = ref(new Map());
@@ -95,33 +98,66 @@ const toggleSpotifyArtistExpanded = spotifyArtistId => {
 // 	});
 // };
 
-const getAlternativeMediaSourcesForTrack = mediaSource => {
-	socket.dispatch(
-		"apis.getAlternativeMediaSourcesForTrack",
-		mediaSource,
-		res => {
+const gettingAlternativeMediaSources = ref(false);
+
+const getAlternativeMediaSourcesForTracks = () => {
+	if (gettingAlternativeMediaSources.value) return;
+	gettingAlternativeMediaSources.value = true;
+
+	const mediaSources = spotifyTracksMediaSourcesArray.value;
+
+	socket.dispatch("apis.getAlternativeMediaSourcesForTracks", mediaSources, {
+		cb: res => {
 			console.log("KRIS111133", res);
-			if (res.status === "success") {
-				AlternativeSourcesForTrackMap.value.set(
-					mediaSource,
-					res.data.alternativeMediaSources
-				);
-				console.log(32211, AlternativeSourcesForTrackMap.value);
-				getMissingAlternativeSongs();
-				// ISRCMap.value.set(ISRC, res.data);
-				// WikidataMusicBrainzWorkMap.value.set(workId, res.data.response);
+			// console.log("Change state to loading");
+			// if (res.status === "success") {
+			// 	AlternativeSourcesForTrackMap.value.set(
+			// 		mediaSource,
+			// 		res.data.alternativeMediaSources
+			// 	);
+			// 	console.log(32211, AlternativeSourcesForTrackMap.value);
+			// 	getMissingAlternativeSongs();
+			// 	// ISRCMap.value.set(ISRC, res.data);
+			// 	// WikidataMusicBrainzWorkMap.value.set(workId, res.data.response);
+			// }
+		},
+		onProgress: data => {
+			console.log("KRIS595959", data);
+			if (data.status === "working") {
+				if (data.data.status === "success") {
+					const { mediaSource, result } = data.data;
+					AlternativeSourcesForTrackMap.value.set(
+						mediaSource,
+						result
+					);
+					console.log(32211, AlternativeSourcesForTrackMap.value);
+					getMissingAlternativeSongs();
+					// ISRCMap.value.set(ISRC, res.data);
+					// WikidataMusicBrainzWorkMap.value.set(workId, res.data.response);
+				}
 			}
 		}
-	);
+	});
 };
 
 const loadingMediaSourcesMap = ref(new Map());
 const failedToLoadMediaSourcesMap = ref(new Map());
 
+const gettingMissingAlternativeSongs = ref(false);
+const getMissingAlternativeSongsAfterAgain = ref(false);
+
 const getMissingAlternativeSongs = () => {
+	if (gettingMissingAlternativeSongs.value) {
+		getMissingAlternativeSongsAfterAgain.value = true;
+		return;
+	}
+	getMissingAlternativeSongsAfterAgain.value = false;
+	gettingMissingAlternativeSongs.value = true;
+
 	const allAlternativeMediaSources = Array.from(
 		new Set(
 			Array.from(AlternativeSourcesForTrackMap.value.values())
+				.filter(t => !!t)
 				.map(t => t.mediaSources)
 				.flat()
 		)
@@ -138,11 +174,6 @@ const getMissingAlternativeSongs = () => {
 			return true;
 		}
 	);
-	console.log(
-		321111145668778,
-		allAlternativeMediaSources,
-		filteredMediaSources
-	);
 	filteredMediaSources.forEach(mediaSource => {
 		loadingMediaSourcesMap.value.set(mediaSource, true);
 	});
@@ -151,7 +182,6 @@ const getMissingAlternativeSongs = () => {
 		"media.getMediaFromMediaSources",
 		filteredMediaSources,
 		res => {
-			console.log("KRIS111136663", res);
 			if (res.status === "success") {
 				const { songMap } = res.data;
 				filteredMediaSources.forEach(mediaSource => {
@@ -168,6 +198,13 @@ const getMissingAlternativeSongs = () => {
 					}
 					loadingMediaSourcesMap.value.delete(mediaSource);
 				});
+
+				if (getMissingAlternativeSongsAfterAgain.value) {
+					setTimeout(() => {
+						gettingMissingAlternativeSongs.value = false;
+						getMissingAlternativeSongs();
+					}, 500);
+				}
 				// console.log(657567, );
 				// AlternativeSourcesForTrackMap.value.set(
 				// 	mediaSource,
@@ -181,6 +218,18 @@ const getMissingAlternativeSongs = () => {
 	);
 };
 
+const replaceSong = (oldMediaSource, newMediaSource) => {
+	socket.dispatch(
+		"playlists.replaceSongInPlaylist",
+		oldMediaSource,
+		newMediaSource,
+		props.playlistId,
+		res => {
+			console.log("KRISWOWOWOW", res);
+		}
+	);
+};
+
 onMounted(() => {
 	console.debug(TAG, "On mounted start");
 
@@ -264,6 +313,36 @@ onMounted(() => {
 				>
 					Toggle show extra
 				</button>
+				<br />
+				<button
+					class="button is-primary"
+					@click="preferTopic = !preferTopic"
+				>
+					Prefer mode:
+					{{ preferTopic ? "first topic" : "first song" }}
+				</button>
+				<br />
+				<button
+					class="button is-primary"
+					@click="getAlternativeMediaSourcesForTracks()"
+				>
+					Get alternatives
+				</button>
+				<br />
+				<button
+					class="button is-primary"
+					@click="singleMode = !singleMode"
+				>
+					Single convert mode: {{ singleMode }}
+				</button>
+				<br />
+				<button
+					class="button is-primary"
+					@click="convertAllTracks()"
+					v-if="!singleMode"
+				>
+					Use prefer mode to convert all available tracks
+				</button>
 				<!-- <p>Sorting by {{ sortBy }}</p> -->
 
 				<br />
@@ -324,21 +403,15 @@ onMounted(() => {
 							</p>
 						</div>
 						<div class="right">
-							<button
+							<p
 								v-if="
 									!AlternativeSourcesForTrackMap.has(
 										spotifyTrackMediaSource
 									)
 								"
-								class="button"
-								@click="
-									getAlternativeMediaSourcesForTrack(
-										spotifyTrackMediaSource
-									)
-								"
 							>
-								Get alternative media sources
-							</button>
+								Track not converted yet
+							</p>
 							<template v-else>
 								<div
 									v-for="[
@@ -381,46 +454,60 @@ onMounted(() => {
 										Song {{ alternativeMediaSource }} not
 										loaded/found
 									</p>
-									<song-item
-										v-else
-										:song="
-											alternativeSongsMap.get(
-												alternativeMediaSource
-											)
-										"
-									>
-										<template #leftIcon>
-											<a
-												v-if="
-													alternativeMediaSource.split(
-														':'
-													)[0] === 'youtube'
-												"
-												:href="`https://youtu.be/${
-													alternativeMediaSource.split(
-														':'
-													)[1]
-												}`"
-												target="_blank"
-											>
-												<div
-													class="youtube-icon left-icon"
-												></div>
-											</a>
-											<a
-												v-if="
-													alternativeMediaSource.split(
-														':'
-													)[0] === 'soundcloud'
-												"
-												target="_blank"
-											>
-												<div
-													class="soundcloud-icon left-icon"
-												></div>
-											</a>
-										</template>
-									</song-item>
+									<template v-else>
+										<song-item
+											:song="
+												alternativeSongsMap.get(
+													alternativeMediaSource
+												)
+											"
+										>
+											<template #leftIcon>
+												<a
+													v-if="
+														alternativeMediaSource.split(
+															':'
+														)[0] === 'youtube'
+													"
+													:href="`https://youtu.be/${
+														alternativeMediaSource.split(
+															':'
+														)[1]
+													}`"
+													target="_blank"
+												>
+													<div
+														class="youtube-icon left-icon"
+													></div>
+												</a>
+												<a
+													v-if="
+														alternativeMediaSource.split(
+															':'
+														)[0] === 'soundcloud'
+													"
+													target="_blank"
+												>
+													<div
+														class="soundcloud-icon left-icon"
+													></div>
+												</a>
+											</template>
+										</song-item>
+										<button
+											class="button is-primary"
+											v-if="singleMode"
+											@click="
+												replaceSong(
+													spotifyTrackMediaSource,
+													alternativeMediaSource
+												)
+											"
+										>
+											Convert to this song
+										</button>
+									</template>
+
 									<ul v-if="showExtra">
 										<li
 											v-for="origin in alternativeMediaSourceOrigins"