Browse Source

feat: cache YouTube channels, store YouTube video response data in documents as well, add jobs to change old videos to include new format

Kristian Vos 2 years ago
parent
commit
7c231078bd

+ 98 - 0
backend/logic/actions/youtube.js

@@ -370,6 +370,66 @@ export default {
 		}
 	),
 
+	/**
+	 * Gets channels, 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
+	 */
+	getChannels: useHasPermission(
+		"admin.view.youtubeChannels",
+		async function getChannels(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "youtubeChannel",
+								blacklistedProperties: [],
+								specialProperties: {},
+								specialQueries: {},
+								specialFilters: {}
+							},
+							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_CHANNELS", `Failed to get YouTube channels. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "YOUTUBE_GET_CHANNELS", `Fetched YouTube channels successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched YouTube channels.",
+						data: response
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Get a YouTube video
 	 *
@@ -432,6 +492,44 @@ export default {
 			});
 	}),
 
+	/**
+	 * Gets missing YouTube video's from all playlists, stations and songs
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	getMissingVideos: useHasPermission("youtube.getApiRequest", function getMissingVideos(session, cb) {
+		return YouTubeModule.runJob("GET_MISSING_VIDEOS", {}, this)
+			.then(response => {
+				this.log("SUCCESS", "YOUTUBE_GET_MISSING_VIDEOS", `Getting missing videos was successful.`);
+				console.log("KRIS", response);
+				return cb({ status: "success", data: { ...response } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_MISSING_VIDEOS", `Getting missing videos failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
+	/**
+	 * Gets missing YouTube video's from all playlists, stations and songs
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	updateVideosV1ToV2: useHasPermission("youtube.getApiRequest", function updateVideosV1ToV2(session, cb) {
+		return YouTubeModule.runJob("UPDATE_VIDEOS_V1_TO_V2", {}, this)
+			.then(response => {
+				this.log("SUCCESS", "YOUTUBE_UPDATE_VIDEOS_V1_TO_V2", `Updating v1 videos to v2 was successful.`);
+				console.log("KRIS", response);
+				return cb({ status: "success", data: { ...response } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_UPDATE_VIDEOS_V1_TO_V2", `Updating v1 videos to v2 failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	/**
 	 * Requests a set of YouTube videos
 	 *

+ 31 - 9
backend/logic/db/index.js

@@ -16,7 +16,8 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	station: 10,
 	user: 4,
 	youtubeApiRequest: 1,
-	youtubeVideo: 1,
+	youtubeVideo: [1, 2],
+	youtubeChannel: 1,
 	ratings: 2,
 	importJob: 1,
 	stationHistory: 2,
@@ -81,6 +82,7 @@ class _DBModule extends CoreClass {
 						punishment: {},
 						youtubeApiRequest: {},
 						youtubeVideo: {},
+						youtubeChannel: {},
 						ratings: {},
 						stationHistory: {},
 						soundcloudTrack: {},
@@ -110,6 +112,7 @@ class _DBModule extends CoreClass {
 					await importSchema("punishment");
 					await importSchema("youtubeApiRequest");
 					await importSchema("youtubeVideo");
+					await importSchema("youtubeChannel");
 					await importSchema("ratings");
 					await importSchema("importJob");
 					await importSchema("stationHistory");
@@ -132,6 +135,7 @@ class _DBModule extends CoreClass {
 						punishment: mongoose.model("punishment", this.schemas.punishment),
 						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),
 						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),
+						youtubeChannel: mongoose.model("youtubeChannel", this.schemas.youtubeChannel),
 						ratings: mongoose.model("ratings", this.schemas.ratings),
 						importJob: mongoose.model("importJob", this.schemas.importJob),
 						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),
@@ -290,6 +294,7 @@ class _DBModule extends CoreClass {
 					this.models.user.syncIndexes();
 					this.models.youtubeApiRequest.syncIndexes();
 					this.models.youtubeVideo.syncIndexes();
+					this.models.youtubeChannel.syncIndexes();
 					this.models.ratings.syncIndexes();
 					this.models.importJob.syncIndexes();
 					this.models.stationHistory.syncIndexes();
@@ -325,17 +330,34 @@ class _DBModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			async.each(
 				Object.keys(REQUIRED_DOCUMENT_VERSIONS),
-				(modelName, next) => {
+				async modelName => {
 					const model = DBModule.models[modelName];
 					const requiredDocumentVersion = REQUIRED_DOCUMENT_VERSIONS[modelName];
-					model.countDocuments({ documentVersion: { $ne: requiredDocumentVersion } }, (err, count) => {
-						if (err) next(err);
-						else if (count > 0)
-							next(
-								`Collection "${modelName}" has ${count} documents with a wrong document version. Run migration.`
-							);
-						else next();
+					const count = await model.countDocuments({
+						documentVersion: {
+							$nin: Array.isArray(requiredDocumentVersion)
+								? requiredDocumentVersion
+								: [requiredDocumentVersion]
+						}
 					});
+
+					if (count > 0)
+						throw new Error(
+							`Collection "${modelName}" has ${count} documents with a wrong document version. Run migration.`
+						);
+
+					if (Array.isArray(requiredDocumentVersion)) {
+						const count2 = await model.countDocuments({
+							documentVersion: {
+								$ne: requiredDocumentVersion[requiredDocumentVersion.length - 1]
+							}
+						});
+
+						if (count2 > 0)
+							console.warn(
+								`Collection "${modelName}" has ${count2} documents with a outdated document version. Run steps manually to update these.`
+							);
+					}
 				},
 				err => {
 					if (err) reject(new Error(err));

+ 8 - 0
backend/logic/db/schemas/youtubeChannel.js

@@ -0,0 +1,8 @@
+export default {
+	channelId: { type: String, required: true, index: true, unique: true },
+	title: { type: String, trim: true, required: true },
+	customUrl: { type: String, trim: true, required: true },
+	rawData: { type: Object },
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 3 - 1
backend/logic/db/schemas/youtubeVideo.js

@@ -4,6 +4,8 @@ export default {
 	author: { type: String, trim: true, required: true },
 	duration: { type: Number, required: true },
 	uploadedAt: { type: Date },
+	rawData: { type: Object },
+	updatedAt: { type: Date, default: Date.now, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
-	documentVersion: { type: Number, default: 1, required: true }
+	documentVersion: { type: Number, default: 2, required: true }
 };

+ 9 - 0
backend/logic/playlists.js

@@ -1474,6 +1474,15 @@ class _PlaylistsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets a list of all media sources from playlist songs
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALL_MEDIA_SOURCES() {
+		return PlaylistsModule.playlistModel.distinct("songs.mediaSource");
+	}
 }
 
 export default new _PlaylistsModule();

+ 9 - 0
backend/logic/songs.js

@@ -1357,6 +1357,15 @@ class _SongsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets a list of all media sources
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ALL_MEDIA_SOURCES() {
+		return SongsModule.SongModel.distinct("mediaSource");
+	}
 }
 
 export default new _SongsModule();

+ 139 - 65
backend/logic/youtube.js

@@ -1381,102 +1381,120 @@ class _YouTubeModule extends CoreClass {
 	 * @param {object} payload - an object containing the payload
 	 * @param {string} payload.identifiers - an array of YouTube video ObjectId's or YouTube ID's
 	 * @param {string} payload.createMissing - attempt to fetch and create video's if not in db
+	 * @param {string} payload.replaceExisting - replace existing
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	async GET_VIDEOS(payload) {
-		const { identifiers, createMissing } = payload;
+		const getVideosFromYoutubeIds = async youtubeIds => {
+			const jobsToRun = [];
 
-		console.log(identifiers, createMissing);
+			const chunkSize = 50;
+			while (youtubeIds.length > 0) {
+				const chunkedYoutubeIds = youtubeIds.splice(0, chunkSize);
 
-		const youtubeIds = identifiers.filter(identifier => !mongoose.Types.ObjectId.isValid(identifier));
-		const objectIds = identifiers.filter(identifier => mongoose.Types.ObjectId.isValid(identifier));
+				const params = {
+					part: "snippet,contentDetails,statistics,status",
+					id: chunkedYoutubeIds.join(",")
+				};
 
-		console.log(youtubeIds, objectIds);
+				jobsToRun.push(YouTubeModule.runJob("API_GET_VIDEOS", { params }, this));
+			}
 
-		const existingVideos = (await YouTubeModule.youtubeVideoModel.find({ youtubeId: youtubeIds }))
-			.concat(await YouTubeModule.youtubeVideoModel.find({ _id: objectIds }))
-			.map(video => video._doc);
+			const jobResponses = await Promise.all(jobsToRun);
 
-		console.log(existingVideos);
+			console.log(jobResponses);
 
-		const existingYoutubeIds = existingVideos.map(existingVideo => existingVideo.youtubeId);
-		const existingYoutubeObjectIds = existingVideos.map(existingVideo => existingVideo._id.toString());
+			return jobResponses
+				.map(jobResponse => jobResponse.response.data.items)
+				.flat()
+				.map(item => {
+					// TODO Clean up duration converter
+					let dur = item.contentDetails.duration;
 
-		console.log(existingYoutubeIds, existingYoutubeObjectIds);
+					dur = dur.replace("PT", "");
 
-		if (!createMissing) return { videos: existingVideos };
-		if (identifiers.length === existingVideos.length || youtubeIds.length === 0) return { videos: existingVideos };
+					let duration = 0;
 
-		const missingYoutubeIds = youtubeIds.filter(youtubeId => existingYoutubeIds.indexOf(youtubeId) === -1);
+					dur = dur.replace(/([\d]*)H/, (v, v2) => {
+						v2 = Number(v2);
+						duration = v2 * 60 * 60;
+						return "";
+					});
 
-		console.log(missingYoutubeIds);
+					dur = dur.replace(/([\d]*)M/, (v, v2) => {
+						v2 = Number(v2);
+						duration += v2 * 60;
+						return "";
+					});
 
-		if (missingYoutubeIds.length === 0) return { videos: existingVideos };
+					dur.replace(/([\d]*)S/, (v, v2) => {
+						v2 = Number(v2);
+						duration += v2;
+						return "";
+					});
 
-		const jobsToRun = [];
+					const youtubeVideo = {
+						youtubeId: item.id,
+						title: item.snippet.title,
+						author: item.snippet.channelTitle,
+						thumbnail: item.snippet.thumbnails.default.url,
+						duration,
+						uploadedAt: new Date(item.snippet.publishedAt),
+						rawData: item
+					};
+
+					return youtubeVideo;
+				});
+		};
 
-		const chunkSize = 50;
-		while (missingYoutubeIds.length > 0) {
-			const chunkedMissingYoutubeIds = missingYoutubeIds.splice(0, chunkSize);
+		const { identifiers, createMissing, replaceExisting } = payload;
+		console.log(identifiers, createMissing, replaceExisting);
 
-			const params = {
-				part: "snippet,contentDetails,statistics,status",
-				id: chunkedMissingYoutubeIds.join(",")
-			};
+		const youtubeIds = identifiers.filter(identifier => !mongoose.Types.ObjectId.isValid(identifier));
+		const objectIds = identifiers.filter(identifier => mongoose.Types.ObjectId.isValid(identifier));
+		console.log(youtubeIds, objectIds);
 
-			jobsToRun.push(YouTubeModule.runJob("API_GET_VIDEOS", { params }, this));
-		}
+		const existingVideos = (await YouTubeModule.youtubeVideoModel.find({ youtubeId: youtubeIds }))
+			.concat(await YouTubeModule.youtubeVideoModel.find({ _id: objectIds }))
+			.map(video => video._doc);
+		console.log(existingVideos);
 
-		const jobResponses = await Promise.all(jobsToRun);
+		const existingYoutubeIds = existingVideos.map(existingVideo => existingVideo.youtubeId);
+		const existingYoutubeObjectIds = existingVideos.map(existingVideo => existingVideo._id.toString());
+		console.log(existingYoutubeIds, existingYoutubeObjectIds);
 
-		console.log(jobResponses);
+		if (!replaceExisting) {
+			if (!createMissing) return { videos: existingVideos };
+			if (identifiers.length === existingVideos.length || youtubeIds.length === 0)
+				return { videos: existingVideos };
 
-		const newVideos = jobResponses
-			.map(jobResponse => jobResponse.response.data.items)
-			.flat()
-			.map(item => {
-				// TODO Clean up duration converter
-				let dur = item.contentDetails.duration;
+			const missingYoutubeIds = youtubeIds.filter(youtubeId => existingYoutubeIds.indexOf(youtubeId) === -1);
 
-				dur = dur.replace("PT", "");
+			console.log(missingYoutubeIds);
 
-				let duration = 0;
+			if (missingYoutubeIds.length === 0) return { videos: existingVideos };
 
-				dur = dur.replace(/([\d]*)H/, (v, v2) => {
-					v2 = Number(v2);
-					duration = v2 * 60 * 60;
-					return "";
-				});
+			const newVideos = await getVideosFromYoutubeIds(missingYoutubeIds);
 
-				dur = dur.replace(/([\d]*)M/, (v, v2) => {
-					v2 = Number(v2);
-					duration += v2 * 60;
-					return "";
-				});
+			console.dir(newVideos, { depth: 5 });
 
-				dur.replace(/([\d]*)S/, (v, v2) => {
-					v2 = Number(v2);
-					duration += v2;
-					return "";
-				});
+			await YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: newVideos }, this);
 
-				const youtubeVideo = {
-					youtubeId: item.id,
-					title: item.snippet.title,
-					author: item.snippet.channelTitle,
-					thumbnail: item.snippet.thumbnails.default.url,
-					duration,
-					uploadedAt: new Date(item.snippet.publishedAt)
-				};
+			return { videos: existingVideos.concat(newVideos) };
+		}
 
-				return youtubeVideo;
-			});
+		const newVideos = await getVideosFromYoutubeIds(existingYoutubeIds);
 
-		console.dir(newVideos, { depth: 5 });
+		const promises = newVideos.map(newVideo =>
+			YouTubeModule.youtubeVideoModel.updateOne(
+				{ youtubeId: newVideo.youtubeId },
+				{ $set: { ...newVideo, updatedAt: Date.now(), documentVersion: 2 } }
+			)
+		);
 
-		await YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: newVideos }, this);
+		await Promise.allSettled(promises);
 
-		return { videos: existingVideos.concat(newVideos) };
+		return { videos: newVideos };
 	}
 
 	/**
@@ -1714,6 +1732,62 @@ class _YouTubeModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Gets missing YouTube video's from all playlists, stations and songs
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_MISSING_VIDEOS() {
+		const youtubeIds = Array.from(
+			new Set(
+				[
+					...(await SongsModule.runJob("GET_ALL_MEDIA_SOURCES", {}, this)),
+					...(await PlaylistsModule.runJob("GET_ALL_MEDIA_SOURCES", {}, this))
+				]
+					.filter(mediaSource => mediaSource.startsWith("youtube:"))
+					.map(mediaSource => mediaSource.split(":")[1])
+			)
+		);
+
+		const existingYoutubeIds = await YouTubeModule.youtubeVideoModel.distinct("youtubeId");
+
+		const missingYoutubeIds = youtubeIds.filter(youtubeId => existingYoutubeIds.indexOf(youtubeId) === -1);
+
+		const res = await YouTubeModule.runJob(
+			"GET_VIDEOS",
+			{ identifiers: missingYoutubeIds, createMissing: true },
+			this
+		);
+
+		const gotVideos = res.videos;
+
+		return {
+			all: youtubeIds.length,
+			existing: existingYoutubeIds.length,
+			missing: missingYoutubeIds.length,
+			got: gotVideos.length
+		};
+	}
+
+	/**
+	 * Updates videos from version 1 to version 2
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_VIDEOS_V1_TO_V2() {
+		const videoIds = await YouTubeModule.youtubeVideoModel.distinct("_id", { documentVersion: 1 });
+
+		const res = await YouTubeModule.runJob("GET_VIDEOS", { identifiers: videoIds, replaceExisting: true }, this);
+
+		const v1 = videoIds.length;
+		const v2 = res.videos.length;
+
+		return {
+			v1,
+			v2
+		};
+	}
 }
 
 export default new _YouTubeModule();

+ 8 - 0
frontend/src/main.ts

@@ -232,6 +232,14 @@ const router = createRouter({
 						permissionRequired: "admin.view.youtubeVideos"
 					}
 				},
+				{
+					path: "youtube/channels",
+					component: () =>
+						import("@/pages/Admin/YouTube/Channels.vue"),
+					meta: {
+						permissionRequired: "admin.view.youtubeVideos"
+					}
+				},
 				{
 					path: "soundcloud",
 					component: () =>

+ 430 - 0
frontend/src/pages/Admin/YouTube/Channels.vue

@@ -0,0 +1,430 @@
+<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", "youtubeId", "songId"],
+		sortable: false,
+		hidable: false,
+		resizable: false,
+		minWidth: 85,
+		defaultWidth: 85
+		// 	(hasPermission("songs.create") || hasPermission("songs.update")) &&
+		// 	hasPermission("youtube.removeVideos")
+		// 		? 129
+		// 		: 85,
+		// defaultWidth:
+		// 	(hasPermission("songs.create") || hasPermission("songs.update")) &&
+		// 	hasPermission("youtube.removeVideos")
+		// 		? 129
+		// 		: 85
+	},
+	// {
+	// 	name: "thumbnailImage",
+	// 	displayName: "Thumb",
+	// 	properties: ["youtubeId"],
+	// 	sortable: false,
+	// 	minWidth: 75,
+	// 	defaultWidth: 75,
+	// 	maxWidth: 75,
+	// 	resizable: false
+	// },
+	{
+		name: "channelId",
+		displayName: "Channel ID",
+		properties: ["channelId"],
+		sortProperty: "channelId",
+		minWidth: 120,
+		defaultWidth: 120
+	},
+	{
+		name: "_id",
+		displayName: "Channel OID",
+		properties: ["_id"],
+		sortProperty: "_id",
+		minWidth: 215,
+		defaultWidth: 215
+	},
+	{
+		name: "custom_url",
+		displayName: "Custom URL",
+		properties: ["custom_url"],
+		sortProperty: "custom_url"
+	},
+	{
+		name: "createdAt",
+		displayName: "Created At",
+		properties: ["createdAt"],
+		sortProperty: "createdAt",
+		defaultWidth: 200,
+		defaultVisibility: "hidden"
+	}
+]);
+const filters = ref<TableFilter[]>([
+	{
+		name: "_id",
+		displayName: "Channel OID",
+		property: "_id",
+		filterTypes: ["exact"],
+		defaultFilterType: "exact"
+	},
+	{
+		name: "channelId",
+		displayName: "Channel ID",
+		property: "channelId",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "title",
+		displayName: "Title",
+		property: "title",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "custom_url",
+		displayName: "Custom URL",
+		property: "custom_url",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
+	{
+		name: "createdAt",
+		displayName: "Created At",
+		property: "createdAt",
+		filterTypes: ["datetimeBefore", "datetimeAfter"],
+		defaultFilterType: "datetimeBefore"
+	}
+	// {
+	// 	name: "importJob",
+	// 	displayName: "Import Job",
+	// 	property: "importJob",
+	// 	filterTypes: ["special"],
+	// 	defaultFilterType: "special"
+	// },
+	// {
+	// 	name: "songId",
+	// 	displayName: "Song ID",
+	// 	property: "songId",
+	// 	filterTypes: ["contains", "exact", "regex"],
+	// 	defaultFilterType: "contains"
+	// },
+	// {
+	// 	name: "uploadedAt",
+	// 	displayName: "Uploaded At",
+	// 	property: "uploadedAt",
+	// 	filterTypes: ["datetimeBefore", "datetimeAfter"],
+	// 	defaultFilterType: "datetimeBefore"
+	// }
+]);
+const events = ref<TableEvents>({
+	adminRoom: "youtubeChannels",
+	updated: {
+		event: "admin.youtubeChannel.updated",
+		id: "youtubeChannel._id",
+		item: "youtubeChannel"
+	},
+	removed: {
+		event: "admin.youtubeChannel.removed",
+		id: "channelId"
+	}
+});
+const bulkActions = ref<TableBulkActions>({ width: 200 });
+const jobs = ref([]);
+// if (hasPermission("media.recalculateAllRatings"))
+jobs.value.push({
+	name: "Get missing YouTube channels from YouTube video's",
+	socket: "media.recalculateAllRatings"
+});
+
+const { openModal } = useModalsStore();
+
+// const rowToSong = row => ({
+// 	mediaSource: `youtube:${row.channelId}`
+// });
+
+// 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(
+// 		({ youtubeId }) => `youtube:${youtubeId}`
+// 	);
+// 	console.log(77988, mediaSources);
+// 	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 => `youtube:${row.youtubeId}`)
+// 		}
+// 	});
+// };
+
+// const removeVideos = videoIds => {
+// 	let id;
+// 	let title;
+
+// 	socket.dispatch("youtube.removeVideos", 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 | YouTube | Channels" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>YouTube Channels</h1>
+				<p>Manage YouTube channel 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="youtube.getChannels"
+			name="admin-youtube-channels"
+			: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: 'viewYoutubeChannel',
+								props: {
+									channelId: slotProps.item.channelId
+								}
+							})
+						"
+						:disabled="slotProps.item.removed"
+						content="View Video"
+						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 video'
+						"
+						v-tippy
+					>
+						music_note
+					</button>
+					<button
+						v-if="hasPermission('youtube.removeVideos')"
+						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: () =>
+										removeVideos(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="slotProps.item" />
+			</template> -->
+			<template #column-channelId="slotProps">
+				<a
+					:href="`https://www.youtube.com/${slotProps.item.channelId}`"
+					target="_blank"
+				>
+					{{ slotProps.item.channelId }}
+				</a>
+			</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-createdAt="slotProps">
+				<span :title="new Date(slotProps.item.createdAt).toString()">{{
+					utils.getDateFormatted(slotProps.item.createdAt)
+				}}</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 videos"
+						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 videos"
+						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('youtube.removeVideos')"
+						class="material-icons delete-icon"
+						@click.prevent="
+							openModal({
+								modal: 'confirm',
+								props: {
+									message:
+										'Removing these videos will remove them from all playlists and cause a ratings recalculation.',
+									onCompleted: () =>
+										removeVideos(
+											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>

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

@@ -218,6 +218,14 @@ if (hasPermission("media.recalculateAllRatings"))
 		name: "Recalculate all ratings",
 		socket: "media.recalculateAllRatings"
 	});
+jobs.value.push({
+	name: "Get missing YouTube video's",
+	socket: "youtube.getMissingVideos"
+});
+jobs.value.push({
+	name: "Update V1 video's to V2",
+	socket: "youtube.updateVideosV1ToV2"
+});
 
 const { openModal } = useModalsStore();
 

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

@@ -445,6 +445,17 @@ onBeforeUnmount(() => {
 									>
 										Videos
 									</router-link>
+									<router-link
+										v-if="
+											hasPermission(
+												'admin.view.youtubeVideos'
+											)
+										"
+										class="sidebar-item-child"
+										to="/admin/youtube/channels"
+									>
+										Channels
+									</router-link>
 								</div>
 							</div>
 							<router-link