Browse Source

feat: worked more on SoundCloud support

Kristian Vos 2 years ago
parent
commit
c7b3ad988a

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

@@ -213,6 +213,8 @@ export default {
 			page === "punishments" ||
 			page === "youtube" ||
 			page === "youtubeVideos" ||
+			page === "soundcloud" ||
+			page === "soundcloudTracks" ||
 			page === "import" ||
 			page === "dataRequests"
 		) {

+ 2 - 0
backend/logic/actions/index.js

@@ -10,6 +10,7 @@ import news from "./news";
 import punishments from "./punishments";
 import utils from "./utils";
 import youtube from "./youtube";
+import soundcloud from "./soundcloud";
 import media from "./media";
 
 export default {
@@ -25,5 +26,6 @@ export default {
 	punishments,
 	utils,
 	youtube,
+	soundcloud,
 	media
 };

+ 587 - 0
backend/logic/actions/soundcloud.js

@@ -0,0 +1,587 @@
+import mongoose from "mongoose";
+import async from "async";
+
+import isLoginRequired from "../hooks/loginRequired";
+import { useHasPermission } from "../hooks/hasPermission";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+const YouTubeModule = moduleManager.modules.youtube;
+const MediaModule = moduleManager.modules.media;
+
+export default {
+	// /**
+	//  * Returns details about the YouTube quota usage
+	//  *
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// getQuotaStatus: useHasPermission("admin.view.youtube", function getQuotaStatus(session, fromDate, cb) {
+	// 	YouTubeModule.runJob("GET_QUOTA_STATUS", { fromDate }, this)
+	// 		.then(response => {
+	// 			this.log("SUCCESS", "YOUTUBE_GET_QUOTA_STATUS", `Getting quota status was successful.`);
+	// 			return cb({ status: "success", data: { status: response.status } });
+	// 		})
+	// 		.catch(async err => {
+	// 			err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 			this.log("ERROR", "YOUTUBE_GET_QUOTA_STATUS", `Getting quota status failed. "${err}"`);
+	// 			return cb({ status: "error", message: err });
+	// 		});
+	// }),
+
+	// /**
+	//  * Returns YouTube quota chart data
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param timePeriod - either hours or days
+	//  * @param startDate - beginning date
+	//  * @param endDate - end date
+	//  * @param dataType - either usage or count
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// getQuotaChartData: useHasPermission(
+	// 	"admin.view.youtube",
+	// 	function getQuotaChartData(session, timePeriod, startDate, endDate, dataType, cb) {
+	// 		YouTubeModule.runJob(
+	// 			"GET_QUOTA_CHART_DATA",
+	// 			{ timePeriod, startDate: new Date(startDate), endDate: new Date(endDate), dataType },
+	// 			this
+	// 		)
+	// 			.then(data => {
+	// 				this.log("SUCCESS", "YOUTUBE_GET_QUOTA_CHART_DATA", `Getting quota chart data was successful.`);
+	// 				return cb({ status: "success", data });
+	// 			})
+	// 			.catch(async err => {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log("ERROR", "YOUTUBE_GET_QUOTA_CHART_DATA", `Getting quota chart data failed. "${err}"`);
+	// 				return cb({ status: "error", message: err });
+	// 			});
+	// 	}
+	// ),
+
+	// /**
+	//  * Gets api requests, used in the admin youtube 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 news item
+	//  * @param sort - the sort object
+	//  * @param queries - the queries array
+	//  * @param operator - the operator for queries
+	//  * @param cb
+	//  */
+	// getApiRequests: useHasPermission(
+	// 	"admin.view.youtube",
+	// 	async function getApiRequests(session, page, pageSize, properties, sort, queries, operator, cb) {
+	// 		async.waterfall(
+	// 			[
+	// 				next => {
+	// 					DBModule.runJob(
+	// 						"GET_DATA",
+	// 						{
+	// 							page,
+	// 							pageSize,
+	// 							properties,
+	// 							sort,
+	// 							queries,
+	// 							operator,
+	// 							modelName: "youtubeApiRequest",
+	// 							blacklistedProperties: [],
+	// 							specialProperties: {},
+	// 							specialQueries: {}
+	// 						},
+	// 						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", "YOUTUBE_GET_API_REQUESTS", `Failed to get YouTube api requests. "${err}"`);
+	// 					return cb({ status: "error", message: err });
+	// 				}
+	// 				this.log("SUCCESS", "YOUTUBE_GET_API_REQUESTS", `Fetched YouTube api requests successfully.`);
+	// 				return cb({
+	// 					status: "success",
+	// 					message: "Successfully fetched YouTube api requests.",
+	// 					data: response
+	// 				});
+	// 			}
+	// 		);
+	// 	}
+	// ),
+
+	// /**
+	//  * Returns a specific api request
+	//  *
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// getApiRequest: useHasPermission("youtube.getApiRequest", function getApiRequest(session, apiRequestId, cb) {
+	// 	if (!mongoose.Types.ObjectId.isValid(apiRequestId))
+	// 		return cb({ status: "error", message: "Api request id is not a valid ObjectId." });
+
+	// 	return YouTubeModule.runJob("GET_API_REQUEST", { apiRequestId }, this)
+	// 		.then(response => {
+	// 			this.log(
+	// 				"SUCCESS",
+	// 				"YOUTUBE_GET_API_REQUEST",
+	// 				`Getting api request with id ${apiRequestId} was successful.`
+	// 			);
+	// 			return cb({ status: "success", data: { apiRequest: response.apiRequest } });
+	// 		})
+	// 		.catch(async err => {
+	// 			err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 			this.log(
+	// 				"ERROR",
+	// 				"YOUTUBE_GET_API_REQUEST",
+	// 				`Getting api request with id ${apiRequestId} failed. "${err}"`
+	// 			);
+	// 			return cb({ status: "error", message: err });
+	// 		});
+	// }),
+
+	// /**
+	//  * Reset stored API requests
+	//  *
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// resetStoredApiRequests: useHasPermission(
+	// 	"youtube.resetStoredApiRequests",
+	// 	async function resetStoredApiRequests(session, cb) {
+	// 		this.keepLongJob();
+	// 		this.publishProgress({
+	// 			status: "started",
+	// 			title: "Reset stored API requests",
+	// 			message: "Resetting stored API requests.",
+	// 			id: this.toString()
+	// 		});
+	// 		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+	// 		await CacheModule.runJob(
+	// 			"PUB",
+	// 			{
+	// 				channel: "longJob.added",
+	// 				value: { jobId: this.toString(), userId: session.userId }
+	// 			},
+	// 			this
+	// 		);
+
+	// 		YouTubeModule.runJob("RESET_STORED_API_REQUESTS", {}, this)
+	// 			.then(() => {
+	// 				this.log(
+	// 					"SUCCESS",
+	// 					"YOUTUBE_RESET_STORED_API_REQUESTS",
+	// 					`Resetting stored API requests was successful.`
+	// 				);
+	// 				this.publishProgress({
+	// 					status: "success",
+	// 					message: "Successfully reset stored YouTube API requests."
+	// 				});
+	// 				return cb({ status: "success", message: "Successfully reset stored YouTube API requests" });
+	// 			})
+	// 			.catch(async err => {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"YOUTUBE_RESET_STORED_API_REQUESTS",
+	// 					`Resetting stored API requests failed. "${err}"`
+	// 				);
+	// 				this.publishProgress({
+	// 					status: "error",
+	// 					message: err
+	// 				});
+	// 				return cb({ status: "error", message: err });
+	// 			});
+	// 	}
+	// ),
+
+	// /**
+	//  * Remove stored API requests
+	//  *
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// removeStoredApiRequest: useHasPermission(
+	// 	"youtube.removeStoredApiRequest",
+	// 	function removeStoredApiRequest(session, requestId, cb) {
+	// 		YouTubeModule.runJob("REMOVE_STORED_API_REQUEST", { requestId }, this)
+	// 			.then(() => {
+	// 				this.log(
+	// 					"SUCCESS",
+	// 					"YOUTUBE_REMOVE_STORED_API_REQUEST",
+	// 					`Removing stored API request "${requestId}" was successful.`
+	// 				);
+
+	// 				return cb({ status: "success", message: "Successfully removed stored YouTube API request" });
+	// 			})
+	// 			.catch(async err => {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 				this.log(
+	// 					"ERROR",
+	// 					"YOUTUBE_REMOVE_STORED_API_REQUEST",
+	// 					`Removing stored API request "${requestId}" failed. "${err}"`
+	// 				);
+	// 				return cb({ status: "error", message: err });
+	// 			});
+	// 	}
+	// ),
+
+	/**
+	 * Gets videos, used in the admin youtube 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 news item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getTracks: useHasPermission(
+		"admin.view.soundcloudTracks",
+		async function getTracks(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "soundcloudTrack",
+								blacklistedProperties: [],
+								specialProperties: {
+									songId: [
+										// Fetch songs from songs collection with a matching mediaSource
+										{
+											$lookup: {
+												from: "songs", // TODO fix this to support mediasource, so start with youtube:, so add a new pipeline steps
+												localField: "trackId",
+												foreignField: "trackId",
+												as: "song"
+											}
+										},
+										// Turn the array of songs returned in the last step into one object, since only one song should have been returned maximum
+										{
+											$unwind: {
+												path: "$song",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										// Add new field songId, which grabs the song object's _id and tries turning it into a string
+										{
+											$addFields: {
+												songId: {
+													$convert: {
+														input: "$song._id",
+														to: "string",
+														onError: "",
+														onNull: ""
+													}
+												}
+											}
+										},
+										// Cleanup, don't return the song object for any further steps
+										{
+											$project: {
+												song: 0
+											}
+										}
+									]
+								},
+								specialQueries: {},
+								specialFilters: {
+									// importJob: importJobId => [
+									// 	{
+									// 		$lookup: {
+									// 			from: "importjobs",
+									// 			let: { trackId: "$trackId" },
+									// 			pipeline: [
+									// 				{
+									// 					$match: {
+									// 						_id: mongoose.Types.ObjectId(importJobId)
+									// 					}
+									// 				},
+									// 				{
+									// 					$addFields: {
+									// 						importJob: {
+									// 							$in: ["$$trackId", "$response.successfulVideoIds"]
+									// 						}
+									// 					}
+									// 				},
+									// 				{
+									// 					$project: {
+									// 						importJob: 1,
+									// 						_id: 0
+									// 					}
+									// 				}
+									// 			],
+									// 			as: "importJob"
+									// 		}
+									// 	},
+									// 	{
+									// 		$unwind: "$importJob"
+									// 	},
+									// 	{
+									// 		$set: {
+									// 			importJob: "$importJob.importJob"
+									// 		}
+									// 	}
+									// ]
+								}
+							},
+							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", "SOUNDCLOUD_GET_VIDEOS", `Failed to get SoundCloud tracks. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "SOUNDCLOUD_GET_VIDEOS", `Fetched SoundCloud tracks successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched SoundCloud tracks.",
+						data: response
+					});
+				}
+			);
+		}
+	)
+
+	// /**
+	//  * Get a YouTube video
+	//  *
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// getVideo: isLoginRequired(function getVideo(session, identifier, createMissing, cb) {
+	// 	return YouTubeModule.runJob("GET_VIDEO", { identifier, createMissing }, this)
+	// 		.then(res => {
+	// 			this.log("SUCCESS", "YOUTUBE_GET_VIDEO", `Fetching video was successful.`);
+
+	// 			return cb({ status: "success", message: "Successfully fetched YouTube video", data: res.video });
+	// 		})
+	// 		.catch(async err => {
+	// 			err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 			this.log("ERROR", "YOUTUBE_GET_VIDEO", `Fetching video failed. "${err}"`);
+	// 			return cb({ status: "error", message: err });
+	// 		});
+	// }),
+
+	// /**
+	//  * Remove YouTube videos
+	//  *
+	//  * @returns {{status: string, data: object}}
+	//  */
+	// removeVideos: useHasPermission("youtube.removeVideos", async function removeVideos(session, videoIds, cb) {
+	// 	this.keepLongJob();
+	// 	this.publishProgress({
+	// 		status: "started",
+	// 		title: "Bulk remove YouTube videos",
+	// 		message: "Bulk removing YouTube videos.",
+	// 		id: this.toString()
+	// 	});
+	// 	await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+	// 	await CacheModule.runJob(
+	// 		"PUB",
+	// 		{
+	// 			channel: "longJob.added",
+	// 			value: { jobId: this.toString(), userId: session.userId }
+	// 		},
+	// 		this
+	// 	);
+
+	// 	YouTubeModule.runJob("REMOVE_VIDEOS", { videoIds }, this)
+	// 		.then(() => {
+	// 			this.log("SUCCESS", "YOUTUBE_REMOVE_VIDEOS", `Removing videos was successful.`);
+	// 			this.publishProgress({
+	// 				status: "success",
+	// 				message: "Successfully removed YouTube videos."
+	// 			});
+	// 			return cb({ status: "success", message: "Successfully removed YouTube videos" });
+	// 		})
+	// 		.catch(async err => {
+	// 			err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 			this.log("ERROR", "YOUTUBE_REMOVE_VIDEOS", `Removing videos failed. "${err}"`);
+	// 			this.publishProgress({
+	// 				status: "error",
+	// 				message: err
+	// 			});
+	// 			return cb({ status: "error", message: err });
+	// 		});
+	// }),
+
+	// /**
+	//  * Requests a set of YouTube videos
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} url - the url of the the YouTube playlist
+	//  * @param {boolean} musicOnly - whether to only get music from the playlist
+	//  * @param {boolean} musicOnly - whether to return videos
+	//  * @param {Function} cb - gets called with the result
+	//  */
+	// requestSet: isLoginRequired(function requestSet(session, url, musicOnly, returnVideos, cb) {
+	// 	YouTubeModule.runJob("REQUEST_SET", { url, musicOnly, returnVideos }, this)
+	// 		.then(response => {
+	// 			this.log(
+	// 				"SUCCESS",
+	// 				"REQUEST_SET",
+	// 				`Successfully imported a YouTube playlist to be requested for user "${session.userId}".`
+	// 			);
+	// 			return cb({
+	// 				status: "success",
+	// 				message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+	// 				videos: returnVideos ? response.videos : null
+	// 			});
+	// 		})
+	// 		.catch(async err => {
+	// 			err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 			this.log(
+	// 				"ERROR",
+	// 				"REQUEST_SET",
+	// 				`Importing a YouTube playlist to be requested failed for user "${session.userId}". "${err}"`
+	// 			);
+	// 			return cb({ status: "error", message: err });
+	// 		});
+	// }),
+
+	// /**
+	//  * Requests a set of YouTube videos as an admin
+	//  *
+	//  * @param {object} session - the session object automatically added by the websocket
+	//  * @param {string} url - the url of the the YouTube playlist
+	//  * @param {boolean} musicOnly - whether to only get music from the playlist
+	//  * @param {boolean} musicOnly - whether to return videos
+	//  * @param {Function} cb - gets called with the result
+	//  */
+	// requestSetAdmin: useHasPermission(
+	// 	"youtube.requestSetAdmin",
+	// 	async function requestSetAdmin(session, url, musicOnly, returnVideos, cb) {
+	// 		const importJobModel = await DBModule.runJob("GET_MODEL", { modelName: "importJob" }, this);
+
+	// 		this.keepLongJob();
+	// 		this.publishProgress({
+	// 			status: "started",
+	// 			title: "Import playlist",
+	// 			message: "Importing playlist.",
+	// 			id: this.toString()
+	// 		});
+	// 		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+	// 		await CacheModule.runJob(
+	// 			"PUB",
+	// 			{
+	// 				channel: "longJob.added",
+	// 				value: { jobId: this.toString(), userId: session.userId }
+	// 			},
+	// 			this
+	// 		);
+
+	// 		async.waterfall(
+	// 			[
+	// 				next => {
+	// 					importJobModel.create(
+	// 						{
+	// 							type: "youtube",
+	// 							query: {
+	// 								url,
+	// 								musicOnly
+	// 							},
+	// 							status: "in-progress",
+	// 							response: {},
+	// 							requestedBy: session.userId,
+	// 							requestedAt: Date.now()
+	// 						},
+	// 						next
+	// 					);
+	// 				},
+
+	// 				(importJob, next) => {
+	// 					YouTubeModule.runJob("REQUEST_SET", { url, musicOnly, returnVideos }, this)
+	// 						.then(response => {
+	// 							next(null, importJob, response);
+	// 						})
+	// 						.catch(err => {
+	// 							next(err, importJob);
+	// 						});
+	// 				},
+
+	// 				(importJob, response, next) => {
+	// 					importJobModel.updateOne(
+	// 						{ _id: importJob._id },
+	// 						{
+	// 							$set: {
+	// 								status: "success",
+	// 								response: {
+	// 									failed: response.failed,
+	// 									successful: response.successful,
+	// 									alreadyInDatabase: response.alreadyInDatabase,
+	// 									successfulVideoIds: response.successfulVideoIds,
+	// 									failedVideoIds: response.failedVideoIds
+	// 								}
+	// 							}
+	// 						},
+	// 						err => {
+	// 							if (err) next(err, importJob);
+	// 							else
+	// 								MediaModule.runJob("UPDATE_IMPORT_JOBS", { jobIds: importJob._id })
+	// 									.then(() => next(null, importJob, response))
+	// 									.catch(error => next(error, importJob));
+	// 						}
+	// 					);
+	// 				}
+	// 			],
+	// 			async (err, importJob, response) => {
+	// 				if (err) {
+	// 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	// 					this.log(
+	// 						"ERROR",
+	// 						"REQUEST_SET_ADMIN",
+	// 						`Importing a YouTube playlist to be requested failed for admin "${session.userId}". "${err}"`
+	// 					);
+	// 					importJobModel.updateOne({ _id: importJob._id }, { $set: { status: "error" } });
+	// 					MediaModule.runJob("UPDATE_IMPORT_JOBS", { jobIds: importJob._id });
+	// 					return cb({ status: "error", message: err });
+	// 				}
+
+	// 				this.log(
+	// 					"SUCCESS",
+	// 					"REQUEST_SET_ADMIN",
+	// 					`Successfully imported a YouTube playlist to be requested for admin "${session.userId}".`
+	// 				);
+
+	// 				this.publishProgress({
+	// 					status: "success",
+	// 					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
+	// 				});
+
+	// 				return cb({
+	// 					status: "success",
+	// 					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+	// 					videos: returnVideos ? response.videos : null
+	// 				});
+	// 			}
+	// 		);
+	// 	}
+	// )
+};

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

@@ -36,6 +36,7 @@ permissions.moderator = {
 	"admin.view.stations": true,
 	"admin.view.users": true,
 	"admin.view.youtubeVideos": true,
+	"admin.view.soundcloudTracks": true,
 	"apis.searchDiscogs": true,
 	"news.create": true,
 	"news.update": true,
@@ -71,6 +72,7 @@ permissions.admin = {
 	"admin.view.dataRequests": true,
 	"admin.view.statistics": true,
 	"admin.view.youtube": true,
+	"admin.view.soundcloud": true,
 	"dataRequests.resolve": true,
 	"media.recalculateAllRatings": true,
 	"media.removeImportJobs": true,

+ 14 - 0
frontend/src/main.ts

@@ -231,6 +231,20 @@ const router = createRouter({
 					meta: {
 						permissionRequired: "admin.view.youtubeVideos"
 					}
+				},
+				{
+					path: "soundcloud",
+					component: () =>
+						import("@/pages/Admin/SoundCloud/index.vue"),
+					meta: { permissionRequired: "admin.view.soundcloud" }
+				},
+				{
+					path: "soundcloud/tracks",
+					component: () =>
+						import("@/pages/Admin/SoundCloud/Tracks.vue"),
+					meta: {
+						permissionRequired: "admin.view.soundcloudTracks"
+					}
 				}
 			],
 			meta: {

+ 617 - 0
frontend/src/pages/Admin/SoundCloud/Tracks.vue

@@ -0,0 +1,617 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useModalsStore } from "@/stores/modals";
+import { useUserAuthStore } from "@/stores/userAuth";
+import {
+	TableColumn,
+	TableFilter,
+	TableEvents,
+	TableBulkActions
+} from "@/types/advancedTable";
+import utils from "@/utils";
+
+const AdvancedTable = defineAsyncComponent(
+	() => import("@/components/AdvancedTable.vue")
+);
+const RunJobDropdown = defineAsyncComponent(
+	() => import("@/components/RunJobDropdown.vue")
+);
+const SongThumbnail = defineAsyncComponent(
+	() => import("@/components/SongThumbnail.vue")
+);
+
+const { setJob } = useLongJobsStore();
+
+const { socket } = useWebsocketsStore();
+
+const userAuthStore = useUserAuthStore();
+const { hasPermission } = userAuthStore;
+
+const columnDefault = ref<TableColumn>({
+	sortable: true,
+	hidable: true,
+	defaultVisibility: "shown",
+	draggable: true,
+	resizable: true,
+	minWidth: 200,
+	maxWidth: 600
+});
+const columns = ref<TableColumn[]>([
+	{
+		name: "options",
+		displayName: "Options",
+		properties: ["_id", "trackId", "songId"],
+		sortable: false,
+		hidable: false,
+		resizable: false,
+		minWidth:
+			(hasPermission("songs.create") || hasPermission("songs.update")) &&
+			hasPermission("soundcloud.removeTracks")
+				? 129
+				: 85,
+		defaultWidth:
+			(hasPermission("songs.create") || hasPermission("songs.update")) &&
+			hasPermission("soundcloud.removeTracks")
+				? 129
+				: 85
+	},
+	{
+		name: "thumbnailImage",
+		displayName: "Thumb",
+		properties: ["trackId", "artworkUrl"],
+		sortable: false,
+		minWidth: 75,
+		defaultWidth: 75,
+		maxWidth: 75,
+		resizable: false
+	},
+	{
+		name: "trackId",
+		displayName: "Track ID",
+		properties: ["trackId"],
+		sortProperty: "trackId",
+		minWidth: 120,
+		defaultWidth: 120
+	},
+	{
+		name: "_id",
+		displayName: "Musare Track ID",
+		properties: ["_id"],
+		sortProperty: "_id",
+		minWidth: 215,
+		defaultWidth: 215
+	},
+	{
+		name: "title",
+		displayName: "Title",
+		properties: ["title"],
+		sortProperty: "title"
+	},
+	{
+		name: "username",
+		displayName: "Username",
+		properties: ["username"],
+		sortProperty: "username"
+	},
+	{
+		name: "duration",
+		displayName: "Duration",
+		properties: ["duration"],
+		sortProperty: "duration",
+		defaultWidth: 200
+	},
+	{
+		name: "createdAt",
+		displayName: "Created At",
+		properties: ["createdAt"],
+		sortProperty: "createdAt",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "songId",
+		displayName: "Song ID",
+		properties: ["songId"],
+		sortProperty: "songId",
+		defaultWidth: 220,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "genre",
+		displayName: "Genre",
+		properties: ["genre"],
+		sortProperty: "genre",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "license",
+		displayName: "License",
+		properties: ["license"],
+		sortProperty: "license",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "likesCount",
+		displayName: "Likes count",
+		properties: ["likesCount"],
+		sortProperty: "likesCount",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "playbackCount",
+		displayName: "Playback count",
+		properties: ["playbackCount"],
+		sortProperty: "playbackCount",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "public",
+		displayName: "Public",
+		properties: ["public"],
+		sortProperty: "public",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "tagList",
+		displayName: "Tag list",
+		properties: ["tagList"],
+		sortProperty: "tagList",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "soundcloudCreatedAt",
+		displayName: "Soundcloud Created At",
+		properties: ["soundcloudCreatedAt"],
+		sortProperty: "soundcloudCreatedAt",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	}
+]);
+const filters = ref<TableFilter[]>([
+	{
+		name: "_id",
+		displayName: "Musare Track ID",
+		property: "_id",
+		filterTypes: ["exact"],
+		defaultFilterType: "exact"
+	},
+	{
+		name: "trackId",
+		displayName: "SoundCloud ID",
+		property: "trackId",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "title",
+		displayName: "Title",
+		property: "title",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "username",
+		displayName: "Username",
+		property: "username",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "duration",
+		displayName: "Duration",
+		property: "duration",
+		filterTypes: [
+			"numberLesserEqual",
+			"numberLesser",
+			"numberGreater",
+			"numberGreaterEqual",
+			"numberEquals"
+		],
+		defaultFilterType: "numberLesser"
+	},
+	{
+		name: "createdAt",
+		displayName: "Created At",
+		property: "createdAt",
+		filterTypes: ["datetimeBefore", "datetimeAfter"],
+		defaultFilterType: "datetimeBefore"
+	},
+	// {
+	// 	name: "importJob",
+	// 	displayName: "Import Job",
+	// 	property: "importJob",
+	// 	filterTypes: ["special"],
+	// 	defaultFilterType: "special"
+	// },
+	{
+		name: "genre",
+		displayName: "Genre",
+		property: "genre",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "license",
+		displayName: "License",
+		property: "license",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "likesCount",
+		displayName: "Likes count",
+		property: "likesCount",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "playbackCount",
+		displayName: "Playback count",
+		property: "playbackCount",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "public",
+		displayName: "Public",
+		property: "public",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "tagList",
+		displayName: "Tag list",
+		property: "tagList",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "songId",
+		displayName: "Song ID",
+		property: "songId",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "soundcloudCreatedAt",
+		displayName: "Soundcloud Created At",
+		property: "soundcloudCreatedAt",
+		filterTypes: ["datetimeBefore", "datetimeAfter"],
+		defaultFilterType: "datetimeBefore"
+	}
+]);
+const events = ref<TableEvents>({
+	adminRoom: "soundcloudTracks",
+	updated: {
+		event: "admin.soundcloudTrack.updated",
+		id: "soundcloudTrack._id",
+		item: "soundcloudTrack"
+	},
+	removed: {
+		event: "admin.soundcloudTrack.removed",
+		id: "trackId"
+	}
+});
+const bulkActions = ref<TableBulkActions>({ width: 200 });
+const jobs = ref([]);
+if (hasPermission("media.recalculateAllRatings"))
+	jobs.value.push({
+		name: "Recalculate all ratings",
+		socket: "media.recalculateAllRatings"
+	});
+
+const { openModal } = useModalsStore();
+
+const rowToSong = row => ({
+	mediaSource: `soundcloud:${row.trackId}`
+});
+
+const editOne = row => {
+	openModal({
+		modal: "editSong",
+		props: { song: rowToSong(row) }
+	});
+};
+
+const editMany = selectedRows => {
+	if (selectedRows.length === 1) editOne(rowToSong(selectedRows[0]));
+	else {
+		const songs = selectedRows.map(rowToSong);
+		openModal({ modal: "editSong", props: { songs } });
+	}
+};
+
+const importAlbum = selectedRows => {
+	const mediaSources = selectedRows.map(
+		({ trackId }) => `soundcloud:${trackId}`
+	);
+	socket.dispatch("songs.getSongsFromMediaSources", mediaSources, res => {
+		if (res.status === "success") {
+			openModal({
+				modal: "importAlbum",
+				props: { songs: res.data.songs }
+			});
+		} else new Toast("Could not get songs.");
+	});
+};
+
+const bulkEditPlaylist = selectedRows => {
+	openModal({
+		modal: "bulkEditPlaylist",
+		props: {
+			mediaSources: selectedRows.map(row => row.trackId)
+		}
+	});
+};
+
+const removeTracks = videoIds => {
+	let id;
+	let title;
+
+	socket.dispatch("soundcloud.removeTracks", videoIds, {
+		cb: () => {},
+		onProgress: res => {
+			if (res.status === "started") {
+				id = res.id;
+				title = res.title;
+			}
+
+			if (id)
+				setJob({
+					id,
+					name: title,
+					...res
+				});
+		}
+	});
+};
+</script>
+
+<template>
+	<div class="admin-tab container">
+		<page-metadata title="Admin | SoundCloud | Tracks" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>SoundCloud Tracks</h1>
+				<p>Manage SoundCloud track cache</p>
+			</div>
+			<div class="button-row">
+				<run-job-dropdown :jobs="jobs" />
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			:events="events"
+			data-action="soundcloud.getTracks"
+			name="admin-soundcloud-tracks"
+			:max-width="1140"
+			:bulk-actions="bulkActions"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'viewSoundcloudTrack',
+								props: {
+									trackId: slotProps.item.trackId
+								}
+							})
+						"
+						:disabled="slotProps.item.removed"
+						content="View Track"
+						v-tippy
+					>
+						open_in_full
+					</button>
+					<button
+						v-if="
+							hasPermission('songs.create') ||
+							hasPermission('songs.update')
+						"
+						class="button is-primary icon-with-button material-icons"
+						@click="editOne(slotProps.item)"
+						:disabled="slotProps.item.removed"
+						:content="
+							!!slotProps.item.songId
+								? 'Edit Song'
+								: 'Create song from track'
+						"
+						v-tippy
+					>
+						music_note
+					</button>
+					<button
+						v-if="hasPermission('soundcloud.removeTracks')"
+						class="button is-danger icon-with-button material-icons"
+						@click.prevent="
+							openModal({
+								modal: 'confirm',
+								props: {
+									message:
+										'Removing this video will remove it from all playlists and cause a ratings recalculation.',
+									onCompleted: () =>
+										removeTracks(slotProps.item._id)
+								}
+							})
+						"
+						:disabled="slotProps.item.removed"
+						content="Delete Video"
+						v-tippy
+					>
+						delete_forever
+					</button>
+				</div>
+			</template>
+			<template #column-thumbnailImage="slotProps">
+				<song-thumbnail
+					class="song-thumbnail"
+					:song="{ thumbnail: slotProps.item.artworkUrl }"
+				/>
+			</template>
+			<template #column-trackId="slotProps">
+				<span :title="slotProps.item.trackId">
+					{{ slotProps.item.trackId }}
+				</span>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
+			<template #column-title="slotProps">
+				<span :title="slotProps.item.title">{{
+					slotProps.item.title
+				}}</span>
+			</template>
+			<template #column-username="slotProps">
+				<span :title="slotProps.item.username">{{
+					slotProps.item.username
+				}}</span>
+			</template>
+			<template #column-duration="slotProps">
+				<span :title="`${slotProps.item.duration}`">{{
+					slotProps.item.duration
+				}}</span>
+			</template>
+			<template #column-createdAt="slotProps">
+				<span :title="new Date(slotProps.item.createdAt).toString()">{{
+					utils.getDateFormatted(slotProps.item.createdAt)
+				}}</span>
+			</template>
+			<template #column-songId="slotProps">
+				<span :title="slotProps.item.songId">{{
+					slotProps.item.songId
+				}}</span>
+			</template>
+			<template #column-genre="slotProps">
+				<span :title="slotProps.item.genre">{{
+					slotProps.item.genre
+				}}</span>
+			</template>
+			<template #column-license="slotProps">
+				<span :title="slotProps.item.license">{{
+					slotProps.item.license
+				}}</span>
+			</template>
+			<template #column-likesCount="slotProps">
+				<span :title="slotProps.item.likesCount">{{
+					slotProps.item.likesCount
+				}}</span>
+			</template>
+			<template #column-playbackCount="slotProps">
+				<span :title="slotProps.item.playbackCount">{{
+					slotProps.item.playbackCount
+				}}</span>
+			</template>
+			<template #column-public="slotProps">
+				<span :title="slotProps.item.public">{{
+					slotProps.item.public
+				}}</span>
+			</template>
+			<template #column-tagList="slotProps">
+				<span :title="slotProps.item.tagList">{{
+					slotProps.item.tagList
+				}}</span>
+			</template>
+			<template #column-soundcloudCreatedAt="slotProps">
+				<span
+					:title="
+						new Date(slotProps.item.soundcloudCreatedAt).toString()
+					"
+					>{{
+						utils.getDateFormatted(
+							slotProps.item.soundcloudCreatedAt
+						)
+					}}</span
+				>
+			</template>
+			<template #bulk-actions="slotProps">
+				<div class="bulk-actions">
+					<i
+						v-if="
+							hasPermission('songs.create') ||
+							hasPermission('songs.update')
+						"
+						class="material-icons create-songs-icon"
+						@click.prevent="editMany(slotProps.item)"
+						content="Create/edit songs from tracks"
+						v-tippy
+						tabindex="0"
+					>
+						music_note
+					</i>
+					<i
+						v-if="
+							hasPermission('songs.create') ||
+							hasPermission('songs.update')
+						"
+						class="material-icons import-album-icon"
+						@click.prevent="importAlbum(slotProps.item)"
+						content="Import album from tracks"
+						v-tippy
+						tabindex="0"
+					>
+						album
+					</i>
+					<i
+						v-if="hasPermission('playlists.songs.add')"
+						class="material-icons playlist-bulk-edit-icon"
+						@click.prevent="bulkEditPlaylist(slotProps.item)"
+						content="Add To Playlist"
+						v-tippy
+						tabindex="0"
+					>
+						playlist_add
+					</i>
+					<i
+						v-if="hasPermission('soundcloud.removeTracks')"
+						class="material-icons delete-icon"
+						@click.prevent="
+							openModal({
+								modal: 'confirm',
+								props: {
+									message:
+										'Removing these tracks will remove them from all playlists and cause a ratings recalculation.',
+									onCompleted: () =>
+										removeTracks(
+											slotProps.item.map(
+												video => video._id
+											)
+										)
+								}
+							})
+						"
+						content="Delete Videos"
+						v-tippy
+						tabindex="0"
+					>
+						delete_forever
+					</i>
+				</div>
+			</template>
+		</advanced-table>
+	</div>
+</template>
+
+<style lang="less" scoped>
+:deep(.song-thumbnail) {
+	width: 50px;
+	height: 50px;
+	min-width: 50px;
+	min-height: 50px;
+	margin: 0 auto;
+}
+</style>

+ 108 - 0
frontend/src/pages/Admin/SoundCloud/index.vue

@@ -0,0 +1,108 @@
+<script setup lang="ts"></script>
+
+<template>
+	<div class="admin-tab container">
+		<page-metadata title="Admin | YouTube" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>SoundCloud API</h1>
+			</div>
+			<div class="button-row">
+				<!-- <run-job-dropdown :jobs="jobs" /> -->
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.night-mode .admin-tab {
+	.table {
+		color: var(--light-grey-2);
+		background-color: var(--dark-grey-3);
+
+		thead tr {
+			background: var(--dark-grey-3);
+			td {
+				color: var(--white);
+			}
+		}
+
+		tbody tr:hover {
+			background-color: var(--dark-grey-4) !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: var(--dark-grey-2) !important;
+		}
+
+		strong {
+			color: var(--light-grey-2);
+		}
+	}
+
+	.card .quotas .card.quota {
+		background-color: var(--dark-grey-2) !important;
+	}
+}
+
+.admin-tab {
+	td {
+		vertical-align: middle;
+	}
+
+	.is-primary:focus {
+		background-color: var(--primary-color) !important;
+	}
+
+	.card {
+		&.charts {
+			flex-direction: row !important;
+
+			.chart {
+				width: 50%;
+			}
+
+			@media screen and (max-width: 1100px) {
+				flex-direction: column !important;
+
+				.chart {
+					width: unset;
+
+					&:not(:first-child) {
+						margin-top: 10px;
+					}
+				}
+			}
+		}
+
+		.quotas {
+			display: flex;
+			flex-direction: row !important;
+			row-gap: 10px;
+			column-gap: 10px;
+
+			.card.quota {
+				background-color: var(--light-grey) !important;
+				padding: 10px !important;
+				flex-basis: 33.33%;
+
+				&:not(:last-child) {
+					margin-right: 10px;
+				}
+
+				h5 {
+					margin-bottom: 5px !important;
+				}
+			}
+
+			@media screen and (max-width: 1100px) {
+				flex-direction: column !important;
+
+				.card.quota {
+					flex-basis: unset;
+				}
+			}
+		}
+	}
+}
+</style>

+ 12 - 8
frontend/src/pages/Admin/YouTube/Videos.vue

@@ -221,25 +221,29 @@ if (hasPermission("media.recalculateAllRatings"))
 
 const { openModal } = useModalsStore();
 
-const editOne = song => {
+const rowToSong = row => ({
+	mediaSource: `youtube:${row.youtubeId}`
+});
+
+const editOne = row => {
 	openModal({
 		modal: "editSong",
-		props: { song }
+		props: { song: rowToSong(row) }
 	});
 };
 
 const editMany = selectedRows => {
-	if (selectedRows.length === 1) editOne(selectedRows[0]);
+	if (selectedRows.length === 1) editOne(rowToSong(selectedRows[0]));
 	else {
-		const songs = selectedRows.map(row => ({
-			youtubeId: row.youtubeId
-		}));
+		const songs = selectedRows.map(rowToSong);
 		openModal({ modal: "editSong", props: { songs } });
 	}
 };
 
 const importAlbum = selectedRows => {
-	const mediaSources = selectedRows.map(({ youtubeId }) => youtubeId);
+	const mediaSources = selectedRows.map(
+		({ youtubeId }) => `youtube:${youtubeId}`
+	);
 	console.log(77988, mediaSources);
 	socket.dispatch("songs.getSongsFromMediaSources", mediaSources, res => {
 		if (res.status === "success") {
@@ -255,7 +259,7 @@ const bulkEditPlaylist = selectedRows => {
 	openModal({
 		modal: "bulkEditPlaylist",
 		props: {
-			mediaSources: selectedRows.map(row => row.youtubeId)
+			mediaSources: selectedRows.map(row => `youtube:${row.youtubeId}`)
 		}
 	});
 };

+ 95 - 1
frontend/src/pages/Admin/index.vue

@@ -39,7 +39,8 @@ const keyboardShortcutsHelper = ref();
 const childrenActive = ref({
 	songs: false,
 	users: false,
-	youtube: false
+	youtube: false,
+	soundcloud: false
 });
 
 const toggleChildren = payload => {
@@ -63,6 +64,8 @@ const onRouteChange = () => {
 		toggleChildren({ child: "users", force: false });
 	} else if (currentTab.value.startsWith("youtube")) {
 		toggleChildren({ child: "youtube", force: false });
+	} else if (currentTab.value.startsWith("soundcloud")) {
+		toggleChildren({ child: "soundcloud", force: false });
 	}
 	currentTab.value = getTabFromPath();
 	// if (this.$refs[`${currentTab.value}-tab`])
@@ -77,6 +80,8 @@ const onRouteChange = () => {
 		toggleChildren({ child: "users", force: true });
 	else if (currentTab.value.startsWith("youtube"))
 		toggleChildren({ child: "youtube", force: true });
+	else if (currentTab.value.startsWith("soundcloud"))
+		toggleChildren({ child: "soundcloud", force: true });
 };
 
 const toggleKeyboardShortcutsHelper = () => {
@@ -465,6 +470,95 @@ onBeforeUnmount(() => {
 								<i class="material-icons">smart_display</i>
 								<span>YouTube</span>
 							</router-link>
+							<div
+								v-if="
+									(hasPermission('admin.view.soundcloud') ||
+										hasPermission(
+											'admin.view.soundcloudTracks'
+										)) &&
+									sidebarActive
+								"
+								class="sidebar-item with-children"
+								:class="{
+									'is-active': childrenActive.soundcloud
+								}"
+							>
+								<span>
+									<router-link
+										:to="`/admin/soundcloud${
+											hasPermission(
+												'admin.view.soundcloud'
+											)
+												? ''
+												: '/videos'
+										}`"
+									>
+										<i class="material-icons">music_note</i>
+										<span>SoundCloud</span>
+									</router-link>
+									<i
+										class="material-icons toggle-sidebar-children"
+										@click="
+											toggleChildren({
+												child: 'soundcloud'
+											})
+										"
+									>
+										{{
+											childrenActive.soundcloud
+												? "expand_less"
+												: "expand_more"
+										}}
+									</i>
+								</span>
+								<div class="sidebar-item-children">
+									<router-link
+										v-if="
+											hasPermission(
+												'admin.view.soundcloud'
+											)
+										"
+										class="sidebar-item-child"
+										to="/admin/soundcloud"
+									>
+										SoundCloud
+									</router-link>
+									<router-link
+										v-if="
+											hasPermission(
+												'admin.view.soundcloudTracks'
+											)
+										"
+										class="sidebar-item-child"
+										to="/admin/soundcloud/tracks"
+									>
+										Tracks
+									</router-link>
+								</div>
+							</div>
+							<router-link
+								v-else-if="
+									(hasPermission('admin.view.soundcloud') ||
+										hasPermission(
+											'admin.view.soundcloudTracks'
+										)) &&
+									!sidebarActive
+								"
+								class="sidebar-item soundcloud"
+								:to="`/admin/soundcloud${
+									hasPermission('admin.view.soundcloud')
+										? ''
+										: '/tracks'
+								}`"
+								content="SoundCloud"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">music_note</i>
+								<span>SoundCloud</span>
+							</router-link>
 						</div>
 					</div>
 				</div>