Browse Source

WIP album artists from weeks/months ago

Kristian Vos 11 months ago
parent
commit
c8ad321f0c

+ 117 - 0
backend/logic/actions/albums.js

@@ -0,0 +1,117 @@
+import async from "async";
+
+import { useHasPermission } from "../hooks/hasPermission";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+export default {
+	/**
+	 * Gets album items, used in the admin album page by the AdvancedTable component
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each album item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getData: useHasPermission(
+		"admin.view.albums",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "albums",
+								blacklistedProperties: [],
+								specialProperties: {
+									createdBy: [
+										{
+											$addFields: {
+												createdByOID: {
+													$convert: {
+														input: "$createdBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
+												}
+											}
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "createdByOID",
+												foreignField: "_id",
+												as: "createdByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$createdByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												createdByUsername: {
+													$ifNull: ["$createdByUser.username", "unknown"]
+												}
+											}
+										},
+										{
+											$project: {
+												createdByOID: 0,
+												createdByUser: 0
+											}
+										}
+									]
+								},
+								specialQueries: {
+									createdBy: newQuery => ({
+										$or: [newQuery, { createdByUsername: newQuery.createdBy }]
+									})
+								}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "ALBUMS_GET_DATA", `Failed to get data from albums. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "ALBUMS_GET_DATA", `Got data from albums successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from albums.",
+						data: response
+					});
+				}
+			);
+		}
+	),
+};

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

@@ -392,6 +392,7 @@ export default {
 	joinAdminRoom(session, page, cb) {
 		if (
 			page === "songs" ||
+			page === "artists" ||
 			page === "stations" ||
 			page === "reports" ||
 			page === "news" ||

+ 328 - 0
backend/logic/actions/artists.js

@@ -0,0 +1,328 @@
+import async from "async";
+
+import { useHasPermission } from "../hooks/hasPermission";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const MusicBrainzModule = moduleManager.modules.musicbrainz;
+const YouTubeModule = moduleManager.modules.youtube;
+
+CacheModule.runJob("SUB", {
+	channel: "artists.create",
+	cb: artist => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.artists",
+			args: ["event:admin.artists.created", { data: { artist } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "artists.remove",
+	cb: artistId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.artists",
+			args: ["event:admin.artists.deleted", { data: { artistId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "artists.update",
+	cb: artist => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.artists",
+			args: ["event:admin.artists.updated", { data: { artist } }]
+		});
+	}
+});
+
+export default {
+	/**
+	 * Gets artist items, used in the admin artist page by the AdvancedTable component
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each artist item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getData: useHasPermission(
+		"admin.view.artists",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "artist",
+								blacklistedProperties: [],
+								specialProperties: {
+									createdBy: [
+										{
+											$addFields: {
+												createdByOID: {
+													$convert: {
+														input: "$createdBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
+												}
+											}
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "createdByOID",
+												foreignField: "_id",
+												as: "createdByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$createdByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												createdByUsername: {
+													$ifNull: ["$createdByUser.username", "unknown"]
+												}
+											}
+										},
+										{
+											$project: {
+												createdByOID: 0,
+												createdByUser: 0
+											}
+										}
+									]
+								},
+								specialQueries: {
+									createdBy: newQuery => ({
+										$or: [newQuery, { createdByUsername: newQuery.createdBy }]
+									})
+								}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "ARTISTS_GET_DATA", `Failed to get data from artists. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "ARTISTS_GET_DATA", `Got data from artists successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from artists.",
+						data: response
+					});
+				}
+			);
+		}
+	),
+
+	/**
+	 * Creates an artist item
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} data - the object of the artist data
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: useHasPermission("artists.create", async function create(session, data, cb) {
+		const artistModel = await DBModule.runJob("GET_MODEL", { modelName: "artist" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (data?.musicbrainzData?.id !== data?.musicbrainzIdentifier) return next("MusicBrainz data must match the provided identifier.");
+					return next();
+				},
+				next => {
+					data.createdBy = session.userId;
+					data.createdAt = Date.now();
+					artistModel.create(data, next);
+				}
+			],
+			async (err, artist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ARTIST_CREATE", `Creating artist failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "artists.create", value: artist });
+
+				this.log("SUCCESS", "ARTIST_CREATE", `Created artist successful.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully created artist"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets a artist item by id
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} artistId - the artist item id
+	 * @param {Function} cb - gets called with the result
+	 */
+	async getArtistFromId(session, artistId, cb) {
+		const artistModel = await DBModule.runJob("GET_MODEL", { modelName: "artist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					artistModel.findById(artistId, next);
+				}
+			],
+			async (err, artist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_ARTIST_FROM_ID", `Getting artist item ${artistId} failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "GET_ARTIST_FROM_ID", `Got artist item ${artistId} successfully.`, false);
+
+				return cb({ status: "success", data: { artist } });
+			}
+		);
+	},
+
+	/**
+	 * Updates an artist item
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} artistId - the id of the artist item
+	 * @param {object} item - the artist item object
+	 * @param {Function} cb - gets called with the result
+	 */
+	update: useHasPermission("artists.update", async function update(session, artistId, item, 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.");
+					if (item?.musicbrainzData?.id !== item?.musicbrainzIdentifier) return next("MusicBrainz data must match the provided identifier.");
+					return next();
+				},
+
+				next => {
+					artistModel.updateOne({ _id: artistId }, item, { upsert: true }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"ARTIST_UPDATE",
+						`Updating artist item "${artistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "artists.update", value: { ...item, _id: artistId } });
+
+				this.log(
+					"SUCCESS",
+					"ARTIST_UPDATE",
+					`Updated artist item "${artistId}" successful for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully updated 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}/`,
+				params: {
+					fmt: "json",
+					inc: "aliases"
+				}
+			},
+			this
+		);
+
+		return cb({
+			data: ArtistApiResponse,
+		});
+	}),
+
+	getMusicbrainzRelatedUrls: useHasPermission("artists.update", async function getMusicbrainzRelatedUrls(session, musicbrainzIdentifier, cb) {
+		const ArtistApiResponse = await MusicBrainzModule.runJob(
+			"API_CALL",
+			{
+				url: `https://musicbrainz.org/ws/2/artist/${musicbrainzIdentifier}/`,
+				params: {
+					fmt: "json",
+					inc: "url-rels"
+				}
+			},
+			this
+		);
+
+		return cb({
+			data: ArtistApiResponse,
+		});
+	}),
+
+	getIdFromUrl: useHasPermission("artists.update", async function getIdFromUrl(session, type, url, cb) {
+		if (type === "youtube") {
+			YouTubeModule.runJob("GET_CHANNEL_ID", {
+				url,
+				disableSearch: false,
+			}).then(({ channelId }) => {
+				if (channelId) {
+					cb({
+						status: "success",
+						channelId,
+					});
+				} else {
+					cb({
+						status: "error",
+						message: "Playlist id not found",
+					});
+				}
+			}).catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				cb({ status: "error", message: err });
+			});
+		} else cb({
+			status: "error",
+			message: "Invalid type"
+		});
+	}),
+};

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

