Procházet zdrojové kódy

feat: worked on logic to convert Spotify tracks to alternative sources

Kristian Vos před 2 roky
rodič
revize
4d0bf9e62d

+ 1 - 0
backend/index.js

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

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

@@ -11,6 +11,8 @@ import moduleManager from "../../index";
 const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 const YouTubeModule = moduleManager.modules.youtube;
+const SpotifyModule = moduleManager.modules.spotify;
+const CacheModule = moduleManager.modules.cache;
 
 export default {
 	/**
@@ -117,6 +119,413 @@ export default {
 		);
 	}),
 
+	// /**
+	//  *
+	//  *
+	//  * @param session
+	//  * @param ISRC - the ISRC
+	//  * @param {Function} cb
+	//  */
+	// searchMusicBrainzISRC: useHasPermission("admin.view.spotify", function searchMusicBrainzISRC(session, ISRC, cb) {
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				if (!ISRC) {
+	// 					next("Invalid ISRC provided.");
+	// 					return;
+	// 				}
+
+	// 				CacheModule.runJob("HGET", { table: "musicbrainz-isrc-2", key: ISRC })
+	// 					.then(response => {
+	// 						if (response) next(null, response);
+	// 						else next(null, null);
+	// 					})
+	// 					.catch(err => {
+	// 						next(err);
+	// 					});
+	// 			},
+
+	// 			(body, next) => {
+	// 				if (body) {
+	// 					next(null, body);
+	// 					return;
+	// 				}
+
+	// 				const options = {
+	// 					params: { fmt: "json", inc: "url-rels+work-rels" },
+	// 					headers: {
+	// 						"User-Agent": "Musare/3.9.0-fork ( https://git.kvos.dev/kris/MusareFork )" // TODO set this in accordance to https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
+	// 					}
+	// 				};
+
+	// 				console.log("KRIS101", options, `https://musicbrainz.org/ws/2/isrc/${ISRC}`);
+
+	// 				axios
+	// 					.get(`https://musicbrainz.org/ws/2/isrc/${ISRC}`, options)
+	// 					.then(res => next(null, res.data))
+	// 					.catch(err => next(err));
+	// 			},
+
+	// 			(body, next) => {
+	// 				console.log("KRIS222", body);
+
+	// 				CacheModule.runJob("HSET", { table: "musicbrainz-isrc-2", key: ISRC, value: body })
+	// 					.then(() => {})
+	// 					.catch(() => {});
+
+	// 				next(null, body);
+	// 			},
+
+	// 			(body, next) => {
+	// 				const response = {};
+
+	// 				const recordingUrls = Array.from(
+	// 					new Set(
+	// 						body.recordings
+	// 							.map(recording =>
+	// 								recording.relations
+	// 									.filter(
+	// 										relation =>
+	// 											relation["target-type"] === "url" &&
+	// 											relation.url &&
+	// 											// relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c" &&
+	// 											(relation.url.resource.indexOf("youtu.be") !== -1 ||
+	// 												relation.url.resource.indexOf("youtube.com") !== -1 ||
+	// 												relation.url.resource.indexOf("soundcloud.com") !== -1)
+	// 									)
+	// 									.map(relation => relation.url.resource)
+	// 							)
+	// 							.flat()
+	// 					)
+	// 				);
+
+	// 				const workIds = Array.from(
+	// 					new Set(
+	// 						body.recordings
+	// 							.map(recording =>
+	// 								recording.relations
+	// 									.filter(relation => relation["target-type"] === "work" && relation.work)
+	// 									.map(relation => relation.work.id)
+	// 							)
+	// 							.flat()
+	// 					)
+	// 				);
+
+	// 				response.recordingUrls = recordingUrls;
+	// 				response.workIds = workIds;
+
+	// 				response.raw = body;
+
+	// 				next(null, response);
+	// 			}
+	// 		],
+	// 		async (err, response) => {
+	// 			if (err && err !== true) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"APIS_SEARCH_MUSICBRAINZ_ISRC",
+	// 					`Searching MusicBrainz ISRC failed with ISRC "${ISRC}". "${err}"`
+	// 				);
+	// 				return cb({ status: "error", message: err });
+	// 			}
+	// 			this.log(
+	// 				"SUCCESS",
+	// 				"APIS_SEARCH_MUSICBRAINZ_ISRC",
+	// 				`User "${session.userId}" searched MusicBrainz ISRC succesfully for ISRC "${ISRC}".`
+	// 			);
+	// 			return cb({
+	// 				status: "success",
+	// 				data: {
+	// 					response
+	// 				}
+	// 			});
+	// 		}
+	// 	);
+	// }),
+
+	// /**
+	//  *
+	//  *
+	//  * @param session
+	//  * @param trackId - the trackId
+	//  * @param {Function} cb
+	//  */
+	// searchWikidataBySpotifyTrackId: useHasPermission(
+	// 	"admin.view.spotify",
+	// 	function searchWikidataBySpotifyTrackId(session, trackId, cb) {
+	// 		async.waterfall(
+	// 			[
+	// 				next => {
+	// 					if (!trackId) {
+	// 						next("Invalid trackId provided.");
+	// 						return;
+	// 					}
+
+	// 					CacheModule.runJob("HGET", { table: "wikidata-spotify-track", key: trackId })
+	// 						.then(response => {
+	// 							if (response) next(null, response);
+	// 							else next(null, null);
+	// 						})
+	// 						.catch(err => {
+	// 							console.log("WOW", err);
+	// 							next(err);
+	// 						});
+	// 				},
+
+	// 				(body, next) => {
+	// 					if (body) {
+	// 						next(null, body);
+	// 						return;
+	// 					}
+
+	// 					// const options = {
+	// 					// 	params: { fmt: "json", inc: "url-rels" },
+	// 					// 	headers: {
+	// 					// 		"User-Agent": "Musare/3.9.0-fork ( https://git.kvos.dev/kris/MusareFork )" // TODO set this in accordance to https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
+	// 					// 	}
+	// 					// };
+
+	// 					// axios
+	// 					// 	.get(`https://musicbrainz.org/ws/2/isrc/${ISRC}`, options)
+	// 					// 	.then(res => next(null, res.data))
+	// 					// 	.catch(err => next(err));
+	// 				},
+
+	// 				(body, next) => {
+	// 					CacheModule.runJob("HSET", { table: "musicbrainz-isrc", key: ISRC, value: body })
+	// 						.then(() => {})
+	// 						.catch(() => {});
+
+	// 					next(null, body);
+	// 				},
+
+	// 				(body, next) => {
+	// 					const response = {};
+
+	// 					const recordingUrls = Array.from(
+	// 						new Set(
+	// 							body.recordings
+	// 								.map(recording =>
+	// 									recording.relations
+	// 										.filter(
+	// 											relation =>
+	// 												relation["target-type"] === "url" &&
+	// 												relation.url &&
+	// 												// relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c" &&
+	// 												(relation.url.resource.indexOf("youtu.be") !== -1 ||
+	// 													relation.url.resource.indexOf("youtube.com") !== -1 ||
+	// 													relation.url.resource.indexOf("soundcloud.com") !== -1)
+	// 										)
+	// 										.map(relation => relation.url.resource)
+	// 								)
+	// 								.flat()
+	// 						)
+	// 					);
+
+	// 					response.recordingUrls = recordingUrls;
+
+	// 					response.raw = body;
+
+	// 					next(null, response);
+	// 				}
+	// 			],
+	// 			async (err, response) => {
+	// 				if (err && err !== true) {
+	// 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 					this.log(
+	// 						"ERROR",
+	// 						"APIS_SEARCH_TODO",
+	// 						`Searching MusicBrainz ISRC failed with ISRC "${ISRC}". "${err}"`
+	// 					);
+	// 					return cb({ status: "error", message: err });
+	// 				}
+	// 				this.log(
+	// 					"SUCCESS",
+	// 					"APIS_SEARCH_TODO",
+	// 					`User "${session.userId}" searched MusicBrainz ISRC succesfully for ISRC "${ISRC}".`
+	// 				);
+	// 				return cb({
+	// 					status: "success",
+	// 					data: {
+	// 						response
+	// 					}
+	// 				});
+	// 			}
+	// 		);
+	// 	}
+	// ),
+
+	// /**
+	//  *
+	//  *
+	//  * @param session
+	//  * @param trackId - the trackId
+	//  * @param {Function} cb
+	//  */
+	// searchWikidataByMusicBrainzWorkId: useHasPermission(
+	// 	"admin.view.spotify",
+	// 	function searchWikidataByMusicBrainzWorkId(session, workId, cb) {
+	// 		async.waterfall(
+	// 			[
+	// 				next => {
+	// 					if (!workId) {
+	// 						next("Invalid workId provided.");
+	// 						return;
+	// 					}
+
+	// 					CacheModule.runJob("HGET", { table: "wikidata-musicbrainz-work", key: workId })
+	// 						.then(response => {
+	// 							if (response) next(null, response);
+	// 							else next(null, null);
+	// 						})
+	// 						.catch(err => {
+	// 							next(err);
+	// 						});
+	// 				},
+
+	// 				(body, next) => {
+	// 					if (body) {
+	// 						next(null, body);
+	// 						return;
+	// 					}
+
+	// 					const endpointUrl = "https://query.wikidata.org/sparql";
+	// 					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. }
+	// 					}`;
+	// 					// OPTIONAL { ?item wdt:P3040 ?SoundCloud_track_ID. }
+
+	// 					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));
+	// 				},
+
+	// 				(body, next) => {
+	// 					CacheModule.runJob("HSET", { table: "wikidata-musicbrainz-work", key: workId, value: body })
+	// 						.then(() => {})
+	// 						.catch(() => {});
+
+	// 					next(null, body);
+	// 				},
+
+	// 				(body, next) => {
+	// 					const response = {};
+
+	// 					const youtubeIds = Array.from(
+	// 						new Set(
+	// 							body.results.bindings
+	// 								.filter(binding => !!binding.YouTube_video_ID)
+	// 								.map(binding => binding.YouTube_video_ID.value)
+	// 						)
+	// 					);
+	// 					// const soundcloudIds = Array.from(new Set(body.results.bindings.filter(binding => !!binding["SoundCloud_track_ID"]).map(binding => binding["SoundCloud_track_ID"].value)))
+
+	// 					response.youtubeIds = youtubeIds;
+
+	// 					response.raw = body;
+
+	// 					next(null, response);
+	// 				}
+	// 			],
+	// 			async (err, response) => {
+	// 				if (err && err !== true) {
+	// 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 					this.log(
+	// 						"ERROR",
+	// 						"APIS_SEARCH_TODO",
+	// 						`Searching MusicBrainz ISRC failed with ISRC "${workId}". "${err}"`
+	// 					);
+	// 					return cb({ status: "error", message: err });
+	// 				}
+	// 				this.log(
+	// 					"SUCCESS",
+	// 					"APIS_SEARCH_TODO",
+	// 					`User "${session.userId}" searched MusicBrainz ISRC succesfully for ISRC "${workId}".`
+	// 				);
+	// 				return cb({
+	// 					status: "success",
+	// 					data: {
+	// 						response
+	// 					}
+	// 				});
+	// 			}
+	// 		);
+	// 	}
+	// ),
+
+	/**
+	 *
+	 *
+	 * @param session
+	 * @param trackId - the trackId
+	 * @param {Function} cb
+	 */
+	getAlternativeMediaSourcesForTrack: useHasPermission(
+		"admin.view.spotify",
+		function getAlternativeMediaSourcesForTrack(session, mediaSource, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!mediaSource) {
+							next("Invalid mediaSource provided.");
+							return;
+						}
+
+						next();
+					},
+
+					async () => {
+						const alternativeMediaSources = await SpotifyModule.runJob(
+							"GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",
+							{ mediaSource }
+						);
+
+						return alternativeMediaSources;
+					}
+				],
+				async (err, alternativeMediaSources) => {
+					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}"`
+						);
+						return cb({ status: "error", message: err });
+					}
+					this.log(
+						"SUCCESS",
+						"APIS_SEARCH_TODO",
+						`User "${session.userId}" searched MusicBrainz ISRC succesfully for ISRC "${mediaSource}".`
+					);
+					return cb({
+						status: "success",
+						data: {
+							alternativeMediaSources
+						}
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Joins a room
 	 *

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

@@ -21,7 +21,8 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	importJob: 1,
 	stationHistory: 2,
 	soundcloudTrack: 1,
-	spotifyTrack: 1
+	spotifyTrack: 1,
+	genericApiRequest: 1
 };
 
 const regex = {
@@ -81,7 +82,8 @@ class _DBModule extends CoreClass {
 						ratings: {},
 						stationHistory: {},
 						soundcloudTrack: {},
-						spotifyTrack: {}
+						spotifyTrack: {},
+						genericApiRequest: {}
 					};
 
 					const importSchema = schemaName =>
@@ -109,6 +111,7 @@ class _DBModule extends CoreClass {
 					await importSchema("stationHistory");
 					await importSchema("soundcloudTrack");
 					await importSchema("spotifyTrack");
+					await importSchema("genericApiRequest");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -127,7 +130,8 @@ class _DBModule extends CoreClass {
 						importJob: mongoose.model("importJob", this.schemas.importJob),
 						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),
 						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack),
-						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack)
+						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack),
+						genericApiRequest: mongoose.model("genericApiRequest", this.schemas.genericApiRequest)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -283,6 +287,7 @@ class _DBModule extends CoreClass {
 					this.models.stationHistory.syncIndexes();
 					this.models.soundcloudTrack.syncIndexes();
 					this.models.spotifyTrack.syncIndexes();
+					this.models.genericApiRequest.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 6 - 0
backend/logic/db/schemas/genericApiRequest.js

@@ -0,0 +1,6 @@
+export default {
+	url: { type: String, required: true },
+	params: { type: Object },
+	responseData: { type: Object, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 194 - 0
backend/logic/musicbrainz.js

@@ -0,0 +1,194 @@
+import config from "config";
+
+import axios from "axios";
+
+import CoreClass from "../core";
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed MusicBrainz request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a MusicBrainz 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 MusicBrainzModule;
+let CacheModule;
+let DBModule;
+let MediaModule;
+let SongsModule;
+let StationsModule;
+let PlaylistsModule;
+let WSModule;
+
+class _MusicBrainzModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("musicbrainz", {
+			concurrency: 10
+			// priorities: {
+			// 	GET_PLAYLIST: 11
+			// }
+		});
+
+		MusicBrainzModule = 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();
+		// });
+	}
+
+	/**
+	 * Perform MusicBrainz 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 MusicBrainzModule.GenericApiRequestModel.findOne({
+			url,
+			params
+		});
+		if (genericApiRequest) return genericApiRequest._doc.responseData;
+
+		await MusicBrainzModule.rateLimiter.continue();
+		MusicBrainzModule.rateLimiter.restart();
+
+		const { data: responseData } = await MusicBrainzModule.axios.get(url, {
+			params,
+			headers: {
+				"User-Agent": "Musare/3.9.0-fork ( https://git.kvos.dev/kris/MusareFork )" // TODO set this in accordance to https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
+			},
+			timeout: MusicBrainzModule.requestTimeout
+		});
+
+		if (responseData.error) throw new Error(responseData.error);
+
+		genericApiRequest = new MusicBrainzModule.GenericApiRequestModel({
+			url,
+			params,
+			responseData,
+			date: Date.now()
+		});
+		genericApiRequest.save();
+
+		return responseData;
+	}
+}
+
+export default new _MusicBrainzModule();

+ 101 - 24
backend/logic/spotify.js

@@ -7,12 +7,16 @@ import axios from "axios";
 import url from "url";
 
 import CoreClass from "../core";
-import { resolve } from "path";
 
 let SpotifyModule;
 let DBModule;
 let CacheModule;
 let MediaModule;
+let MusicBrainzModule;
+
+const youtubeVideoUrlRegex =
+	/^(https?:\/\/)?(www\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
+const youtubeVideoIdRegex = /^([\w-]{11})$/;
 
 const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => {
 	return {
@@ -78,6 +82,7 @@ class _SpotifyModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		CacheModule = this.moduleManager.modules.cache;
 		MediaModule = this.moduleManager.modules.media;
+		MusicBrainzModule = this.moduleManager.modules.musicbrainz;
 
 		// this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
 		// 	modelName: "youtubeApiRequest"
@@ -526,29 +531,101 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
-	// /**
-	//  * @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);
-	// 			});
-	// 	});
-	// }
+	/**
+	 *
+	 * @param {*} payload
+	 * @returns
+	 */
+	async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK(payload) {
+		const { mediaSource } = payload;
+
+		if (!mediaSource || !mediaSource.startsWith("spotify:"))
+			throw new Error("Media source provided is not a valid Spotify media source.");
+
+		const spotifyTrackId = mediaSource.split(":")[1];
+
+		const { track: spotifyTrack } = await SpotifyModule.runJob(
+			"GET_TRACK",
+			{
+				identifier: spotifyTrackId,
+				createMissing: true
+			},
+			this
+		);
+
+		const ISRC = spotifyTrack.externalIds.isrc;
+		if (!ISRC) throw new Error(`ISRC not found for Spotify track ${mediaSource}.`);
+
+		const ISRCApiResponse = await MusicBrainzModule.runJob(
+			"API_CALL",
+			{
+				url: `https://musicbrainz.org/ws/2/isrc/${ISRC}`,
+				params: {
+					fmt: "json",
+					inc: "url-rels+work-rels"
+				}
+			},
+			this
+		);
+
+		console.dir(ISRCApiResponse);
+
+		const mediaSources = new Set();
+		const mediaSourcesOrigins = {};
+
+		ISRCApiResponse.recordings.forEach(recording => {
+			recording.relations.forEach(relation => {
+				if (relation["target-type"] === "url" && relation.url) {
+					// relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c"
+					const { resource } = relation.url;
+
+					if (resource.indexOf("soundcloud.com") !== -1) {
+						throw new Error(`Unable to parse SoundCloud resource ${resource}.`);
+
+						return;
+					}
+
+					if (resource.indexOf("youtube.com") !== -1 || resource.indexOf("youtu.be") !== -1) {
+						const match = youtubeVideoUrlRegex.exec(resource);
+						if (!match) throw new Error(`Unable to parse YouTube resource ${resource}.`);
+
+						const { youtubeId } = match.groups;
+						if (!youtubeId) throw new Error(`Unable to parse YouTube resource ${resource}.`);
+
+						const mediaSource = `youtube:${youtubeId}`;
+						const mediaSourceOrigins = [
+							`Spotify track ${spotifyTrackId}`,
+							`ISRC ${ISRC}`,
+							`MusicBrainz recordings`,
+							`MusicBrainz recording ${recording.id}`,
+							`MusicBrainz relations`,
+							`MusicBrainz relation target-type url`,
+							`MusicBrainz relation resource ${resource}`,
+							`YouTube ID ${youtubeId}`
+						];
+
+						mediaSources.add(mediaSource);
+						if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
+
+						mediaSourcesOrigins[mediaSource].push([mediaSourceOrigins]);
+
+						return;
+					}
+
+					return;
+				}
+
+				if (relation["target-type"] === "work") {
+					return;
+				}
+			});
+		});
+
+		return {
+			mediaSources: Array.from(mediaSources),
+			mediaSourcesOrigins
+		};
+	}
 }
 
 export default new _SpotifyModule();

+ 257 - 45
frontend/src/components/modals/ConvertSpotifySongs.vue

@@ -29,11 +29,16 @@ const props = defineProps({
 const playlist = ref(null);
 const allSongs = ref(null);
 const loaded = ref(false);
-const currentConvertType = ref("artist");
+const currentConvertType = ref("track");
 const sortBy = ref("track_count_des");
 
 const spotifyArtists = ref({});
 
+// const ISRCMap = ref(new Map());
+// const WikidataSpotifyTrackMap = ref(new Map());
+// const WikidataMusicBrainzWorkMap = ref(new Map());
+const AlternativeSourcesForTrackMap = ref(new Map());
+
 const spotifyArtistsArray = computed(() =>
 	Object.entries(spotifyArtists.value)
 		.map(([spotifyArtistId, spotifyArtist]) => ({
@@ -48,11 +53,60 @@ const spotifyArtistsArray = computed(() =>
 		})
 );
 
+const spotifyTracksMediaSourcesArray = computed(() =>
+	Object.keys(allSongs.value)
+);
+
 const toggleSpotifyArtistExpanded = spotifyArtistId => {
 	spotifyArtists.value[spotifyArtistId].expanded =
 		!spotifyArtists.value[spotifyArtistId].expanded;
 };
 
+// const getFromISRC = ISRC => {
+// 	socket.dispatch("apis.searchMusicBrainzISRC", ISRC, res => {
+// 		console.log("KRIS111", res);
+// 		if (res.status === "success") {
+// 			// ISRCMap.value.set(ISRC, res.data);
+// 			ISRCMap.value.set(ISRC, res.data.response);
+// 		}
+// 	});
+// };
+
+// const getFromWikidataSpotifyTrack = trackId => {
+// 	socket.dispatch("apis.searchWikidataBySpotifyTrackId", trackId, res => {
+// 		console.log("KRIS11111", res);
+// 		if (res.status === "success") {
+// 			// ISRCMap.value.set(ISRC, res.data);
+// 			WikidataSpotifyTrackMap.value.set(trackId, res.data.response);
+// 		}
+// 	});
+// };
+
+// const getFromWikidataByMusicBrainzWorkId = workId => {
+// 	socket.dispatch("apis.searchWikidataByMusicBrainzWorkId", workId, res => {
+// 		console.log("KRIS111112", res);
+// 		if (res.status === "success") {
+// 			// ISRCMap.value.set(ISRC, res.data);
+// 			WikidataMusicBrainzWorkMap.value.set(workId, res.data.response);
+// 		}
+// 	});
+// };
+
+const getAlternativeMediaSourcesForTrack = mediaSource => {
+	socket.dispatch(
+		"apis.getAlternativeMediaSourcesForTrack",
+		mediaSource,
+		res => {
+			console.log("KRIS111133", res);
+			if (res.status === "success") {
+				AlternativeSourcesForTrackMap.value.set(mediaSource, res.data);
+				// ISRCMap.value.set(ISRC, res.data);
+				// WikidataMusicBrainzWorkMap.value.set(workId, res.data.response);
+			}
+		}
+	);
+};
+
 onMounted(() => {
 	console.debug(TAG, "On mounted start");
 
@@ -130,20 +184,164 @@ onMounted(() => {
 		>
 			<template #body>
 				<p>Converting by {{ currentConvertType }}</p>
-				<p>Sorting by {{ sortBy }}</p>
+				<!-- <p>Sorting by {{ sortBy }}</p> -->
 
 				<br />
 
-				<div class="column-headers">
+				<!-- <div class="column-headers">
 					<div class="spotify-column-header column-header">
 						<h3>Spotify</h3>
 					</div>
 					<div class="soumdcloud-column-header column-header">
 						<h3>Soundcloud</h3>
 					</div>
+				</div> -->
+
+				<div class="tracks" v-if="currentConvertType === 'track'">
+					<div
+						class="track-row"
+						v-for="spotifyTrackMediaSource in spotifyTracksMediaSourcesArray"
+						:key="spotifyTrackMediaSource"
+					>
+						<div class="left">
+							<p>Media source: {{ spotifyTrackMediaSource }}</p>
+							<p>
+								Name:
+								{{
+									allSongs[spotifyTrackMediaSource].track.name
+								}}
+							</p>
+							<p>Artists:</p>
+							<ul>
+								<li
+									v-for="artist in allSongs[
+										spotifyTrackMediaSource
+									].track.artists"
+									:key="artist"
+								>
+									- {{ artist }}
+								</li>
+							</ul>
+							<p>
+								Duration:
+								{{
+									allSongs[spotifyTrackMediaSource].track
+										.duration
+								}}
+							</p>
+							<p>
+								ISRC:
+								{{
+									allSongs[spotifyTrackMediaSource].track
+										.externalIds.isrc
+								}}
+							</p>
+						</div>
+						<div class="right">
+							<button
+								class="button"
+								@click="
+									getAlternativeMediaSourcesForTrack(
+										spotifyTrackMediaSource
+									)
+								"
+							>
+								Get alternative media sources
+							</button>
+							<!-- <button
+								class="button"
+								v-if="
+									!ISRCMap.has(
+										allSongs[spotifyTrackMediaSource].track
+											.externalIds.isrc
+									)
+								"
+								@click="
+									getFromISRC(
+										allSongs[spotifyTrackMediaSource].track
+											.externalIds.isrc
+									)
+								"
+							>
+								Get MusicBrainz ISRC data
+							</button>
+							<div v-else>
+								<p>Recording URL's</p>
+								<ul>
+									<li
+										v-for="recordingUrl in ISRCMap.get(
+											allSongs[spotifyTrackMediaSource]
+												.track.externalIds.isrc
+										).recordingUrls"
+										:key="recordingUrl"
+									>
+										{{ recordingUrl }}
+									</li>
+								</ul>
+								<hr />
+								<p>Work ID's</p>
+								<ul>
+									<li
+										v-for="workId in ISRCMap.get(
+											allSongs[spotifyTrackMediaSource]
+												.track.externalIds.isrc
+										).workIds"
+										:key="workId"
+									>
+										<p>{{ workId }}</p>
+										<button
+											v-if="
+												!WikidataMusicBrainzWorkMap.has(
+													workId
+												)
+											"
+											@click="
+												getFromWikidataByMusicBrainzWorkId(
+													workId
+												)
+											"
+											class="button"
+										>
+											Get WikiData data
+										</button>
+										<div v-else>
+											<p>YouTube ID's</p>
+											<ul>
+												<li
+													v-for="youtubeId in WikidataMusicBrainzWorkMap.get(
+														workId
+													).youtubeIds"
+												>
+													{{ youtubeId }}
+												</li>
+											</ul>
+										</div>
+									</li>
+								</ul>
+							</div>
+							<hr />
+							<button
+								class="button"
+								v-if="
+									!WikidataSpotifyTrackMap.has(
+										allSongs[spotifyTrackMediaSource].track
+											.trackId
+									)
+								"
+								@click="
+									getFromWikidataSpotifyTrack(
+										allSongs[spotifyTrackMediaSource].track
+											.trackId
+									)
+								"
+							>
+								Get WikiData Spotify track data
+							</button> -->
+						</div>
+					</div>
 				</div>
 
-				<div class="artists">
+				<!-- <div class="artists">
 					<div
 						v-for="spotifyArtist in spotifyArtistsArray"
 						:key="spotifyArtist.artistId"
@@ -203,58 +401,72 @@ onMounted(() => {
 							</div>
 						</div>
 					</div>
-				</div>
+				</div> -->
 			</template>
 		</modal>
 	</div>
 </template>
 
 <style lang="less" scoped>
-.column-headers {
-	display: flex;
-	flex-direction: row;
-
-	.column-header {
-		flex: 1;
-	}
-}
-
-.artists {
+.tracks {
 	display: flex;
 	flex-direction: column;
 
-	.artist-item {
-		display: flex;
-		flex-direction: column;
-		row-gap: 8px;
-		box-shadow: inset 0px 0px 1px white;
-		width: 50%;
-
-		position: relative;
-
-		.spotify-section {
-			display: flex;
-			flex-direction: column;
-			row-gap: 8px;
-			padding: 8px 12px;
-
-			.spotify-songs {
-				display: flex;
-				flex-direction: column;
-				row-gap: 4px;
-			}
-		}
-
-		.soundcloud-section {
-			position: absolute;
-			left: 100%;
-			top: 0;
-			width: 100%;
-			height: 100%;
-			overflow: hidden;
+	.track-row {
+		.left,
+		.right {
+			padding: 8px;
+			width: 50%;
 			box-shadow: inset 0px 0px 1px white;
-			padding: 8px 12px;
 		}
 	}
 }
+
+// .column-headers {
+// 	display: flex;
+// 	flex-direction: row;
+
+// 	.column-header {
+// 		flex: 1;
+// 	}
+// }
+
+// .artists {
+// 	display: flex;
+// 	flex-direction: column;
+
+// 	.artist-item {
+// 		display: flex;
+// 		flex-direction: column;
+// 		row-gap: 8px;
+// 		box-shadow: inset 0px 0px 1px white;
+// 		width: 50%;
+
+// 		position: relative;
+
+// 		.spotify-section {
+// 			display: flex;
+// 			flex-direction: column;
+// 			row-gap: 8px;
+// 			padding: 8px 12px;
+
+// 			.spotify-songs {
+// 				display: flex;
+// 				flex-direction: column;
+// 				row-gap: 4px;
+// 			}
+// 		}
+
+// 		.soundcloud-section {
+// 			position: absolute;
+// 			left: 100%;
+// 			top: 0;
+// 			width: 100%;
+// 			height: 100%;
+// 			overflow: hidden;
+// 			box-shadow: inset 0px 0px 1px white;
+// 			padding: 8px 12px;
+// 		}
+// 	}
+// }
 </style>