6 Commits 913535c1d3 ... af56e19ba5

Auteur SHA1 Message Date
  Kristian Vos af56e19ba5 refactor: remove invalid query keys on AdvancedTable pages automatically il y a 1 mois
  Kristian Vos a239743c0c feat: add dispatchAsync wrapper for socket dispatch function, using promises instead of callbacks il y a 1 mois
  Kristian Vos 7d09ea6e91 refactor: improve youtube module channel URL parsing to support more formats il y a 1 mois
  Kristian Vos 3365a222d5 feat: add artist actions for searching for musicbrainz artists, saving linking data, removing artists, and small other changes il y a 1 mois
  Kristian Vos 205ea7e36c feat: add job for searching for musicbrainz artists il y a 1 mois
  Kristian Vos 576e070fc9 refactor: add linking data to artist db schema il y a 1 mois

+ 117 - 3
backend/logic/actions/artists.js

@@ -181,7 +181,10 @@ export default {
 
 				return cb({
 					status: "success",
-					message: "Successfully created artist"
+					message: "Successfully created artist",
+					data: {
+						artistId: artist._id
+					}
 				});
 			}
 		);
@@ -265,13 +268,59 @@ export default {
 		);
 	}),
 
+	/**
+	 * Deletes an artist - shouldn't be used outside of testing
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} artistId - the id of the artist item
+	 * @param {Function} cb - gets called with the result
+	 */
+	remove: useHasPermission("artists.update", async function remove(session, artistId, cb) {
+		const artistModel = await DBModule.runJob("GET_MODEL", { modelName: "artist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!artistId) return next("Please provide an artist item id to remove.");
+					return next();
+				},
+
+				next => {
+					artistModel.remove({ _id: artistId }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"ARTIST_REMOVE",
+						`Removing artist item "${artistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "artists.remove", value: artistId });
+
+				this.log(
+					"SUCCESS",
+					"ARTIST_REMOVE",
+					`Removing artist item "${artistId}" successful for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully removed artist item"
+				});
+			}
+		);
+	}),
+
 	getMusicbrainzArtist: useHasPermission(
 		"artists.update",
 		async function getMusicbrainzArtist(session, musicbrainzIdentifier, cb) {
 			const ArtistApiResponse = await MusicBrainzModule.runJob(
 				"API_CALL",
 				{
-					url: `https://musicbrainz.org/ws/2/artist/${musicbrainzIdentifier}/`,
+					url: `https://musicbrainz.org/ws/2/artist/${musicbrainzIdentifier}`,
 					params: {
 						fmt: "json",
 						inc: "aliases"
@@ -335,5 +384,70 @@ export default {
 				status: "error",
 				message: "Invalid type"
 			});
-	})
+	}),
+
+	saveLinkingData: useHasPermission("artists.update", async function saveLinkingData(session, artistId, data, cb) {
+		const artistModel = await DBModule.runJob("GET_MODEL", { modelName: "artist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!artistId) return next("Please provide an artist item id to update.");
+					return next();
+				},
+
+				next => {
+					artistModel.updateOne({ _id: artistId }, { $set: { linkingData: data } }, err => next(err));
+				},
+
+				next => {
+					artistModel.findOne({ _id: artistId }, next);
+				}
+			],
+			async (err, artist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"ARTIST_SAVE_LINKING_DATA",
+						`Saving linking data for artist "${artistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "artists.update", value: artist });
+
+				this.log(
+					"SUCCESS",
+					"ARTIST_SAVE_LINKING_DATA",
+					`Saving linking data for artist "${artistId}" was successful for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully saved linking data"
+				});
+			}
+		);
+	}),
+
+	searchMusicbrainzArtists: useHasPermission(
+		"artists.update",
+		async function searchMusicbrainzArtists(session, query, cb) {
+			MusicBrainzModule.runJob("SEARCH_MUSICBRAINZ_ARTISTS", {
+				query
+			})
+				.then(({ musicbrainzArtists }) => {
+					cb({
+						status: "success",
+						data: {
+							musicbrainzArtists
+						}
+					});
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					cb({ status: "error", message: err });
+				});
+		}
+	)
 };