@@ -13,6 +13,8 @@ import youtube from "./youtube";
 import soundcloud from "./soundcloud";
 import spotify from "./spotify";
 import media from "./media";
+import artists from "./artists";
+import albums from "./albums";
 
 export default {
 	apis,
@@ -29,5 +31,7 @@ export default {
 	youtube,
 	soundcloud,
 	spotify,
-	media
+	media,
+	artists,
+	albums,
 };

+ 3 - 2
backend/logic/actions/playlists.js

@@ -1822,7 +1822,7 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	addYoutubeSetToPlaylist: isLoginRequired(
-		async function addYoutubeSetToPlaylist(session, url, playlistId, musicOnly, cb) {
+		async function addYoutubeSetToPlaylist(session, url, playlistId, musicOnly, max, cb) {
 			let videosInPlaylistTotal = 0;
 			let songsInPlaylistTotal = 0;
 			let addSongsStats = null;
@@ -1869,7 +1869,8 @@ export default {
 								{
 									url,
 									musicOnly,
-									disableSearch: !isAdmin
+									disableSearch: !isAdmin,
+									max,
 								},
 								this
 							)

+ 3 - 2
backend/logic/actions/youtube.js

@@ -657,7 +657,7 @@ export default {
 	 */
 	requestSetAdmin: useHasPermission(
 		"youtube.requestSetAdmin",
-		async function requestSetAdmin(session, url, musicOnly, returnVideos, cb) {
+		async function requestSetAdmin(session, url, musicOnly, max, returnVideos, cb) {
 			const importJobModel = await DBModule.runJob("GET_MODEL", { modelName: "importJob" }, this);
 
 			this.keepLongJob();
@@ -697,8 +697,9 @@ export default {
 					},
 
 					(importJob, next) => {
-						YouTubeModule.runJob("REQUEST_SET", { url, musicOnly, returnVideos }, this)
+						YouTubeModule.runJob("REQUEST_SET", { url, musicOnly, max, returnVideos }, this)
 							.then(response => {
+								console.log(111, response, max);
 								next(null, importJob, response);
 							})
 							.catch(err => {

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

@@ -25,7 +25,9 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	spotifyTrack: 1,
 	spotifyAlbum: 1,
 	spotifyArtist: 1,
-	genericApiRequest: 1
+	genericApiRequest: 1,
+	artist: 1,
+	album: 1,
 };
 
 const regex = {
@@ -88,7 +90,9 @@ class _DBModule extends CoreClass {
 						spotifyTrack: {},
 						spotifyAlbum: {},
 						spotifyArtist: {},
-						genericApiRequest: {}
+						genericApiRequest: {},
+						artist: {},
+						album: {},
 					};
 
 					const importSchema = schemaName =>
@@ -120,6 +124,8 @@ class _DBModule extends CoreClass {
 					await importSchema("spotifyAlbum");
 					await importSchema("spotifyArtist");
 					await importSchema("genericApiRequest");
+					await importSchema("artist");
+					await importSchema("album");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -142,7 +148,9 @@ class _DBModule extends CoreClass {
 						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)
+						genericApiRequest: mongoose.model("genericApiRequest", this.schemas.genericApiRequest),
+						artist: mongoose.model("artist", this.schemas.artist),
+						album: mongoose.model("album", this.schemas.album),
 					};
 
 					mongoose.connection.on("error", err => {
@@ -311,6 +319,8 @@ class _DBModule extends CoreClass {
 					this.models.spotifyTrack.syncIndexes();
 					this.models.spotifyArtist.syncIndexes();
 					this.models.genericApiRequest.syncIndexes();
+					this.models.artist.syncIndexes();
+					this.models.album.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 27 - 0
backend/logic/db/schemas/album.js

@@ -0,0 +1,27 @@
+export default {
+	name: { type: String, required: true },
+	// youtubeChannelIds: [
+	// 	{
+	// 		youtubeChannelId: { type: String },
+	// 		comment: { type: String },
+	// 	}
+	// ],
+	// spotifyArtistIds: [
+	// 	{
+	// 		spotifyArtistId: { type: String },
+	// 		comment: { type: String },
+	// 	}
+	// ],
+	// soundcloudArtistIds: [
+	// 	{
+	// 		soundcloudArtistId: { type: String },
+	// 		comment: { type: String },
+	// 	}
+	// ],
+	// musicBrainzIdentifier: { type: String, required: true },
+	// musicBrainzData: { type: Object, required: true },
+	comment: { type: String },
+	createdBy: { type: String, required: true },
+	createdAt: { type: Number, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

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

@@ -0,0 +1,27 @@
+export default {
+	name: { type: String, required: true },
+	youtubeChannels: [
+		{
+			youtubeChannelId: { type: String },
+			comment: { type: String },
+		}
+	],
+	spotifyArtists: [
+		{
+			spotifyArtistId: { type: String },
+			comment: { type: String },
+		}
+	],
+	soundcloudArtists: [
+		{
+			soundcloudArtistId: { type: String },
+			comment: { type: String },
+		}
+	],
+	musicbrainzIdentifier: { type: String, required: true },
+	musicbrainzData: { type: Object, required: true },
+	comment: { type: String },
+	createdBy: { type: String, required: true },
+	createdAt: { type: Number, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

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

@@ -33,6 +33,8 @@ permissions.moderator = {
 	"admin.view.playlists": true,
 	"admin.view.punishments": true,
 	"admin.view.reports": true,
+	"admin.view.artists": true,
+	"admin.view.albums": true,
 	"admin.view.songs": true,
 	"admin.view.stations": true,
 	"admin.view.users": true,
@@ -57,6 +59,10 @@ permissions.moderator = {
 	"songs.get": true,
 	"songs.update": true,
 	"songs.verify": true,
+	"artists.create": true,
+	"artists.update": true,
+	"albums.create": true,
+	"albums.update": true,
 	"stations.create.official": true,
 	"stations.index": false,
 	"stations.index.other": true,

+ 1 - 1
backend/logic/ws.js

@@ -19,7 +19,7 @@ let PunishmentsModule;
 class _WSModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
-		super("ws", { concurrency: 2 });
+		super("ws", { concurrency: 10 });
 
 		WSModule = this;
 	}

+ 183 - 14
backend/logic/youtube.js

@@ -465,6 +465,47 @@ class _YouTubeModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets the id of the channel from a username
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.username - the username of the YouTube channel
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_CHANNEL_ID_FROM_USERNAME(payload) {
+		return new Promise((resolve, reject) => {
+			const params = {
+				part: "id"
+			};
+
+			params.forUsername = payload.username;
+
+			YouTubeModule.runJob(
+				"API_GET_CHANNELS",
+				{
+					params
+				},
+				this
+			)
+				.then(({ response }) => {
+					const { data } = response;
+
+					if (data.pageInfo.totalResults === 0) return reject(new Error("Channel not found."));
+
+					const channelId = data.items[0].id;
+
+					return resolve({ channelId });
+				})
+				.catch(err => {
+					YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_USERNAME", `${err.message}`);
+					if (err.message === "Request failed with status code 404")
+						return reject(new Error("Channel not found. Is the channel public/unlisted?"));
+					if (err.message === "Searching with YouTube is disabled.") return reject(err);
+					return reject(new Error("An error has occured. Please try again later."));
+				});
+		});
+	}
+
 	/**
 	 * Gets the id of the channel upload playlist
 	 * @param {object} payload - object that contains the payload
@@ -635,6 +676,7 @@ class _YouTubeModule extends CoreClass {
 								next(null, playlistInfo);
 							})
 							.catch(err => {
+								console.log(123, err);
 								next(err);
 							});
 					},
@@ -655,6 +697,7 @@ class _YouTubeModule extends CoreClass {
 
 						async.whilst(
 							next => {
+								if (payload.max > 0 && songs.length > payload.max) return next(null, false);
 								if (nextPageToken === undefined) return next(null, false);
 
 								if (currentPage >= maxPages) {
@@ -674,9 +717,7 @@ class _YouTubeModule extends CoreClass {
 
 								YouTubeModule.log(
 									"INFO",
-									`Getting playlist progress for job (${this.toString()}): ${
-										songs.length
-									} songs gotten so far. Current page: ${currentPage}`
+									`Getting playlist progress for job (${this.toString()}): ${songs.length} songs gotten so far. Current page: ${currentPage}. Max: ${payload.max}.`
 								);
 
 								// Add 250ms delay between each job request
@@ -701,6 +742,7 @@ class _YouTubeModule extends CoreClass {
 						),
 
 					(songs, next) => {
+						if (payload.max > 0) songs.splice(payload.max);
 						if (!payload.musicOnly) return next(true, { songs });
 						return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
 							.then(filteredSongs => next(null, { filteredSongs, songs }))
@@ -854,6 +896,77 @@ class _YouTubeModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Returns the channel ID from a provided URL
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {boolean} payload.disableSearch - whether to allow searching for custom url/username
+	 * @param {string} payload.url - the url of the YouTube channel
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	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);
+
+			if (!splitQuery) {
+				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 disableSearch = payload.disableSearch || false;
+
+			async.waterfall(
+				[
+					next => {
+						if (channelId) return next(true, channelId);
+						if (!channelUsername) return next(null, true, null);
+
+						YouTubeModule.runJob("GET_CHANNEL_ID_FROM_USERNAME", { username: channelUsername, }, this)
+							.then(({ channelId }) => {
+								next(null, null, channelId)
+							})
+							.catch(next);
+					},
+
+					(getUsernameFromCustomUrl, channelId, next) => {
+						if (!getUsernameFromCustomUrl) return next(null, channelId);
+
+						if (disableSearch)
+							return next(
+								"You are not allowed to look up this type of YouTube channel URL. Please provide a channel URL with the channel ID in it."
+							);
+						const payload = {};
+						if (channelCustomUrl) payload.customUrl = channelCustomUrl;
+						else if (channelUsernameOrCustomUrl) payload.customUrl = channelUsernameOrCustomUrl;
+						else return next("No proper URL provided.");
+
+						return YouTubeModule.runJob("GET_CHANNEL_ID_FROM_CUSTOM_URL", payload, this)
+							.then(({ channelId }) => {
+								next(null, channelId);
+							})
+							.catch(err => next(err));
+					},
+				],
+				(err, channelId) => {
+					console.log(111, err, channelId);
+					if (err && err !== true) {
+						YouTubeModule.log("ERROR", "GET_CHANNEL_ID", "Some error has occurred.", err.message || err);
+						reject(new Error(err.message || err));
+					} else {
+						resolve({ channelId });
+					}
+				}
+			);
+		});
+	}
+
 	/**
 	 * Returns an array of songs taken from a YouTube channel
 	 * @param {object} payload - object that contains the payload
@@ -869,7 +982,7 @@ class _YouTubeModule extends CoreClass {
 			const splitQuery = regex.exec(payload.url);
 
 			if (!splitQuery) {
-				YouTubeModule.log("ERROR", "GET_CHANNEL", "Invalid YouTube channel URL query.");
+				YouTubeModule.log("ERROR", "GET_CHANNEL_VIDEOS", "Invalid YouTube channel URL query.");
 				reject(new Error("Invalid playlist URL."));
 				return;
 			}
@@ -928,6 +1041,7 @@ class _YouTubeModule extends CoreClass {
 
 						async.whilst(
 							next => {
+								if (payload.max > 0 && songs.length > payload.max) return next(null, false);
 								YouTubeModule.log(
 									"INFO",
 									`Getting channel progress for job (${this.toString()}): ${
@@ -959,6 +1073,7 @@ class _YouTubeModule extends CoreClass {
 						),
 
 					(songs, next) => {
+						if (payload.max > 0) songs.splice(payload.max);
 						if (!payload.musicOnly) return next(true, { songs });
 						return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
 							.then(filteredSongs => next(null, { filteredSongs, songs }))
@@ -1519,6 +1634,7 @@ class _YouTubeModule extends CoreClass {
 		};
 
 		const { identifiers, createMissing, replaceExisting } = payload;
+		const stats = {};
 
 		const youtubeIds = identifiers.filter(identifier => !mongoose.Types.ObjectId.isValid(identifier));
 		const objectIds = identifiers.filter(identifier => mongoose.Types.ObjectId.isValid(identifier));
@@ -1530,20 +1646,51 @@ class _YouTubeModule extends CoreClass {
 		const existingYoutubeIds = existingVideos.map(existingVideo => existingVideo.youtubeId);
 		// const existingYoutubeObjectIds = existingVideos.map(existingVideo => existingVideo._id.toString());
 
+		if (payload.returnStats) {
+			stats.successful = existingYoutubeIds.length;
+			stats.alreadyInDatabase = existingYoutubeIds.length;
+			stats.failed = 0;
+			stats.failedVideoIds = [];
+			stats.successfulVideoIds = existingYoutubeIds;
+		}
+		
+
 		if (!replaceExisting) {
-			if (!createMissing) return { videos: existingVideos };
-			if (identifiers.length === existingVideos.length || youtubeIds.length === 0)
-				return { videos: existingVideos };
+			if (!createMissing) {
+				return {
+					...stats,
+					videos: existingVideos,
+				}
+			}
+			if (identifiers.length === existingVideos.length || youtubeIds.length === 0) {
+				return {
+					...stats,
+					videos: existingVideos
+				}
+			}
 
 			const missingYoutubeIds = youtubeIds.filter(youtubeId => existingYoutubeIds.indexOf(youtubeId) === -1);
 
-			if (missingYoutubeIds.length === 0) return { videos: existingVideos };
+			if (missingYoutubeIds.length === 0) return {
+				...stats,
+				videos: existingVideos,
+			}
 
 			const newVideos = await getVideosFromYoutubeIds(missingYoutubeIds);
 
 			await YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: newVideos }, this);
 
-			return { videos: existingVideos.concat(newVideos) };
+			stats.successful += newVideos.length;
+			stats.successfulVideoIds = [
+				...stats.successfulVideoIds,
+				...newVideos.map(newVideo => newVideo.youtubeId),
+			];
+			// TODO actually handle failed videos I guess?
+
+			return {
+				...stats,
+				videos: existingVideos.concat(newVideos),
+			}
 		}
 
 		const newVideos = await getVideosFromYoutubeIds(existingYoutubeIds);
@@ -1557,7 +1704,10 @@ class _YouTubeModule extends CoreClass {
 
 		await Promise.allSettled(promises);
 
-		return { videos: newVideos };
+		return {
+			...stats,
+			videos: newVideos,
+		}
 	}
 
 	/**
@@ -1834,7 +1984,8 @@ class _YouTubeModule extends CoreClass {
 								playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL_VIDEOS",
 								{
 									url: payload.url,
-									musicOnly: payload.musicOnly
+									musicOnly: payload.musicOnly,
+									max: payload.max,
 								},
 								this
 							)
@@ -1848,13 +1999,31 @@ class _YouTubeModule extends CoreClass {
 					async youtubeIds => {
 						if (youtubeIds.length === 0) return { videos: [] };
 
-						const { videos } = await YouTubeModule.runJob(
+						const {
+							videos,
+							successful,
+							failed,
+							alreadyInDatabase,
+							successfulVideoIds,
+							failedVideoIds,
+						} = await YouTubeModule.runJob(
 							"GET_VIDEOS",
-							{ identifiers: youtubeIds, createMissing: true },
+							{
+								identifiers: youtubeIds,
+								createMissing: true,
+								returnStats: true,
+							},
 							this
 						);
 
-						return { videos };
+						return {
+							videos,
+							successful,
+							failed,
+							alreadyInDatabase,
+							successfulVideoIds,
+							failedVideoIds,
+						};
 					}
 				],
 				(err, response) => {

+ 1 - 1
backend/package-lock.json

@@ -9051,4 +9051,4 @@
       "dev": true
     }
   }
-}
+}

+ 1 - 0
frontend/src/components/ModalManager.vue

@@ -14,6 +14,7 @@ const modalComponents = shallowRef(
 		whatIsNew: "WhatIsNew.vue",
 		createStation: "CreateStation.vue",
 		editNews: "EditNews.vue",
+		editArtist: "EditArtist.vue",
 		manageStation: "ManageStation/index.vue",
 		editPlaylist: "EditPlaylist/index.vue",
 		createPlaylist: "CreatePlaylist.vue",

+ 161 - 0
frontend/src/components/modals/EditAlbum.vue

@@ -0,0 +1,161 @@
+<script setup lang="ts">
+import { useForm } from "@/composables/useForm";
+import Toast from "toasters";
+import { defineAsyncComponent, ref, onMounted } from "vue";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SaveButton = defineAsyncComponent(
+	() => import("@/components/SaveButton.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	createArtist: { type: Boolean, default: false },
+	artistId: { type: String, default: null },
+	sector: { type: String, default: "admin" }
+});
+
+const { socket } = useWebsocketsStore();
+
+const { closeCurrentModal } = useModalsStore();
+
+const createdBy = ref();
+const createdAt = ref(0);
+
+onMounted(() => {
+	socket.onConnect(() => {
+		if (props.artistId && !props.createArtist) {
+			socket.dispatch(
+				`artists.getArtistFromId`,
+				props.artistId,
+				(res) => { // res: GetArtistResponse
+					if (res.status === "success") {
+						setOriginalValue({
+							name: res.data.artist.name,
+							musicbrainzIdentifier: res.data.artist.musicbrainzIdentifier,
+						});
+						createdBy.value = res.data.artist.createdBy;
+						createdAt.value = res.data.artist.createdAt;
+					} else {
+						new Toast("Artist with that ID not found.");
+						closeCurrentModal();
+					}
+				}
+			);
+		}
+	});
+});
+
+const {
+	inputs,
+	save,
+	setOriginalValue,
+ } = useForm(
+	{
+		name: {
+			value: "",
+		},
+		musicbrainzIdentifier: {
+			value: "",
+		},
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			const data = {
+				name: values.name,
+				musicbrainzIdentifier: values.musicbrainzIdentifier,
+			};
+			const cb = (res: GenericResponse) => {
+				new Toast(res.message);
+				if (res.status === "success") resolve();
+				else reject(new Error(res.message));
+			};
+			if (props.createArtist) socket.dispatch("artists.create", data, cb);
+			else socket.dispatch("artists.update", props.artistId, data, cb);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+	},
+);
+
+const saveArtist = (close?: boolean) => {
+	save(() => {
+		if (close) {
+			closeCurrentModal();
+		}
+	});
+};
+
+</script>
+
+<template>
+	<modal
+		class="edit-artist-modal"
+		:title="createArtist ? 'Create Artist' : 'Edit Artist'"
+		:size="'wide'"
+		:split="true"
+	>
+		<template #body>
+			<div class="control is-grouped">
+				<div class="name-container">
+					<label class="label">Name</label>
+					<p class="control has-addons">
+						<input
+							class="input"
+							type="text"
+							:ref="el => (inputs['name'].ref = el)"
+							v-model="inputs['name'].value"
+							placeholder="Enter artist name..."
+						/>
+					</p>
+				</div>
+			</div>
+			<div class="control is-grouped">
+				<div class="musicbrainz-identifier-container">
+					<label class="label">MusicBrainz identifier</label>
+					<p class="control has-addons">
+						<input
+							class="input"
+							type="text"
+							:ref="el => (inputs['musicbrainzIdentifier'].ref = el)"
+							v-model="inputs['musicbrainzIdentifier'].value"
+							placeholder="Enter MusicBrainz identifier..."
+						/>
+					</p>
+				</div>
+			</div>
+			<div>
+				<p>MusicBrainz data</p>
+			</div>
+		</template>
+		<template #footer>
+			<div>
+				<save-button
+					:default-message="`${createArtist ? 'Create' : 'Update'} Artist`"
+					@clicked="saveArtist()"
+				/>
+				<save-button
+					:default-message="`${createArtist ? 'Create' : 'Update'} and close`"
+					@clicked="saveArtist(true)"
+				/>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<style lang="less">
+</style>
+
+<style lang="less" scoped>
+</style>

+ 518 - 0
frontend/src/components/modals/EditArtist.vue

@@ -0,0 +1,518 @@
+<script setup lang="ts">
+import { useForm } from "@/composables/useForm";
+import Toast from "toasters";
+import { defineAsyncComponent, ref, onMounted } from "vue";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+import { useLongJobsStore } from "@/stores/longJobs";
+import VueJsonPretty from "vue-json-pretty";
+import "vue-json-pretty/lib/styles.css";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SaveButton = defineAsyncComponent(
+	() => import("@/components/SaveButton.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	createArtist: { type: Boolean, default: false },
+	artistId: { type: String, default: null },
+	sector: { type: String, default: "admin" }
+});
+
+const { socket } = useWebsocketsStore();
+
+const { closeCurrentModal } = useModalsStore();
+const { setJob } = useLongJobsStore();
+
+const createdBy = ref();
+const createdAt = ref(0);
+const hideMusicbrainzData = ref(true);
+
+const relatedSongs = ref([]);
+
+const refreshRelatedSongs = () => {}
+const refreshRelatedAlbums = () => {}
+
+onMounted(() => {
+	socket.onConnect(() => {
+		if (props.artistId && !props.createArtist) {
+			socket.dispatch(
+				`artists.getArtistFromId`,
+				props.artistId,
+				(res) => { // res: GetArtistResponse
+					if (res.status === "success") {
+						setOriginalValue({
+							name: res.data.artist.name,
+							musicbrainzIdentifier: res.data.artist.musicbrainzIdentifier,
+							musicbrainzData: res.data.artist.musicbrainzData ?? {},
+							youtubeChannels: res.data.artist.youtubeChannels ?? [],
+							spotifyArtists: res.data.artist.spotifyArtists ?? [],
+							soundcloudArtists: res.data.artist.soundcloudArtists ?? [],
+						});
+						createdBy.value = res.data.artist.createdBy;
+						createdAt.value = res.data.artist.createdAt;
+
+						refreshRelatedSongs();
+						refreshRelatedAlbums();
+					} else {
+						new Toast("Artist with that ID not found.");
+						closeCurrentModal();
+					}
+				}
+			);
+		}
+	});
+});
+
+const {
+	inputs,
+	save,
+	setOriginalValue,
+ } = useForm(
+	{
+		name: {
+			value: "",
+		},
+		musicbrainzIdentifier: {
+			value: "",
+		},
+		musicbrainzData: {
+			value: {},
+		},
+		youtubeChannels: {
+			value: [],
+		},
+		spotifyArtists: {
+			value: [],
+		},
+		soundcloudArtists: {
+			value: [],
+		},
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			const data = {
+				name: values.name,
+				musicbrainzIdentifier: values.musicbrainzIdentifier,
+				musicbrainzData: values.musicbrainzData,
+				youtubeChannels: values.youtubeChannels,
+				spotifyArtists: values.spotifyArtists,
+				soundcloudArtists: values.soundcloudArtists,
+			};
+			const cb = (res: GenericResponse) => {
+				new Toast(res.message);
+				if (res.status === "success") resolve();
+				else reject(new Error(res.message));
+			};
+			if (props.createArtist) socket.dispatch("artists.create", data, cb);
+			else socket.dispatch("artists.update", props.artistId, data, cb);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+	},
+);
+
+const saveArtist = (close?: boolean) => {
+	save(() => {
+		if (close) {
+			closeCurrentModal();
+		}
+	});
+};
+
+const getMusicbrainzArtistData = (musicbrainzIdentifier) => {
+	socket.dispatch("artists.getMusicbrainzArtist", musicbrainzIdentifier, (res) => {
+		new Toast("Successfully got data");
+		inputs.value["musicbrainzData"].value = res.data;
+	});
+}
+
+const addYoutubeChannel = () => {
+	inputs.value["youtubeChannels"].value.push({
+		youtubeChannelId: "",
+		comment: "",
+	});
+}
+
+const removeYoutubeChannel = index => {
+	inputs.value["youtubeChannels"].value.splice(index, 1);
+}
+
+const addSpotifyArtist = () => {
+	inputs.value["spotifyArtists"].value.push({
+		spotifyArtistId: "",
+		comment: "",
+	});
+}
+
+const removeSpotifyArtist = index => {
+	inputs.value["spotifyArtists"].value.splice(index, 1);
+}
+
+const addSoundcloudArtist = () => {
+	inputs.value["soundcloudArtists"].value.push({
+		soundcloudArtistId: "",
+		comment: "",
+	});
+}
+
+const removeSoundcloudArtist = index => {
+	inputs.value["soundcloudArtists"].value.splice(index, 1);
+}
+
+const importYoutubeChannel = youtubeChannelId => {
+	let id;
+	let title;
+
+	const youtubeChannelUrl = `https://www.youtube.com/channel/${youtubeChannelId}`;
+
+	socket.dispatch(
+		"youtube.requestSetAdmin",
+		youtubeChannelUrl,
+		false, // Import only music = false
+		0, // Max = 0, so import all
+		true, // Return video's = true
+		{
+			cb: () => {
+				console.log("CB done");
+			},
+			onProgress: res => {
+				console.log(123, res);
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+}
+
+const fillMissingUrls = musicbrainzIdentifier => {
+	socket.dispatch("artists.getMusicbrainzRelatedUrls", musicbrainzIdentifier, res => {
+		const youtubeUrls = res.data.relations.filter(relation => relation.type === "youtube").map(relation => relation.url.resource);
+
+		const promises = [];
+
+		youtubeUrls.forEach(youtubeUrl => {
+			promises.push(new Promise(resolve => {
+				socket.dispatch("artists.getIdFromUrl", "youtube", youtubeUrl, res => {
+					console.log(555, res);
+
+					if (res.status === "success") {
+						const youtubeChannelId = res.channelId;
+
+						const existingYoutubeChannelIds = inputs.value["youtubeChannels"].value.map(youtubeChannel => youtubeChannel.youtubeChannelId);
+						if (!existingYoutubeChannelIds.includes(youtubeChannelId)) {
+							inputs.value["youtubeChannels"].value.push({
+								youtubeChannelId,
+								comment: `MusicBrainz URL artist relation ${'test'}`,
+							});
+						}
+					}
+
+					resolve(null);
+				});
+			}));
+		});
+
+		Promise.all(promises);
+	});
+}
+</script>
+
+<template>
+	<modal
+		class="edit-artist-modal"
+		:title="createArtist ? 'Create Artist' : 'Edit Artist'"
+		:size="'wide'"
+		:split="true"
+	>
+		<template #body>
+			<div class="flex flex-row w-full">
+				<div class="flex flex-column gap-4 w-2/3">
+					<div>
+						<div class="control is-grouped">
+							<div class="name-container">
+								<label class="label">Name</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										:ref="el => (inputs['name'].ref = el)"
+										v-model="inputs['name'].value"
+										placeholder="Enter artist name..."
+									/>
+								</p>
+							</div>
+						</div>
+					</div>
+					<div>
+						<div class="control is-grouped gap-4">
+							<div class="musicbrainz-identifier-container">
+								<label class="label">MusicBrainz identifier</label>
+								<input
+									class="input"
+									type="text"
+									:ref="el => (inputs['musicbrainzIdentifier'].ref = el)"
+									v-model="inputs['musicbrainzIdentifier'].value"
+									placeholder="Enter MusicBrainz identifier..."
+								/>
+							</div>
+							<button
+								class="button is-primary button-bottom"
+								@click="getMusicbrainzArtistData(inputs['musicbrainzIdentifier'].value)"
+							>
+								Get MusicBrainz artist data
+							</button>
+							<button
+								class="button is-primary button-bottom"
+								@click="fillMissingUrls(inputs['musicbrainzIdentifier'].value)"
+							>
+								Fill artists/channels from MusicBrainz
+							</button>
+						</div>
+						<div>
+							<div class="flex flex-row gap-4">
+								<p class="text-vcenter">MusicBrainz data</p>
+								<button
+									class="button is-primary"
+									@click="hideMusicbrainzData = !hideMusicbrainzData"
+								>
+									<span v-show="hideMusicbrainzData">Show MusicBrainz data</span>
+									<span v-show="!hideMusicbrainzData">Hide MusicBrainz data</span>
+								</button>
+							</div>
+							<vue-json-pretty
+								:data="inputs['musicbrainzData'].value"
+								:show-length="true"
+								v-if="!hideMusicbrainzData"
+							></vue-json-pretty>
+						</div>
+					</div>
+					<div>
+						<p>YouTube channels</p>
+						<div class="flex flex-column gap-4">
+							<template
+								v-for="(youtubeChannel, index) in inputs['youtubeChannels'].value"
+								:key="`${index}`"
+							>
+								<div class="control is-grouped gap-4">
+									<div class="name-container">
+										<label class="label">YouTube channel ID</label>
+										<input
+											class="input"
+											type="text"
+											v-model="youtubeChannel.youtubeChannelId"
+											placeholder="Enter YouTube channel ID..."
+										/>
+									</div>
+									<div class="name-container">
+										<label class="label">Comment</label>
+										<input
+											class="input"
+											type="text"
+											v-model="youtubeChannel.comment"
+											placeholder="Enter comment..."
+										/>
+									</div>
+									<button
+										class="button is-primary button-bottom"
+										@click="removeYoutubeChannel(index)"
+									>
+										Remove
+									</button>
+									<button
+										class="button is-primary button-bottom"
+										@click="importYoutubeChannel(youtubeChannel.youtubeChannelId)"
+									>
+										Import
+									</button>
+								</div>
+							</template>
+						</div>
+						<button
+							class="button is-primary"
+							@click="addYoutubeChannel()"
+						>
+							Add YouTube channel
+						</button>
+					</div>
+					<div>
+						<p>Spotify artists</p>
+						<div class="flex flex-column gap-4">
+							<template
+								v-for="(spotifyArtist, index) in inputs['spotifyArtists'].value"
+								:key="`${index}`"
+							>
+								<div class="control is-grouped gap-4">
+									<div class="name-container">
+										<label class="label">Spotify artist ID</label>
+										<input
+											class="input"
+											type="text"
+											v-model="spotifyArtist.spotifyArtistId"
+											placeholder="Enter Spotify artist ID..."
+										/>
+									</div>
+									<div class="name-container">
+										<label class="label">Comment</label>
+										<input
+											class="input"
+											type="text"
+											v-model="spotifyArtist.comment"
+											placeholder="Enter comment..."
+										/>
+									</div>
+									<button
+										class="button is-primary button-bottom"
+										@click="removeSpotifyArtist(index)"
+									>
+										Remove
+									</button>
+								</div>
+							</template>
+						</div>
+						<button
+							class="button is-primary"
+							@click="addSpotifyArtist()"
+						>
+							Add Spotify artist
+						</button>
+					</div>
+					<div>
+						<p>SoundCloud artists</p>
+						<div class="flex flex-column gap-4">
+							<template
+								v-for="(soundcloudArtist, index) in inputs['soundcloudArtists'].value"
+								:key="`${index}`"
+							>
+								<div class="control is-grouped gap-4">
+									<div class="name-container">
+										<label class="label">SoundCloud artist ID</label>
+										<input
+											class="input"
+											type="text"
+											v-model="soundcloudArtist.soundcloudArtistId"
+											placeholder="Enter Soundcloud artist ID..."
+										/>
+									</div>
+									<div class="name-container">
+										<label class="label">Comment</label>
+										<input
+											class="input"
+											type="text"
+											v-model="soundcloudArtist.comment"
+											placeholder="Enter comment..."
+										/>
+									</div>
+									<button
+										class="button is-primary button-bottom"
+										@click="removeSoundcloudArtist(index)"
+									>
+										Remove
+									</button>
+								</div>
+							</template>
+						</div>
+						<button
+							class="button is-primary"
+							@click="addSoundcloudArtist()"
+						>
+							Add Soundcloud artist
+						</button>
+					</div>
+				</div>
+				<div class="flex flex-column w-1/3">
+					
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<div>
+				<save-button
+					:default-message="`${createArtist ? 'Create' : 'Update'} Artist`"
+					@clicked="saveArtist()"
+				/>
+				<save-button
+					:default-message="`${createArtist ? 'Create' : 'Update'} and close`"
+					@clicked="saveArtist(true)"
+				/>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<style lang="less">
+.night-mode {
+	.edit-artist-modal {
+		.vjs-tree-node.is-highlight, .vjs-tree-node:hover {
+			background: black;
+		}
+	}
+}
+</style>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.button-bottom {
+	align-self: flex-end;
+}
+
+.musicbrainz-identifier-container {
+	input {
+		width: 21rem;
+	}
+}
+
+.text-vcenter {
+	align-content: center;
+}
+
+.gap-4 {
+	gap: 1rem;
+}
+
+.w-1\/2 {
+	width: 50%;
+}
+
+.w-1\/3 {
+	width: calc(100% / 3);
+}
+
+.w-2\/3 {
+	width: calc((100% / 3) * 2);
+}
+
+.w-full {
+	width: 100%;
+}
+</style>

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

@@ -57,6 +57,7 @@ const importYoutubePlaylist = () => {
 		youtubeSearch.value.playlist.query,
 		playlist.value._id,
 		youtubeSearch.value.playlist.isImportingOnlyMusic,
+		youtubeSearch.value.playlist.max,
 		{
 			cb: () => {},
 			onProgress: res => {
@@ -227,6 +228,14 @@ const importMusarePlaylistFile = () => {
 					@keyup.enter="importYoutubePlaylist()"
 				/>
 			</p>
+			<p class="control" style="width: 80px;">
+				<input
+					class="input"
+					type="number"
+					placeholder="Max"
+					v-model="youtubeSearch.playlist.max"
+				/>
+			</p>
 			<p class="control has-addons">
 				<span class="select" id="playlist-import-type">
 					<select

+ 1 - 0
frontend/src/composables/useSearchYoutube.ts

@@ -11,6 +11,7 @@ export const useSearchYoutube = () => {
 		},
 		playlist: {
 			query: "",
+			max: 0,
 			isImportingOnlyMusic: true
 		}
 	});

+ 10 - 0
frontend/src/main.ts

@@ -164,6 +164,16 @@ const router = createRouter({
 					component: () => import("@/pages/Admin/Songs/Import.vue"),
 					meta: { permissionRequired: "admin.view.import" }
 				},
+				{
+					path: "artists",
+					component: () => import("@/pages/Admin/Artists.vue"),
+					meta: { permissionRequired: "admin.view.artists" }
+				},
+				{
+					path: "albums",
+					component: () => import("@/pages/Admin/Albums.vue"),
+					meta: { permissionRequired: "admin.view.albums" }
+				},
 				{
 					path: "reports",
 					component: () => import("@/pages/Admin/Reports.vue"),

+ 230 - 0
frontend/src/pages/Admin/Albums.vue

@@ -0,0 +1,230 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref } from "vue";
+import Toast from "toasters";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+
+const AdvancedTable = defineAsyncComponent(
+	() => import("@/components/AdvancedTable.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+const UserLink = defineAsyncComponent(
+	() => import("@/components/UserLink.vue")
+);
+
+const { socket } = useWebsocketsStore();
+
+const columnDefault = ref<TableColumn>({
+	sortable: true,
+	hidable: true,
+	defaultVisibility: "shown",
+	draggable: true,
+	resizable: true,
+	minWidth: 150,
+	maxWidth: 600
+});
+const columns = ref<TableColumn[]>([
+	{
+		name: "options",
+		displayName: "Options",
+		properties: ["_id"],
+		sortable: false,
+		hidable: false,
+		resizable: false,
+		minWidth: 85,
+		defaultWidth: 85
+	},
+	// {
+	// 	name: "status",
+	// 	displayName: "Status",
+	// 	properties: ["status"],
+	// 	sortProperty: "status",
+	// 	defaultWidth: 150
+	// },
+	// {
+	// 	name: "showToNewUsers",
+	// 	displayName: "Show to new users",
+	// 	properties: ["showToNewUsers"],
+	// 	sortProperty: "showToNewUsers",
+	// 	defaultWidth: 180
+	// },
+	// {
+	// 	name: "title",
+	// 	displayName: "Title",
+	// 	properties: ["title"],
+	// 	sortProperty: "title"
+	// },
+	{
+		name: "createdBy",
+		displayName: "Created By",
+		properties: ["createdBy"],
+		sortProperty: "createdBy",
+		defaultWidth: 150
+	},
+	{
+		name: "markdown",
+		displayName: "Markdown",
+		properties: ["markdown"],
+		sortProperty: "markdown"
+	}
+]);
+const filters = ref<TableFilter[]>([
+	// {
+	// 	name: "status",
+	// 	displayName: "Status",
+	// 	property: "status",
+	// 	filterTypes: ["contains", "exact", "regex"],
+	// 	defaultFilterType: "contains"
+	// },
+	// {
+	// 	name: "showToNewUsers",
+	// 	displayName: "Show to new users",
+	// 	property: "showToNewUsers",
+	// 	filterTypes: ["boolean"],
+	// 	defaultFilterType: "boolean"
+	// },
+	// {
+	// 	name: "title",
+	// 	displayName: "Title",
+	// 	property: "title",
+	// 	filterTypes: ["contains", "exact", "regex"],
+	// 	defaultFilterType: "contains"
+	// },
+	{
+		name: "createdBy",
+		displayName: "Created By",
+		property: "createdBy",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "markdown",
+		displayName: "Markdown",
+		property: "markdown",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	}
+]);
+const events = ref<TableEvents>({
+	adminRoom: "albums",
+	updated: {
+		event: "admin.albums.updated",
+		id: "album._id",
+		item: "album"
+	},
+	removed: {
+		event: "admin.albums.deleted",
+		id: "albumId"
+	}
+});
+
+const { openModal } = useModalsStore();
+
+const { hasPermission } = useUserAuthStore();
+
+const remove = (id: string) => {
+	socket.dispatch(
+		"album.remove",
+		id,
+		(res: GenericResponse) => new Toast(res.message)
+	);
+};
+</script>
+
+<template>
+	<div class="admin-tab container">
+		<page-metadata title="Admin | Albums" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Albums</h1>
+				<p>Create and update albums</p>
+			</div>
+			<div class="button-row">
+				<button
+					v-if="hasPermission('albums.create')"
+					class="is-primary button"
+					@click="
+						openModal({
+							modal: 'editAlbum',
+							props: { createAlbum: true }
+						})
+					"
+				>
+					Create Album
+				</button>
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="albums.getData"
+			name="admin-albums"
+			:max-width="1200"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						v-if="hasPermission('albums.update')"
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'editNews',
+								props: { albumId: slotProps.item._id }
+							})
+						"
+						content="Edit Album"
+						v-tippy
+					>
+						edit
+					</button>
+					<quick-confirm
+						v-if="hasPermission('album.remove')"
+						@confirm="remove(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+					>
+						<button
+							class="button is-danger icon-with-button material-icons"
+							content="Remove Album"
+							v-tippy
+						>
+							delete_forever
+						</button>
+					</quick-confirm>
+				</div>
+			</template>
+			<!-- <template #column-status="slotProps">
+				<span :title="slotProps.item.status">{{
+					slotProps.item.status
+				}}</span>
+			</template>
+			<template #column-showToNewUsers="slotProps">
+				<span :title="slotProps.item.showToNewUsers">{{
+					slotProps.item.showToNewUsers
+				}}</span>
+			</template>
+			<template #column-title="slotProps">
+				<span :title="slotProps.item.title">{{
+					slotProps.item.title
+				}}</span>
+			</template> -->
+			<template #column-createdBy="slotProps">
+				<user-link
+					:user-id="slotProps.item.createdBy"
+					:alt="slotProps.item.createdBy"
+				/>
+			</template>
+			<template #column-markdown="slotProps">
+				<span :title="slotProps.item.markdown">{{
+					slotProps.item.markdown
+				}}</span>
+			</template>
+		</advanced-table>
+	</div>
+</template>

+ 212 - 0
frontend/src/pages/Admin/Artists.vue

@@ -0,0 +1,212 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref } from "vue";
+import Toast from "toasters";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+import utils from "@/utils";
+
+const AdvancedTable = defineAsyncComponent(
+	() => import("@/components/AdvancedTable.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+const UserLink = defineAsyncComponent(
+	() => import("@/components/UserLink.vue")
+);
+
+const { socket } = useWebsocketsStore();
+
+const columnDefault = ref<TableColumn>({
+	sortable: true,
+	hidable: true,
+	defaultVisibility: "shown",
+	draggable: true,
+	resizable: true,
+	minWidth: 150,
+	maxWidth: 600
+});
+const columns = ref<TableColumn[]>([
+	{
+		name: "options",
+		displayName: "Options",
+		properties: ["_id"],
+		sortable: false,
+		hidable: false,
+		resizable: false,
+		minWidth: 85,
+		defaultWidth: 85
+	},
+	{
+		name: "name",
+		displayName: "Name",
+		properties: ["name"],
+		sortProperty: "name"
+	},
+	{
+		name: "musicbrainzIdentifier",
+		displayName: "MusicBrainz identifier",
+		properties: ["musicbrainzIdentifier"],
+		sortProperty: "musicbrainzIdentifier"
+	},
+	{
+		name: "createdBy",
+		displayName: "Created By",
+		properties: ["createdBy"],
+		sortProperty: "createdBy",
+		defaultWidth: 150
+	},
+	{
+		name: "createdAt",
+		displayName: "Created At",
+		properties: ["createdAt"],
+		sortProperty: "createdAt",
+		defaultWidth: 150
+	},
+]);
+const filters = ref<TableFilter[]>([
+	{
+		name: "name",
+		displayName: "Name",
+		property: "name",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "musicbrainzIdentifier",
+		displayName: "MusicBrainz identifier",
+		property: "musicbrainzIdentifier",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "createdBy",
+		displayName: "Created By",
+		property: "createdBy",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "createdAt",
+		displayName: "Created At",
+		property: "createdAt",
+		filterTypes: ["datetimeBefore", "datetimeAfter"],
+		defaultFilterType: "datetimeBefore"
+	},
+]);
+const events = ref<TableEvents>({
+	adminRoom: "artists",
+	updated: {
+		event: "admin.artists.updated",
+		id: "artist._id",
+		item: "artist"
+	},
+	removed: {
+		event: "admin.artists.deleted",
+		id: "artistId"
+	}
+});
+
+const { openModal } = useModalsStore();
+
+const { hasPermission } = useUserAuthStore();
+
+const remove = (id: string) => {
+	socket.dispatch(
+		"artist.remove",
+		id,
+		(res: GenericResponse) => new Toast(res.message)
+	);
+};
+</script>
+
+<template>
+	<div class="admin-tab container">
+		<page-metadata title="Admin | Artists" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Artists</h1>
+				<p>Create and update artists</p>
+			</div>
+			<div class="button-row">
+				<button
+					v-if="hasPermission('artists.create')"
+					class="is-primary button"
+					@click="
+						openModal({
+							modal: 'editArtist',
+							props: { createArtist: true }
+						})
+					"
+				>
+					Create Artist
+				</button>
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="artists.getData"
+			name="admin-artists"
+			:max-width="1200"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						v-if="hasPermission('artists.update')"
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'editArtist',
+								props: { artistId: slotProps.item._id }
+							})
+						"
+						content="Edit Artist"
+						v-tippy
+					>
+						edit
+					</button>
+					<quick-confirm
+						v-if="hasPermission('artist.remove')"
+						@confirm="remove(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+					>
+						<button
+							class="button is-danger icon-with-button material-icons"
+							content="Remove Artist"
+							v-tippy
+						>
+							delete_forever
+						</button>
+					</quick-confirm>
+				</div>
+			</template>
+			<template #column-name="slotProps">
+				<span :title="slotProps.item.name">{{
+					slotProps.item.name
+				}}</span>
+			</template>
+			<template #column-musicbrainzIdentifier="slotProps">
+				<span :title="slotProps.item.musicbrainzIdentifier">{{
+					slotProps.item.musicbrainzIdentifier
+				}}</span>
+			</template>
+			<template #column-createdBy="slotProps">
+				<user-link
+					:user-id="slotProps.item.createdBy"
+					:alt="slotProps.item.createdBy"
+				/>
+			</template>
+			<template #column-createdAt="slotProps">
+				<span :title="new Date(slotProps.item.createdAt).toString()">{{
+					utils.getDateFormatted(slotProps.item.createdAt)
+				}}</span>
+			</template>
+		</advanced-table>
+	</div>
+</template>

+ 18 - 1
frontend/src/pages/Admin/Songs/Import.vue

@@ -29,6 +29,7 @@ const createImport = ref({
 	stage: 2,
 	importMethod: "youtube",
 	youtubeUrl: "",
+	max: 0,
 	isImportingOnlyMusic: false
 });
 const columnDefault = ref<TableColumn>({
@@ -295,6 +296,7 @@ const importFromYoutube = () => {
 		"youtube.requestSetAdmin",
 		createImport.value.youtubeUrl,
 		createImport.value.isImportingOnlyMusic,
+		createImport.value.max,
 		true,
 		{
 			cb: () => {},
@@ -434,7 +436,14 @@ const removeImportJob = jobId => {
 								v-model="createImport.youtubeUrl"
 							/>
 						</div>
-
+						<div class="control is-expanded">
+							<input
+								class="input"
+								type="number"
+								placeholder="Max"
+								v-model="createImport.max"
+							/>
+						</div>
 						<div class="control is-expanded checkbox-control">
 							<label class="switch">
 								<input
@@ -453,6 +462,14 @@ const removeImportJob = jobId => {
 								/>
 							</label>
 						</div>
+						<!-- <div class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Comment"
+								v-model="createImport.comment"
+							/>
+						</div> -->
 
 						<div class="control is-expanded">
 							<button

+ 26 - 0
frontend/src/pages/Admin/index.vue

@@ -243,6 +243,32 @@ onBeforeUnmount(() => {
 								<i class="material-icons">music_note</i>
 								<span>Songs</span>
 							</router-link>
+							<router-link
+								v-if="hasPermission('admin.view.artists')"
+								class="sidebar-item artists"
+								to="/admin/artists"
+								content="Artists"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">chrome_reader_mode</i>
+								<span>Artists</span>
+							</router-link>
+							<router-link
+								v-if="hasPermission('admin.view.albums')"
+								class="sidebar-item albums"
+								to="/admin/albums"
+								content="Albums"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">chrome_reader_mode</i>
+								<span>Albums</span>
+							</router-link>
 							<router-link
 								v-if="hasPermission('admin.view.reports')"
 								class="sidebar-item reports"