Browse Source

feat: Started adding separated YouTube video logic and admin page

Owen Diffey 2 years ago
parent
commit
5e49d638ea

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

@@ -196,7 +196,8 @@ export default {
 			page === "users" ||
 			page === "statistics" ||
 			page === "punishments" ||
-			page === "youtube"
+			page === "youtube" ||
+			page === "youtubeVideos"
 		) {
 			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
 				WSModule.runJob("SOCKET_JOIN_ROOM", {

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

@@ -27,6 +27,20 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "youtube.removeVideos",
+	cb: videoIds => {
+		const videos = Array.isArray(videoIds) ? videoIds : [videoIds];
+		videos.forEach(videoId => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.youtubeVideos",
+				args: ["event:admin.youtubeVideo.removed", { data: { videoId } }]
+			});
+		});
+	}
+});
+
 export default {
 	/**
 	 * Returns details about the YouTube quota usage
@@ -236,5 +250,113 @@ export default {
 				);
 				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
+	 */
+	getVideos: isAdminRequired(async function getVideos(
+		session,
+		page,
+		pageSize,
+		properties,
+		sort,
+		queries,
+		operator,
+		cb
+	) {
+		async.waterfall(
+			[
+				next => {
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "youtubeVideo",
+							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_VIDEOS", `Failed to get YouTube videos. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "YOUTUBE_GET_VIDEOS", `Fetched YouTube videos successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched YouTube videos.",
+					data: response
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Get a YouTube video
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	getVideo: isAdminRequired(function getVideo(session, identifier, cb) {
+		YouTubeModule.runJob("GET_VIDEO", { identifier }, 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: isAdminRequired(function removeVideos(session, videoIds, cb) {
+		YouTubeModule.runJob("REMOVE_VIDEOS", { videoIds }, this)
+			.then(() => {
+				this.log("SUCCESS", "YOUTUBE_REMOVE_VIDEOS", `Removing videos was successful.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "youtube.removeVideos",
+					value: videoIds
+				});
+
+				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}"`);
+				return cb({ status: "error", message: err });
+			});
 	})
 };

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

@@ -15,7 +15,8 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	song: 8,
 	station: 8,
 	user: 3,
-	youtubeApiRequest: 1
+	youtubeApiRequest: 1,
+	youtubeVideo: 1
 };
 
 const regex = {
@@ -70,7 +71,8 @@ class _DBModule extends CoreClass {
 						news: {},
 						report: {},
 						punishment: {},
-						youtubeApiRequest: {}
+						youtubeApiRequest: {},
+						youtubeVideo: {}
 					};
 
 					const importSchema = schemaName =>
@@ -92,6 +94,7 @@ class _DBModule extends CoreClass {
 					await importSchema("report");
 					await importSchema("punishment");
 					await importSchema("youtubeApiRequest");
+					await importSchema("youtubeVideo");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -104,7 +107,8 @@ class _DBModule extends CoreClass {
 						news: mongoose.model("news", this.schemas.news),
 						report: mongoose.model("report", this.schemas.report),
 						punishment: mongoose.model("punishment", this.schemas.punishment),
-						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest)
+						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),
+						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -247,6 +251,7 @@ class _DBModule extends CoreClass {
 					this.models.station.syncIndexes();
 					this.models.user.syncIndexes();
 					this.models.youtubeApiRequest.syncIndexes();
+					this.models.youtubeVideo.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

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

@@ -0,0 +1,8 @@
+export default {
+	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
+	title: { type: String, trim: true, required: true },
+	author: { type: String, trim: true, required: true },
+	duration: { type: Number, required: true },
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 9 - 4
backend/logic/songs.js

@@ -1260,8 +1260,14 @@ class _SongsModule extends CoreClass {
 						// TODO Add err object as first param of callback
 
 						return YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
-							.then(response => {
-								const { song } = response;
+							.then(response => next(null, user, response.song))
+							.catch(next);
+					},
+					(user, youtubeVideo, next) =>
+						YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: youtubeVideo }, this)
+							.then(() => {
+								const song = youtubeVideo;
+								delete song.author;
 								song.artists = [];
 								song.genres = [];
 								song.skipDuration = 0;
@@ -1271,8 +1277,7 @@ class _SongsModule extends CoreClass {
 								song.verified = false;
 								next(null, song);
 							})
-							.catch(next);
-					},
+							.catch(next),
 					(newSong, next) => {
 						const song = new SongsModule.SongModel(newSong);
 						song.save({ validateBeforeSave: false }, err => {

+ 93 - 3
backend/logic/youtube.js

@@ -1,5 +1,6 @@
 /* eslint-disable */
 
+import mongoose from "mongoose";
 import async from "async";
 import config from "config";
 
@@ -45,7 +46,7 @@ let DBModule;
 
 const isQuotaExceeded = apiCalls => {
 	const reversedApiCalls = apiCalls.slice().reverse();
-	const quotas = config.get("apis.youtube.quotas");
+	const quotas = config.get("apis.youtube.quotas").slice();
 	const sortedQuotas = quotas.sort((a, b) => a.limit > b.limit);
 
 	let quotaExceeded = false;
@@ -84,7 +85,7 @@ class _YouTubeModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
 		super("youtube", {
-			concurrency: 1,
+			concurrency: 10,
 			priorities: {
 				GET_PLAYLIST: 11
 			}
@@ -107,6 +108,10 @@ class _YouTubeModule extends CoreClass {
 				modelName: "youtubeApiRequest"
 			});
 
+			this.youtubeVideoModel = this.YoutubeVideoModel = await DBModule.runJob("GET_MODEL", {
+				modelName: "youtubeVideo"
+			});
+
 			this.rateLimiter = new RateLimitter(config.get("apis.youtube.rateLimit"));
 			this.requestTimeout = config.get("apis.youtube.requestTimeout");
 
@@ -180,7 +185,7 @@ class _YouTubeModule extends CoreClass {
 					if (err) reject(new Error("Couldn't load YouTube API requests."));
 					else {
 						const reversedApiCalls = youtubeApiRequests.slice().reverse();
-						const quotas = config.get("apis.youtube.quotas");
+						const quotas = config.get("apis.youtube.quotas").slice();
 						const sortedQuotas = quotas.sort((a, b) => a.limit > b.limit);
 						const status = {};
 
@@ -269,6 +274,7 @@ class _YouTubeModule extends CoreClass {
 					const song = {
 						youtubeId: data.items[0].id,
 						title: data.items[0].snippet.title,
+						author: data.items[0].snippet.channelTitle,
 						thumbnail: data.items[0].snippet.thumbnails.default.url,
 						duration
 					};
@@ -1025,6 +1031,90 @@ class _YouTubeModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Create YouTube videos
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.youtubeVideos - the youtubeVideo object or array of
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CREATE_VIDEOS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						let youtubeVideos = payload.youtubeVideos;
+						if (typeof youtubeVideos !== "object") next("Invalid youtubeVideos type");
+						else {
+							if (!Array.isArray(youtubeVideos)) youtubeVideos = [youtubeVideos];
+							YouTubeModule.youtubeVideoModel.insertMany(youtubeVideos, next);
+						}
+					}
+				],
+				(err, youtubeVideos) => {
+					if (err) reject(new Error(err));
+					else resolve({ youtubeVideos });
+				}
+			)
+		});
+	}
+
+	/**
+	 * Get YouTube video
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.identifier - the youtube video ObjectId or YouTube ID
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	 GET_VIDEO(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const query = mongoose.Types.ObjectId.isValid(payload.identifier) ?
+							{ _id: payload.identifier } :
+							{ youtubeId: payload.identifier };
+
+						return YouTubeModule.youtubeVideoModel.findOne(query, next);
+					}
+				],
+				(err, video) => {
+					if (err) reject(new Error(err));
+					else resolve({ video });
+				}
+			)
+		});
+	}
+
+	/**
+	 * Remove YouTube videos
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.videoIds - Array of youtubeVideo ObjectIds
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	 REMOVE_VIDEOS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						let videoIds = payload.videoIds;
+						if (!Array.isArray(videoIds)) videoIds = [videoIds];
+						if (!videoIds.every(videoId => mongoose.Types.ObjectId.isValid(videoId)))
+							next("One or more videoIds are not a valid ObjectId.");
+						else {
+							YouTubeModule.youtubeVideoModel.deleteMany({_id: { $in: videoIds }}, next);
+						}
+					}
+				],
+				err => {
+					if (err) reject(new Error(err));
+					else resolve();
+				}
+			)
+		});
+	}
 }
 
 export default new _YouTubeModule();

+ 5 - 1
frontend/src/main.js

@@ -209,7 +209,11 @@ const router = createRouter({
 				},
 				{
 					path: "youtube",
-					component: () => import("@/pages/Admin/YouTube.vue")
+					component: () => import("@/pages/Admin/YouTube/index.vue")
+				},
+				{
+					path: "youtube/videos",
+					component: () => import("@/pages/Admin/YouTube/Videos.vue")
 				}
 			],
 			meta: {

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

@@ -0,0 +1,345 @@
+<template>
+	<div class="admin-tab container">
+		<page-metadata title="Admin | YouTube | Videos" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>YouTube Videos</h1>
+				<p>Manage YouTube video cache</p>
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			:events="events"
+			data-action="youtube.getVideos"
+			name="admin-youtube-videos"
+			:max-width="1140"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'viewYoutubeVideo',
+								data: {
+									videoId: slotProps.item._id
+								}
+							})
+						"
+						:disabled="slotProps.item.removed"
+						content="View Video"
+						v-tippy
+					>
+						open_in_full
+					</button>
+					<button
+						class="button is-danger icon-with-button material-icons"
+						@click.prevent="
+							confirmAction({
+								message:
+									'Removing this video will remove it from all playlists and cause a ratings recalculation.',
+								action: 'removeVideos',
+								params: 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-youtubeId="slotProps">
+				<a
+					:href="
+						'https://www.youtube.com/watch?v=' +
+						`${slotProps.item.youtubeId}`
+					"
+					target="_blank"
+				>
+					{{ slotProps.item.youtubeId }}
+				</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-author="slotProps">
+				<span :title="slotProps.item.author">{{
+					slotProps.item.author
+				}}</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)">{{
+					getDateFormatted(slotProps.item.createdAt)
+				}}</span>
+			</template>
+			<template #bulk-actions="slotProps">
+				<div class="bulk-actions">
+					<i
+						class="material-icons create-songs-icon"
+						@click.prevent="editMany(slotProps.item)"
+						content="Create songs from videos"
+						v-tippy
+						tabindex="0"
+					>
+						music_note
+					</i>
+					<i
+						class="material-icons delete-icon"
+						@click.prevent="
+							confirmAction({
+								message:
+									'Removing these videos will remove them from all playlists and cause a ratings recalculation.',
+								action: 'removeVideos',
+								params: slotProps.item.map(video => video._id)
+							})
+						"
+						content="Delete Videos"
+						v-tippy
+						tabindex="0"
+					>
+						delete_forever
+					</i>
+				</div>
+			</template>
+		</advanced-table>
+	</div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+
+export default {
+	components: {
+		AdvancedTable
+	},
+	data() {
+		return {
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 200,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "thumbnailImage",
+					displayName: "Thumb",
+					properties: ["youtubeId"],
+					sortable: false,
+					minWidth: 75,
+					defaultWidth: 75,
+					maxWidth: 75,
+					resizable: false
+				},
+				{
+					name: "youtubeId",
+					displayName: "YouTube ID",
+					properties: ["youtubeId"],
+					sortProperty: "youtubeId",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "_id",
+					displayName: "Video ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					properties: ["title"],
+					sortProperty: "title"
+				},
+				{
+					name: "author",
+					displayName: "Author",
+					properties: ["author"],
+					sortProperty: "author"
+				},
+				{
+					name: "duration",
+					displayName: "Duration",
+					properties: ["duration"],
+					sortProperty: "duration",
+					defaultWidth: 200
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					properties: ["createdAt"],
+					sortProperty: "createdAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Video ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "youtubeId",
+					displayName: "YouTube ID",
+					property: "youtubeId",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					property: "title",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "author",
+					displayName: "Author",
+					property: "author",
+					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"
+				}
+			],
+			events: {
+				adminRoom: "youtubeVideos",
+				updated: {
+					event: "admin.youtubeVideo.updated",
+					id: "youtubeVideo._id",
+					item: "youtubeVideo"
+				},
+				removed: {
+					event: "admin.youtubeVideo.removed",
+					id: "videoId"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		editOne(song) {
+			this.openModal({
+				modal: "editSong",
+				data: { song: { songId: song._id } }
+			});
+		},
+		editMany(selectedRows) {
+			if (selectedRows.length === 1) this.editOne(selectedRows[0]);
+			else {
+				const songs = selectedRows.map(row => ({
+					songId: row._id
+				}));
+				this.openModal({ modal: "editSongs", data: { songs } });
+			}
+		},
+		removeVideos(videoIds) {
+			this.socket.dispatch(
+				"youtube.removeVideos",
+				videoIds,
+				res => new Toast(res.message)
+			);
+		},
+		getDateFormatted(createdAt) {
+			const date = new Date(createdAt);
+			const year = date.getFullYear();
+			const month = `${date.getMonth() + 1}`.padStart(2, 0);
+			const day = `${date.getDate()}`.padStart(2, 0);
+			const hour = `${date.getHours()}`.padStart(2, 0);
+			const minute = `${date.getMinutes()}`.padStart(2, 0);
+			return `${year}-${month}-${day} ${hour}:${minute}`;
+		},
+		confirmAction({ message, action, params }) {
+			this.openModal({
+				modal: "confirm",
+				data: {
+					message,
+					action,
+					params,
+					onCompleted: this.handleConfirmed
+				}
+			});
+		},
+		handleConfirmed({ action, params }) {
+			if (typeof this[action] === "function") {
+				if (params) this[action](params);
+				else this[action]();
+			}
+		},
+		...mapActions("modalVisibility", ["openModal"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+:deep(.song-thumbnail) {
+	width: 50px;
+	height: 50px;
+	min-width: 50px;
+	min-height: 50px;
+	margin: 0 auto;
+}
+</style>

+ 0 - 0
frontend/src/pages/Admin/YouTube.vue → frontend/src/pages/Admin/YouTube/index.vue


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

@@ -189,7 +189,48 @@
 								<i class="material-icons">show_chart</i>
 								<span>Statistics</span>
 							</router-link>
+							<div
+								v-if="sidebarActive"
+								class="sidebar-item with-children"
+								:class="{ 'is-active': childrenActive.youtube }"
+							>
+								<span>
+									<router-link to="/admin/youtube">
+										<i class="material-icons"
+											>smart_display</i
+										>
+										<span>YouTube</span>
+									</router-link>
+									<i
+										class="material-icons toggle-sidebar-children"
+										@click="
+											toggleChildren({ child: 'youtube' })
+										"
+									>
+										{{
+											childrenActive.youtube
+												? "expand_less"
+												: "expand_more"
+										}}
+									</i>
+								</span>
+								<div class="sidebar-item-children">
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/youtube"
+									>
+										YouTube
+									</router-link>
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/youtube/videos"
+									>
+										Videos
+									</router-link>
+								</div>
+							</div>
 							<router-link
+								v-else
 								class="sidebar-item youtube"
 								to="/admin/youtube"
 								content="YouTube"
@@ -386,6 +427,8 @@ export default {
 				this.toggleChildren({ child: "songs", force: false });
 			} else if (this.currentTab.startsWith("users")) {
 				this.toggleChildren({ child: "users", force: false });
+			} else if (this.currentTab.startsWith("youtube")) {
+				this.toggleChildren({ child: "youtube", force: false });
 			}
 			this.currentTab = this.getTabFromPath();
 			if (this.$refs[`${this.currentTab}-tab`])
@@ -398,6 +441,8 @@ export default {
 				this.toggleChildren({ child: "songs", force: true });
 			else if (this.currentTab.startsWith("users"))
 				this.toggleChildren({ child: "users", force: true });
+			else if (this.currentTab.startsWith("youtube"))
+				this.toggleChildren({ child: "youtube", force: true });
 		},
 		toggleKeyboardShortcutsHelper() {
 			this.$refs.keyboardShortcutsHelper.toggleBox();

+ 2 - 1
frontend/src/store/modules/admin.js

@@ -70,7 +70,8 @@ export default {
 	state: {
 		childrenActive: {
 			songs: false,
-			users: false
+			users: false,
+			youtube: false
 		}
 	},
 	getters: {},