+ 25 - 0
backend/logic/db/schemas/artist.js

@@ -21,6 +21,31 @@ export default {
 	musicbrainzIdentifier: { type: String, required: true },
 	musicbrainzData: { type: Object, required: true },
 	comment: { type: String },
+	linkingData: {
+		recordingSort: { type: String, enum: ["title", "length"] },
+		recordingFilters: {
+			hideNullLength: { type: Boolean },
+			hidePartOf: { type: Boolean },
+			hideTrackArtistOnly: { type: Boolean }
+		},
+		youtubeVideosSort: { type: String, enum: ["title", "length"] },
+		youtubeVideoTitleChanges: {
+			artistDash: { type: Boolean },
+			parantheses: { type: Boolean },
+			brackets: { type: Boolean },
+			commonPhrases: { type: Boolean }
+		},
+		youtubeVideoFilters: {
+			teaser: { type: Boolean },
+			under45: { type: Boolean },
+			live: { type: Boolean },
+			tour: { type: Boolean },
+			noMusicCategory: { type: Boolean }
+		},
+		linkedVideos: {},
+		manualHideRecordingMap: {},
+		recordingLockedIds: [{ type: String }]
+	},
 	createdBy: { type: String, required: true },
 	createdAt: { type: Number, default: Date.now, required: true },
 	documentVersion: { type: Number, default: 1, required: true }

+ 1 - 0
backend/logic/hooks/hasPermission.js

@@ -61,6 +61,7 @@ permissions.moderator = {
 	"songs.verify": true,
 	"artists.create": true,
 	"artists.update": true,
+	"artist.remove": true,
 	"albums.create": true,
 	"albums.update": true,
 	"stations.create.official": true,

+ 29 - 0
backend/logic/musicbrainz.js

@@ -205,6 +205,35 @@ class _MusicBrainzModule extends CoreClass {
 
 		return response;
 	}
+
+	/**
+	 * Searches for MusicBrainz artists
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the artist query
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SEARCH_MUSICBRAINZ_ARTISTS(payload) {
+		const { query } = payload;
+
+		// TODO support offset
+		const response = await MusicBrainzModule.runJob(
+			"API_CALL",
+			{
+				url: `https://musicbrainz.org/ws/2/artist`,
+				params: {
+					fmt: "json",
+					query,
+					limit: 100,
+					offset: 0
+				}
+			},
+			this
+		);
+
+		return {
+			musicbrainzArtists: response.artists
+		};
+	}
 }
 
 export default new _MusicBrainzModule();

+ 33 - 17
backend/logic/youtube.js

@@ -83,6 +83,12 @@ const isQuotaExceeded = apiCalls => {
 	return quotaExceeded;
 };
 
+const youtubeChannelRegex =
+	// eslint-disable-next-line max-len
+	/\.[\w]+\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?(?<channelUsername>[\w-]+))|(?:c\/?(?<channelCustomUrl>[\w-]+))|(?:\/?(?<channelUsernameOrCustomUrl>@?[\w-]+)))/;
+
+// TODO maybe make use of the forHandle/forUsername for YouTube channel list queries, instead of doing a search
+
 class _YouTubeModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
@@ -554,6 +560,21 @@ class _YouTubeModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_CHANNEL_ID_FROM_CUSTOM_URL(payload) {
+		/**
+		 * Example URLs
+		 * # Caravan Palace
+		 * https://www.youtube.com/channel/UCKH9HfYY_GEcyltl2mbD5lA
+		 * https://www.youtube.com/user/CaravanPalace
+		 * https://www.youtube.com/c/CaravanPalace # Doesn't exist
+		 * https://www.youtube.com/@CaravanPalace
+		 * https://www.youtube.com/CaravanPalace
+		 * # Pogo
+		 * https://www.youtube.com/channel/UCn-K7GIs62ENvdQe6ZZk9-w
+		 * https://www.youtube.com/user/PogoMusic # Doesn't exist
+		 * https://www.youtube.com/c/PogoMusic
+		 * https://www.youtube.com/@PogoMusic # Redirects to /pogomusic
+		 * https://www.youtube.com/PogoMusic
+		 */
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -564,7 +585,12 @@ class _YouTubeModule extends CoreClass {
 							maxResults: 50
 						};
 
-						params.q = payload.customUrl;
+						const { customUrl } = payload;
+
+						// Force query to YouTube to start with @, so it will search for "@PogoMusic" for example
+						const query = customUrl.startsWith("@") ? customUrl : `@${customUrl}`;
+
+						params.q = query;
 
 						YouTubeModule.runJob(
 							"API_SEARCH",
@@ -904,19 +930,14 @@ class _YouTubeModule extends CoreClass {
 	 */
 	GET_CHANNEL_ID(payload) {
 		return new Promise((resolve, reject) => {
-			const regex =
-				/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
-			const splitQuery = regex.exec(payload.url);
+			const splitQuery = youtubeChannelRegex.exec(payload.url);
 
-			if (!splitQuery) {
+			if (!splitQuery.groups) {
 				YouTubeModule.log("ERROR", "GET_CHANNEL_ID", "Invalid YouTube channel URL query.");
 				reject(new Error("Invalid playlist URL."));
 				return;
 			}
-			const channelId = splitQuery[1];
-			const channelUsername = splitQuery[2];
-			const channelCustomUrl = splitQuery[3];
-			const channelUsernameOrCustomUrl = splitQuery[4];
+			const { channelId, channelUsername, channelCustomUrl, channelUsernameOrCustomUrl } = splitQuery.groups;
 
 			const disableSearch = payload.disableSearch || false;
 
@@ -977,19 +998,14 @@ class _YouTubeModule extends CoreClass {
 	 */
 	GET_CHANNEL_VIDEOS(payload) {
 		return new Promise((resolve, reject) => {
-			const regex =
-				/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
-			const splitQuery = regex.exec(payload.url);
+			const splitQuery = youtubeChannelRegex.exec(payload.url);
 
-			if (!splitQuery) {
+			if (!splitQuery.groups) {
 				YouTubeModule.log("ERROR", "GET_CHANNEL_VIDEOS", "Invalid YouTube channel URL query.");
 				reject(new Error("Invalid playlist URL."));
 				return;
 			}
-			const channelId = splitQuery[1];
-			const channelUsername = splitQuery[2];
-			const channelCustomUrl = splitQuery[3];
-			const channelUsernameOrCustomUrl = splitQuery[4];
+			const { channelId, channelUsername, channelCustomUrl, channelUsernameOrCustomUrl } = splitQuery.groups;
 
 			const disableSearch = payload.disableSearch || false;
 

+ 11 - 0
frontend/src/classes/SocketHandler.class.ts

@@ -189,6 +189,17 @@ export default class SocketHandler {
 		return this.socket.send(JSON.stringify([...args]));
 	}
 
+	dispatchAsync(...args: [string, ...any]) {
+		return new Promise(resolve => {
+			this.dispatch(...args, (res, ...extraRes) => {
+				resolve({
+					res,
+					extraRes
+				});
+			});
+		});
+	}
+
 	onConnect(cb: (...args: any[]) => any, persist = false) {
 		if (this.socket && this.socket.readyState === 1 && this.ready) cb();
 

+ 20 - 0
frontend/src/components/AdvancedTable.vue

@@ -250,6 +250,16 @@ const getData = () => {
 	);
 };
 
+const validQueryKeys = [
+	"page",
+	"pageSize",
+	"filter",
+	"columnSort",
+	"columnOrder",
+	"columnWidths",
+	"shownColumns"
+];
+
 const setQuery = () => {
 	const queryObject = {
 		...route.query,
@@ -272,6 +282,16 @@ const setQuery = () => {
 		shownColumns: JSON.stringify(shownColumns.value)
 	};
 
+	const invalidKeys = Object.keys(queryObject).filter(
+		key => !validQueryKeys.includes(key)
+	);
+	invalidKeys.forEach(invalidKey => {
+		console.log(
+			`Removing query key ${invalidKey} with value ${queryObject[invalidKey]}.`
+		);
+		delete queryObject[invalidKey];
+	});
+
 	const queryString = `?${Object.keys(queryObject)
 		.map(key => `${key}=${queryObject[key]}`)
 		.join("&")}`;