Browse Source

Merge branch 'staging'

Owen Diffey 3 years ago
parent
commit
b43387ed1c
100 changed files with 11881 additions and 4893 deletions
  1. 1 1
      .env.example
  2. 1 1
      .github/workflows/build-eslint.yml
  3. 4 0
      .wiki/Configuration.md
  4. 42 0
      CHANGELOG.md
  5. 10 7
      README.md
  6. 5 2
      backend/Dockerfile
  7. 19 2
      backend/config/template.json
  8. 139 77
      backend/core.js
  9. 55 12
      backend/index.js
  10. 11 6
      backend/logic/actions/apis.js
  11. 5 1
      backend/logic/actions/index.js
  12. 915 0
      backend/logic/actions/media.js
  13. 203 131
      backend/logic/actions/playlists.js
  14. 330 984
      backend/logic/actions/songs.js
  15. 46 287
      backend/logic/actions/stations.js
  16. 238 44
      backend/logic/actions/users.js
  17. 555 0
      backend/logic/actions/youtube.js
  18. 136 1
      backend/logic/cache/index.js
  19. 1 0
      backend/logic/cache/schemas/ratings.js
  20. 27 5
      backend/logic/db/index.js
  21. 9 0
      backend/logic/db/schemas/importJob.js
  22. 1 1
      backend/logic/db/schemas/playlist.js
  23. 6 0
      backend/logic/db/schemas/ratings.js
  24. 1 3
      backend/logic/db/schemas/song.js
  25. 1 1
      backend/logic/db/schemas/station.js
  26. 6 0
      backend/logic/db/schemas/youtubeApiRequest.js
  27. 8 0
      backend/logic/db/schemas/youtubeVideo.js
  28. 502 0
      backend/logic/media.js
  29. 2 1
      backend/logic/migration/index.js
  30. 102 0
      backend/logic/migration/migrations/migration21.js
  31. 205 23
      backend/logic/playlists.js
  32. 180 412
      backend/logic/songs.js
  33. 439 108
      backend/logic/stations.js
  34. 13 9
      backend/logic/ws.js
  35. 1463 168
      backend/logic/youtube.js
  36. 214 265
      backend/package-lock.json
  37. 10 10
      backend/package.json
  38. 4 4
      docker-compose.dev.yml
  39. 18 10
      docker-compose.yml
  40. 11 3
      frontend/Dockerfile
  41. 0 9
      frontend/bootstrap.sh
  42. 8 0
      frontend/entrypoint.sh
  43. 274 242
      frontend/package-lock.json
  44. 19 15
      frontend/package.json
  45. 4 0
      frontend/src/App.vue
  46. 50 106
      frontend/src/components/AdvancedTable.vue
  47. 127 75
      frontend/src/components/FloatingBox.vue
  48. 100 0
      frontend/src/components/LineChart.vue
  49. 256 0
      frontend/src/components/LongJobs.vue
  50. 3 2
      frontend/src/components/ModalManager.vue
  51. 1 5
      frontend/src/components/PlaylistItem.vue
  52. 2 3
      frontend/src/components/PunishmentItem.vue
  53. 19 11
      frontend/src/components/RunJobDropdown.vue
  54. 19 11
      frontend/src/components/SongItem.vue
  55. 0 110
      frontend/src/components/SongThumbnail.vue
  56. 141 0
      frontend/src/components/global/SongThumbnail.vue
  57. 0 46
      frontend/src/components/global/UserIdToUsername.vue
  58. 53 0
      frontend/src/components/global/UserLink.vue
  59. 22 6
      frontend/src/components/modals/BulkActions.vue
  60. 1 2
      frontend/src/components/modals/EditNews.vue
  61. 14 41
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  62. 375 215
      frontend/src/components/modals/EditSong/index.vue
  63. 70 34
      frontend/src/components/modals/EditSongs.vue
  64. 29 16
      frontend/src/components/modals/ImportAlbum.vue
  65. 0 164
      frontend/src/components/modals/ImportPlaylist.vue
  66. 149 0
      frontend/src/components/modals/ViewApiRequest.vue
  67. 1 1
      frontend/src/components/modals/ViewReport.vue
  68. 1019 0
      frontend/src/components/modals/ViewYoutubeVideo.vue
  69. 2 3
      frontend/src/components/modals/WhatIsNew.vue
  70. 16 3
      frontend/src/main.js
  71. 156 0
      frontend/src/mixins/DragBox.vue
  72. 68 65
      frontend/src/pages/Admin/News.vue
  73. 77 79
      frontend/src/pages/Admin/Playlists.vue
  74. 101 103
      frontend/src/pages/Admin/Reports.vue
  75. 719 0
      frontend/src/pages/Admin/Songs/Import.vue
  76. 294 289
      frontend/src/pages/Admin/Songs/index.vue
  77. 123 125
      frontend/src/pages/Admin/Stations.vue
  78. 82 120
      frontend/src/pages/Admin/Statistics.vue
  79. 57 53
      frontend/src/pages/Admin/Users/DataRequests.vue
  80. 115 144
      frontend/src/pages/Admin/Users/Punishments.vue
  81. 96 92
      frontend/src/pages/Admin/Users/index.vue
  82. 411 0
      frontend/src/pages/Admin/YouTube/Videos.vue
  83. 411 0
      frontend/src/pages/Admin/YouTube/index.vue
  84. 203 55
      frontend/src/pages/Admin/index.vue
  85. 2 7
      frontend/src/pages/Home.vue
  86. 1 2
      frontend/src/pages/News.vue
  87. 3 3
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  88. 1 1
      frontend/src/pages/Profile/index.vue
  89. 2 2
      frontend/src/pages/Station/Sidebar/Users.vue
  90. 33 25
      frontend/src/pages/Station/index.vue
  91. 6 3
      frontend/src/store/index.js
  92. 3 1
      frontend/src/store/modules/admin.js
  93. 56 0
      frontend/src/store/modules/longJobs.js
  94. 5 3
      frontend/src/store/modules/modalVisibility.js
  95. 14 8
      frontend/src/store/modules/modals/editSong.js
  96. 4 4
      frontend/src/store/modules/modals/editSongs.js
  97. 5 0
      frontend/src/store/modules/modals/importAlbum.js
  98. 25 0
      frontend/src/store/modules/modals/viewApiRequest.js
  99. 83 0
      frontend/src/store/modules/modals/viewYoutubeVideo.js
  100. 13 10
      frontend/src/store/modules/user.js

+ 1 - 1
.env.example

@@ -8,7 +8,7 @@ BACKEND_PORT=8080
 
 
 FRONTEND_HOST=127.0.0.1
 FRONTEND_HOST=127.0.0.1
 FRONTEND_PORT=80
 FRONTEND_PORT=80
-FRONTEND_MODE=dev
+FRONTEND_MODE=prod
 
 
 MONGO_HOST=127.0.0.1
 MONGO_HOST=127.0.0.1
 MONGO_PORT=27017
 MONGO_PORT=27017

+ 1 - 1
.github/workflows/build-eslint.yml

@@ -10,7 +10,7 @@ env:
     BACKEND_PORT: 8080
     BACKEND_PORT: 8080
     FRONTEND_HOST: 127.0.0.1
     FRONTEND_HOST: 127.0.0.1
     FRONTEND_PORT: 80
     FRONTEND_PORT: 80
-    FRONTEND_MODE: dev
+    FRONTEND_MODE: prod
     MONGO_HOST: 127.0.0.1
     MONGO_HOST: 127.0.0.1
     MONGO_PORT: 27017
     MONGO_PORT: 27017
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_ROOT_PASSWORD: PASSWORD_HERE

+ 4 - 0
.wiki/Configuration.md

@@ -17,6 +17,10 @@ Location: `backend/config/default.json`
 | `apis.youtube.rateLimit` | Minimum interval between YouTube API requests in milliseconds. |
 | `apis.youtube.rateLimit` | Minimum interval between YouTube API requests in milliseconds. |
 | `apis.youtube.requestTimeout` | YouTube API requests timeout in milliseconds. |
 | `apis.youtube.requestTimeout` | YouTube API requests timeout in milliseconds. |
 | `apis.youtube.retryAmount` | The amount of retries to perform of a failed YouTube API request. |
 | `apis.youtube.retryAmount` | The amount of retries to perform of a failed YouTube API request. |
+| `apis.youtube.quotas` | Array of YouTube API quotas. |
+| `apis.youtube.quotas.type` | YouTube API quota type, should be one of `QUERIES_PER_DAY`, `QUERIES_PER_MINUTE` or `QUERIES_PER_100_SECONDS`. |
+| `apis.youtube.quotas.title` | YouTube API quota title. |
+| `apis.youtube.quotas.limit` | YouTube API quota limit. |
 | `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained from [here](https://www.google.com/recaptcha/admin). |
 | `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained from [here](https://www.google.com/recaptcha/admin). |
 | `apis.recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
 | `apis.recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
 | `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
 | `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |

+ 42 - 0
CHANGELOG.md

@@ -1,5 +1,47 @@
 # Changelog
 # Changelog
 
 
+## [v3.6.0-rc1] - 2022-06-05
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+- feat: Added tab-completion to backend commands
+- feat: Added YouTube quota usage tracking
+- feat: Added YouTube API requests tracking, caching and management
+- feat: Added YouTube channel import functionality
+- feat: Added YouTube title and artists prefill button to Edit Song modal
+- feat: Warn if thumbnail fails to load in Edit Song modal
+- feat: Added Import Songs admin page
+- feat: Added YouTube API requests admin page with charts and an advanced table
+- feat: Added YouTube videos admin page with an advanced table
+- feat: Added View API Request modal
+- feat: Added View YouTube Video modal
+- feat: Added long jobs handling and monitoring
+
+### Changed
+- refactor: Display user display names instead of usernames in links and station user list
+- refactor: Use YouTube thumbnail as a fallback to song thumbnails
+- refactor: Use song thumbnail component in Edit Song modal, with fallback disabled
+- refactor: Edit Song positioning and styling tweaks
+- refactor: Moved vote skip processing to dedicated job
+- refactor: Prevent auto vote to skip if locally paused
+- refactor: Added info header card to admin pages
+- refactor: Allowed for song style usage of YouTube videos in playlists and station queues
+- refactor: Moved ratings to dedicated model within media module, with YouTube video support
+- refactor: Replace songs with YouTube videos in playlists, station queues and ratings on removal
+- refactor: Moved drag box handling to mixin
+- refactor: Floating box logic and styling improvements
+- refactor: Added support for creation of songs from YouTube videos in Edit Song(s) modals
+- refactor: Compile production frontend as part of docker image build
+- refactor: Changed default frontend docker mode to prod
+- refactor: Import Album can now use a selection of songs or YouTube videos in addition to YouTube playlist importing.
+
+### Fixed
+- fix: musare.sh attach not working with podman-compose
+- fix: Station autofill not run after removal from queue
+- fix: AdvancedTable multi-row select with left ctrl/shift doesnt work
+- fix: YouTube search actions don't require login
+
 ## [v3.5.2] - 2022-05-12
 ## [v3.5.2] - 2022-05-12
 
 
 Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).

+ 10 - 7
README.md

@@ -25,14 +25,14 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
     - Automatically generated playlists for genres
     - Automatically generated playlists for genres
     - Privacy configuration
     - Privacy configuration
     - Liked and Disliked songs playlists per user
     - Liked and Disliked songs playlists per user
-    - Bulk import songs from YouTube playlist
-    - Add songs from verified catalogue or YouTube
+    - Bulk import videos from YouTube playlist
+    - Add songs from verified catalogue or YouTube videos
     - Ability to download in JSON format
     - Ability to download in JSON format
 - **Stations**
 - **Stations**
     - Requests - Toggleable module to allow users to add songs to the queue
     - Requests - Toggleable module to allow users to add songs to the queue
         - Configurable access level and per user request limit
         - Configurable access level and per user request limit
         - Automatically request songs from selected playlists
         - Automatically request songs from selected playlists
-        - Ability to search for songs from verified catalogue or YouTube
+        - Ability to search for songs from verified catalogue or YouTube videos
     - Autofill - Toggleable module to allow owners to configure automatic filling of the queue from selected playlists
     - Autofill - Toggleable module to allow owners to configure automatic filling of the queue from selected playlists
         - Configurable song limit
         - Configurable song limit
         - Play mode option to randomly play many playlists, or sequentially play one playlist
         - Play mode option to randomly play many playlists, or sequentially play one playlist
@@ -49,14 +49,17 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
     - Force skipping song by admins or owners
     - Force skipping song by admins or owners
 - **Song Management**
 - **Song Management**
     - Verify songs to allow them to be searched for and added to automatically generated genre playlists
     - Verify songs to allow them to be searched for and added to automatically generated genre playlists
-    - Import Album to import songs in bulk
     - Discogs integration to import metadata
     - Discogs integration to import metadata
     - Ability for users to report issues with songs and admins to resolve
     - Ability for users to report issues with songs and admins to resolve
     - Configurable skip duration and song duration to cut intros and outros
     - Configurable skip duration and song duration to cut intros and outros
-    - Import YouTube playlists from admin area
-    - Any song added to playlists or stations will be automatically requested
+    - Import YouTube playlists or channels from admin area
+    - Import Album to associate Discogs album data with media in bulk
     - Bulk admin management of songs
     - Bulk admin management of songs
-    - Create songs from scratch
+    - Create songs from scratch or from YouTube videos
+- **YouTube**
+    - Monitor and manage API requests and quota usage
+    - Configure API quota limits
+    - YouTube video management
 - **Users**
 - **Users**
     - Activity logs
     - Activity logs
     - Profile page showing public playlists and activity logs
     - Profile page showing public playlists and activity logs

+ 5 - 2
backend/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:16.15
+FROM node:16.15 AS musare_backend
 
 
 RUN npm install -g nodemon
 RUN npm install -g nodemon
 
 
@@ -12,4 +12,7 @@ RUN npm install
 
 
 COPY . /opt/app
 COPY . /opt/app
 
 
-CMD npm run docker:dev
+ENTRYPOINT npm run docker:dev
+
+EXPOSE 8080/tcp
+EXPOSE 8080/udp

+ 19 - 2
backend/config/template.json

@@ -13,7 +13,24 @@
 			"key": "",
 			"key": "",
 			"rateLimit": 500,
 			"rateLimit": 500,
 			"requestTimeout": 5000,
 			"requestTimeout": 5000,
-			"retryAmount": 2
+			"retryAmount": 2,
+			"quotas": [
+				{
+					"type": "QUERIES_PER_DAY",
+					"title": "Queries Per Day",
+					"limit": 10000
+				},
+				{
+					"type": "QUERIES_PER_MINUTE",
+					"title": "Queries Per Minute",
+					"limit": 1800000
+				},
+				{
+					"type": "QUERIES_PER_100_SECONDS",
+					"title": "Queries Per 100 Seconds",
+					"limit": 3000000
+				}
+			]
 		},
 		},
 		"recaptcha": {
 		"recaptcha": {
 			"secret": "",
 			"secret": "",
@@ -96,5 +113,5 @@
 			]
 			]
 		}
 		}
 	},
 	},
-	"configVersion": 9
+	"configVersion": 10
 }
 }

+ 139 - 77
backend/core.js

@@ -1,4 +1,5 @@
 import config from "config";
 import config from "config";
+import { EventEmitter } from "events";
 
 
 class DeferredPromise {
 class DeferredPromise {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
@@ -169,7 +170,7 @@ class Queue {
 
 
 class Job {
 class Job {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
-	constructor(name, payload, onFinish, module, parentJob) {
+	constructor(name, payload, onFinish, module, parentJob, options) {
 		this.name = name;
 		this.name = name;
 		this.payload = payload;
 		this.payload = payload;
 		this.response = null;
 		this.response = null;
@@ -186,6 +187,13 @@ class Job {
 		});
 		});
 		this.status = "INITIALIZED";
 		this.status = "INITIALIZED";
 		this.task = null;
 		this.task = null;
+		this.onProgress = options.onProgress;
+		this.lastProgressData = null;
+		this.lastProgressTime = Date.now();
+		this.lastProgressTimeout = null;
+		this.longJob = false;
+		this.longJobTitle = "";
+		this.longJobStatus = "";
 	}
 	}
 
 
 	/**
 	/**
@@ -258,6 +266,52 @@ class Job {
 		args.splice(1, 0, this.name); // Adds the name of the job as the first argument (after INFO/SUCCESS/ERROR).
 		args.splice(1, 0, this.name); // Adds the name of the job as the first argument (after INFO/SUCCESS/ERROR).
 		this.module.log(...args);
 		this.module.log(...args);
 	}
 	}
+
+	keepLongJob() {
+		this.longJob = true;
+	}
+
+	forgetLongJob() {
+		this.longJob = false;
+		this.module.moduleManager.jobManager.removeJob(this);
+	}
+
+	/**
+	 * 
+	 * @param {data} data - Data to publish upon progress
+	 */
+	publishProgress(data, notALongJob) {
+		if (this.longJob || notALongJob) {
+			if (this.onProgress) {
+				if (notALongJob) {
+					this.onProgress.emit("progress", data);
+				} else {
+					this.lastProgressData = data;
+
+					if (data.status === "update") {
+						if ((Date.now() - this.lastProgressTime) > 1000) {
+							this.lastProgressTime = Date.now();
+						} else {
+							if (this.lastProgressTimeout) clearTimeout(this.lastProgressTimeout);
+							this.lastProgressTimeout = setTimeout(() => {
+								this.lastProgressTime = Date.now();
+								this.lastProgressTimeout = null;
+								this.onProgress.emit("progress", data);
+							}, Date.now() - this.lastProgressTime);
+							return;
+						}
+					} else if (data.status === "success" || data.status === "error")
+						if (this.lastProgressTimeout) clearTimeout(this.lastProgressTimeout);
+
+					if (data.title)	this.longJobTitle = data.title;
+
+					this.onProgress.emit("progress", data);
+				}
+			} else this.log("Progress published, but no onProgress specified.")
+		} else {
+			this.parentJob.publishProgress(data);
+		}
+	}
 }
 }
 
 
 class MovingAverageCalculator {
 class MovingAverageCalculator {
@@ -313,6 +367,7 @@ export default class CoreClass {
 		this.priorities = options && options.priorities ? options.priorities : {};
 		this.priorities = options && options.priorities ? options.priorities : {};
 		this.stage = 0;
 		this.stage = 0;
 		this.jobStatistics = {};
 		this.jobStatistics = {};
+		this.jobNames = [];
 
 
 		this.logRules = config.get("customLoggingPerModule")[name]
 		this.logRules = config.get("customLoggingPerModule")[name]
 			? config.get("customLoggingPerModule")[name]
 			? config.get("customLoggingPerModule")[name]
@@ -434,6 +489,7 @@ export default class CoreClass {
 				averageTiming: new MovingAverageCalculator()
 				averageTiming: new MovingAverageCalculator()
 			};
 			};
 		});
 		});
+		this.jobNames = jobNames;
 	}
 	}
 
 
 	/**
 	/**
@@ -473,9 +529,15 @@ export default class CoreClass {
 		} else _priority = priority;
 		} else _priority = priority;
 
 
 		if (!_options) _options = { isQuiet: false };
 		if (!_options) _options = { isQuiet: false };
+		if (_options && typeof _options.onProgress === "function") {
+			const onProgress = new EventEmitter();
+			onProgress.on("progress", _options.onProgress);
+			_options.onProgress = onProgress;
+		}
+		if (!_options.onProgress && parentJob) _options.onProgress = parentJob.onProgress;
 
 
 		const deferredPromise = new DeferredPromise();
 		const deferredPromise = new DeferredPromise();
-		const job = new Job(name, payload, deferredPromise, this, _parentJob);
+		const job = new Job(name, payload, deferredPromise, this, _parentJob, { onProgress: _options.onProgress });
 
 
 		this.log("INFO", `Queuing job ${name} (${job.toString()})`);
 		this.log("INFO", `Queuing job ${name} (${job.toString()})`);
 
 
@@ -550,83 +612,83 @@ export default class CoreClass {
 				if (!options.isQuiet) this.log("INFO", `Job ${job.name} (${job.toString()}) is queued, so calling it`);
 				if (!options.isQuiet) this.log("INFO", `Job ${job.name} (${job.toString()}) is queued, so calling it`);
 
 
 				if (this[job.name])
 				if (this[job.name])
-				this[job.name]
-					.apply(job, [job.payload])
-					.then(response => {
-						if (!options.isQuiet) this.log("INFO", `Ran job ${job.name} (${job.toString()}) successfully`);
-						job.setStatus("FINISHED");
-						job.setResponse(response);
-						this.jobStatistics[job.name].successful += 1;
-						job.setResponseType("RESOLVE");
-						if (
-							config.debug &&
-							config.debug.stationIssue === true &&
-							config.debug.captureJobs &&
-							config.debug.captureJobs.indexOf(job.name) !== -1
-						) {
-							this.moduleManager.debugJobs.completed.push({
-								status: "success",
-								job,
-								priority: job.task.priority,
-								response
-							});
-						}
-					})
-					.catch(error => {
-						this.log("INFO", `Running job ${job.name} (${job.toString()}) failed`);
-						job.setStatus("FINISHED");
-						job.setResponse(error);
-						job.setResponseType("REJECT");
-						this.jobStatistics[job.name].failed += 1;
-						if (
-							config.debug &&
-							config.debug.stationIssue === true &&
-							config.debug.captureJobs &&
-							config.debug.captureJobs.indexOf(job.name) !== -1
-						) {
-							this.moduleManager.debugJobs.completed.push({
-								status: "error",
-								job,
-								error
-							});
-						}
-					})
-					.finally(() => {
-						const endTime = Date.now();
-						const executionTime = endTime - startTime;
-						this.jobStatistics[job.name].total += 1;
-						this.jobStatistics[job.name].averageTiming.update(executionTime);
-						this.moduleManager.jobManager.removeJob(job);
-						job.cleanup();
-
-						if (!job.parentJob) {
-							if (job.responseType === "RESOLVE") {
-								job.onFinish.resolve(job.response);
-								job.responseType = "RESOLVED";
-							} else if (job.responseType === "REJECT") {
-								job.onFinish.reject(job.response);
-								job.responseType = "REJECTED";
+					this[job.name]
+						.apply(job, [job.payload])
+						.then(response => {
+							if (!options.isQuiet) this.log("INFO", `Ran job ${job.name} (${job.toString()}) successfully`);
+							job.setStatus("FINISHED");
+							job.setResponse(response);
+							this.jobStatistics[job.name].successful += 1;
+							job.setResponseType("RESOLVE");
+							if (
+								config.debug &&
+								config.debug.stationIssue === true &&
+								config.debug.captureJobs &&
+								config.debug.captureJobs.indexOf(job.name) !== -1
+							) {
+								this.moduleManager.debugJobs.completed.push({
+									status: "success",
+									job,
+									priority: job.task.priority,
+									response
+								});
 							}
 							}
-						} else if (
-							job.parentJob &&
-							job.parentJob.childJobs.find(childJob =>
-								childJob ? childJob.status !== "FINISHED" : true
-							) === undefined
-						) {
-							if (job.parentJob.status !== "WAITING_ON_CHILD_JOB") {
-								this.log(
-									"ERROR",
-									`Job ${
-										job.parentJob.name
-									} (${job.parentJob.toString()}) had a child job complete even though it is not waiting on a child job. This should never happen.`
-								);
-							} else {
-								job.parentJob.setStatus("REQUEUED");
-								job.parentJob.module.jobQueue.resumeRunningJob(job.parentJob);
+						})
+						.catch(error => {
+							this.log("INFO", `Running job ${job.name} (${job.toString()}) failed`);
+							job.setStatus("FINISHED");
+							job.setResponse(error);
+							job.setResponseType("REJECT");
+							this.jobStatistics[job.name].failed += 1;
+							if (
+								config.debug &&
+								config.debug.stationIssue === true &&
+								config.debug.captureJobs &&
+								config.debug.captureJobs.indexOf(job.name) !== -1
+							) {
+								this.moduleManager.debugJobs.completed.push({
+									status: "error",
+									job,
+									error
+								});
 							}
 							}
-						}
-						resolve();
-					});
+						})
+						.finally(() => {
+							const endTime = Date.now();
+							const executionTime = endTime - startTime;
+							this.jobStatistics[job.name].total += 1;
+							this.jobStatistics[job.name].averageTiming.update(executionTime);
+							if (!job.longJob) this.moduleManager.jobManager.removeJob(job);
+							job.cleanup();
+
+							if (!job.parentJob) {
+								if (job.responseType === "RESOLVE") {
+									job.onFinish.resolve(job.response);
+									job.responseType = "RESOLVED";
+								} else if (job.responseType === "REJECT") {
+									job.onFinish.reject(job.response);
+									job.responseType = "REJECTED";
+								}
+							} else if (
+								job.parentJob &&
+								job.parentJob.childJobs.find(childJob =>
+									childJob ? childJob.status !== "FINISHED" : true
+								) === undefined
+							) {
+								if (job.parentJob.status !== "WAITING_ON_CHILD_JOB") {
+									this.log(
+										"ERROR",
+										`Job ${
+											job.parentJob.name
+										} (${job.parentJob.toString()}) had a child job complete even though it is not waiting on a child job. This should never happen.`
+									);
+								} else {
+									job.parentJob.setStatus("REQUEUED");
+									job.parentJob.module.jobQueue.resumeRunningJob(job.parentJob);
+								}
+							}
+							resolve();
+						});
 				else
 				else
 					this.log("ERROR", `Job not found! ${job.name}`)
 					this.log("ERROR", `Job not found! ${job.name}`)
 			} else {
 			} else {

+ 55 - 12
backend/index.js

@@ -6,7 +6,7 @@ import fs from "fs";
 
 
 import package_json from "./package.json" assert { type: "json" };
 import package_json from "./package.json" assert { type: "json" };
 
 
-const REQUIRED_CONFIG_VERSION = 9;
+const REQUIRED_CONFIG_VERSION = 10;
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {
 Array.prototype.remove = function (item) {
@@ -72,17 +72,17 @@ if (config.debug && config.debug.traceUnhandledPromises === true) {
 class JobManager {
 class JobManager {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
 	constructor() {
-		this.runningJobs = {};
+		this.jobs = {};
 	}
 	}
 
 
 	/**
 	/**
-	 * Adds a job to the list of running jobs
+	 * Adds a job to the list of jobs
 	 *
 	 *
 	 * @param {object} job - the job object
 	 * @param {object} job - the job object
 	 */
 	 */
 	addJob(job) {
 	addJob(job) {
-		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
-		this.runningJobs[job.module.name][job.toString()] = job;
+		if (!this.jobs[job.module.name]) this.jobs[job.module.name] = {};
+		this.jobs[job.module.name][job.toString()] = job;
 	}
 	}
 
 
 	/**
 	/**
@@ -91,8 +91,9 @@ class JobManager {
 	 * @param {object} job - the job object
 	 * @param {object} job - the job object
 	 */
 	 */
 	removeJob(job) {
 	removeJob(job) {
-		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
-		delete this.runningJobs[job.module.name][job.toString()];
+		return;
+		if (!this.jobs[job.module.name]) this.jobs[job.module.name] = {};
+		delete this.jobs[job.module.name][job.toString()];
 	}
 	}
 
 
 	/**
 	/**
@@ -103,8 +104,8 @@ class JobManager {
 	 */
 	 */
 	getJob(uuid) {
 	getJob(uuid) {
 		let job = null;
 		let job = null;
-		Object.keys(this.runningJobs).forEach(moduleName => {
-			if (this.runningJobs[moduleName][uuid]) job = this.runningJobs[moduleName][uuid];
+		Object.keys(this.jobs).forEach(moduleName => {
+			if (this.jobs[moduleName][uuid]) job = this.jobs[moduleName][uuid];
 		});
 		});
 		return job;
 		return job;
 	}
 	}
@@ -258,6 +259,7 @@ if (!config.get("migration")) {
 	moduleManager.addModule("punishments");
 	moduleManager.addModule("punishments");
 	moduleManager.addModule("songs");
 	moduleManager.addModule("songs");
 	moduleManager.addModule("stations");
 	moduleManager.addModule("stations");
+	moduleManager.addModule("media");
 	moduleManager.addModule("tasks");
 	moduleManager.addModule("tasks");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");
 	moduleManager.addModule("youtube");
@@ -297,8 +299,43 @@ function printTask(task, layer) {
 	});
 	});
 }
 }
 
 
-process.stdin.on("data", data => {
-	const command = data.toString().replace(/\r?\n|\r/g, "");
+import * as readline from 'node:readline';
+
+var rl = readline.createInterface({
+	input: process.stdin,
+	output: process.stdout,
+	completer: function(command) {
+		const parts = command.split(" ");
+		const commands = ["version", "lockdown", "status", "running ", "queued ", "paused ", "stats ", "jobinfo ", "runjob ", "eval "];
+		if (parts.length === 1) {
+			const hits = commands.filter(c => c.startsWith(parts[0]));
+			return [hits.length ? hits : commands, command];
+		} else if (parts.length === 2) {
+			if (["queued", "running", "paused", "runjob", "stats"].indexOf(parts[0]) !== -1) {
+				const modules = Object.keys(moduleManager.modules);
+				const hits = modules.filter(module => module.startsWith(parts[1])).map(module => `${parts[0]} ${module}${parts[0] === "runjob" ? " " : ""}`);
+				return  [hits.length ? hits : modules, command];
+			} else {
+				return [];
+			}
+		} else if (parts.length === 3) {
+			if (parts[0] === "runjob") {
+				const modules = Object.keys(moduleManager.modules);
+				if (modules.indexOf(parts[1]) !== -1) {
+					const jobs = moduleManager.modules[parts[1]].jobNames;
+					const hits = jobs.filter(job => job.startsWith(parts[2])).map(job => `${parts[0]} ${parts[1]} ${job} `);
+					return  [hits.length ? hits : jobs, command];
+				}
+			} else {
+				return [];
+			}
+		} else {
+			return [];
+		}
+	}
+});
+
+rl.on("line",function(command) {
 	if (command === "version") {
 	if (command === "version") {
 		printVersion();
 		printVersion();
 	}
 	}
@@ -371,7 +408,7 @@ process.stdin.on("data", data => {
 		const parts = command.split(" ");
 		const parts = command.split(" ");
 		const module = parts[1];
 		const module = parts[1];
 		const jobName = parts[2];
 		const jobName = parts[2];
-		const payload = JSON.parse(parts[3]);
+		const payload = parts.length < 4 ? {} : JSON.parse(parts[3]);
 
 
 		moduleManager.modules[module]
 		moduleManager.modules[module]
 			.runJob(jobName, payload)
 			.runJob(jobName, payload)
@@ -389,6 +426,12 @@ process.stdin.on("data", data => {
 		const response = eval(evalCommand);
 		const response = eval(evalCommand);
 		console.log(`Eval response: `, response);
 		console.log(`Eval response: `, response);
 	}
 	}
+	if (command.startsWith("debug")) {
+		moduleManager.modules["youtube"].apiCalls.forEach(apiCall => {
+			// console.log(`${apiCall.date.toISOString()} - ${apiCall.url} - ${apiCall.quotaCost} - ${JSON.stringify(apiCall.params)}`);
+			console.log(apiCall);
+		});
+	}
 });
 });
 
 
 export default moduleManager;
 export default moduleManager;

+ 11 - 6
backend/logic/actions/apis.js

@@ -2,7 +2,7 @@ import config from "config";
 import async from "async";
 import async from "async";
 import axios from "axios";
 import axios from "axios";
 
 
-import { isAdminRequired } from "./hooks";
+import { isAdminRequired, isLoginRequired } from "./hooks";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
 import moduleManager from "../../index";
 import moduleManager from "../../index";
@@ -20,7 +20,7 @@ export default {
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 * @returns {{status: string, data: object}} - returns an object
 	 * @returns {{status: string, data: object}} - returns an object
 	 */
 	 */
-	searchYoutube(session, query, cb) {
+	searchYoutube: isLoginRequired(function searchYoutube(session, query, cb) {
 		return YouTubeModule.runJob("SEARCH", { query }, this)
 		return YouTubeModule.runJob("SEARCH", { query }, this)
 			.then(data => {
 			.then(data => {
 				this.log("SUCCESS", "APIS_SEARCH_YOUTUBE", `Searching YouTube successful with query "${query}".`);
 				this.log("SUCCESS", "APIS_SEARCH_YOUTUBE", `Searching YouTube successful with query "${query}".`);
@@ -31,7 +31,7 @@ export default {
 				this.log("ERROR", "APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
 				this.log("ERROR", "APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
 				return cb({ status: "error", message: err });
 				return cb({ status: "error", message: err });
 			});
 			});
-	},
+	}),
 
 
 	/**
 	/**
 	 * Fetches a specific page of search results from Youtube's API
 	 * Fetches a specific page of search results from Youtube's API
@@ -42,7 +42,7 @@ export default {
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 * @returns {{status: string, data: object}} - returns an object
 	 * @returns {{status: string, data: object}} - returns an object
 	 */
 	 */
-	searchYoutubeForPage(session, query, pageToken, cb) {
+	searchYoutubeForPage: isLoginRequired(function searchYoutubeForPage(session, query, pageToken, cb) {
 		return YouTubeModule.runJob("SEARCH", { query, pageToken }, this)
 		return YouTubeModule.runJob("SEARCH", { query, pageToken }, this)
 			.then(data => {
 			.then(data => {
 				this.log(
 				this.log(
@@ -61,7 +61,7 @@ export default {
 				);
 				);
 				return cb({ status: "error", message: err });
 				return cb({ status: "error", message: err });
 			});
 			});
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets Discogs data
 	 * Gets Discogs data
@@ -132,6 +132,8 @@ export default {
 			room.startsWith("edit-song.") ||
 			room.startsWith("edit-song.") ||
 			room.startsWith("view-report.") ||
 			room.startsWith("view-report.") ||
 			room.startsWith("edit-user.") ||
 			room.startsWith("edit-user.") ||
+			room.startsWith("view-api-request.") ||
+			room.startsWith("view-youtube-video.") ||
 			room === "import-album" ||
 			room === "import-album" ||
 			room === "edit-songs"
 			room === "edit-songs"
 		) {
 		) {
@@ -194,7 +196,10 @@ export default {
 			page === "playlists" ||
 			page === "playlists" ||
 			page === "users" ||
 			page === "users" ||
 			page === "statistics" ||
 			page === "statistics" ||
-			page === "punishments"
+			page === "punishments" ||
+			page === "youtube" ||
+			page === "youtubeVideos" ||
+			page === "import"
 		) {
 		) {
 			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
 			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
 				WSModule.runJob("SOCKET_JOIN_ROOM", {
 				WSModule.runJob("SOCKET_JOIN_ROOM", {

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

@@ -9,6 +9,8 @@ import reports from "./reports";
 import news from "./news";
 import news from "./news";
 import punishments from "./punishments";
 import punishments from "./punishments";
 import utils from "./utils";
 import utils from "./utils";
+import youtube from "./youtube";
+import media from "./media";
 
 
 export default {
 export default {
 	apis,
 	apis,
@@ -21,5 +23,7 @@ export default {
 	reports,
 	reports,
 	news,
 	news,
 	punishments,
 	punishments,
-	utils
+	utils,
+	youtube,
+	media
 };
 };

+ 915 - 0
backend/logic/actions/media.js

@@ -0,0 +1,915 @@
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const SongsModule = moduleManager.modules.songs;
+const ActivitiesModule = moduleManager.modules.activities;
+const MediaModule = moduleManager.modules.media;
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.like",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.liked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: true,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.dislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.disliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: true
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.unlike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.unliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "ratings.undislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:ratings.undisliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
+			});
+		});
+	}
+});
+
+export default {
+	/**
+	 * Recalculates all ratings
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	recalculateAllRatings: isAdminRequired(async function recalculateAllRatings(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Recalculate all ratings",
+			message: "Recalculating all ratings.",
+			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 => {
+					MediaModule.runJob("RECALCULATE_ALL_RATINGS", {}, this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "MEDIA_RECALCULATE_ALL_RATINGS", `Failed to recalculate all ratings. "${err}"`);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "MEDIA_RECALCULATE_ALL_RATINGS", `Recalculated all ratings successfully.`);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully recalculated all ratings."
+				});
+				return cb({ status: "success", message: "Successfully recalculated all ratings." });
+			}
+		);
+	}),
+
+	/**
+	 * Like
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	like: isLoginRequired(async function like(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					MediaModule.runJob(
+						"GET_MEDIA",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(() => next(null, song, user.likedSongsPlaylist))
+						.catch(res => {
+							if (!(res.message && res.message === "That song is not currently in the playlist."))
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						});
+				},
+
+				(song, likedSongsPlaylist, next) =>
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(() => next(null, song))
+						.catch(res => {
+							if (res.message && res.message === "That song is already in the playlist")
+								return next("You have already liked this song.");
+							return next("Unable to add song to the 'Liked Songs' playlist.");
+						}),
+
+				(song, next) => {
+					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MEDIA_RATINGS_LIKE",
+						`User "${session.userId}" failed to like song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.like",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__like",
+					payload: {
+						message: `Liked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully liked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Dislike
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	dislike: isLoginRequired(async function dislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					MediaModule.runJob(
+						"GET_MEDIA",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.likedSongsPlaylist]
+							},
+							this
+						)
+						.then(() => next(null, song, user.dislikedSongsPlaylist))
+						.catch(res => {
+							if (!(res.message && res.message === "That song is not currently in the playlist."))
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song, user.dislikedSongsPlaylist);
+						});
+				},
+
+				(song, dislikedSongsPlaylist, next) =>
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(() => next(null, song))
+						.catch(res => {
+							if (res.message && res.message === "That song is already in the playlist")
+								return next("You have already disliked this song.");
+							return next("Unable to add song to the 'Disliked Songs' playlist.");
+						}),
+
+				(song, next) => {
+					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MEDIA_RATINGS_DISLIKE",
+						`User "${session.userId}" failed to dislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.dislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__dislike",
+					payload: {
+						message: `Disliked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully disliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Undislike
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	undislike: isLoginRequired(async function undislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					MediaModule.runJob(
+						"GET_MEDIA",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(() => next(null, song))
+						.catch(res => {
+							if (!(res.message && res.message === "That song is not currently in the playlist."))
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						});
+				},
+
+				(song, next) => {
+					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MEDIA_RATINGS_UNDISLIKE",
+						`User "${session.userId}" failed to undislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.undislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__undislike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Disliked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully undisliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Unlike
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	unlike: isLoginRequired(async function unlike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					MediaModule.runJob(
+						"GET_MEDIA",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, verified } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							});
+						})
+						.catch(next);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(() => next(null, song, user.likedSongsPlaylist))
+						.catch(res => {
+							if (!(res.message && res.message === "That song is not currently in the playlist."))
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						});
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MEDIA_RATINGS_UNLIKE",
+						`User "${session.userId}" failed to unlike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				if (song._id) SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "ratings.unlike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__unlike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Liked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully unliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Get ratings
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+
+	getRatings: isLoginRequired(async function getRatings(session, youtubeId, cb) {
+		async.waterfall(
+			[
+				next => {
+					MediaModule.runJob("GET_RATINGS", { youtubeId, createMissing: true }, this)
+						.then(res => next(null, res.ratings))
+						.catch(next);
+				},
+
+				(ratings, next) => {
+					next(null, {
+						likes: ratings.likes,
+						dislikes: ratings.dislikes
+					});
+				}
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MEDIA_GET_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						likes,
+						dislikes
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets user's own ratings
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	getOwnRatings: isLoginRequired(async function getOwnRatings(session, youtubeId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					MediaModule.runJob(
+						"GET_MEDIA",
+						{
+							youtubeId
+						},
+						this
+					)
+						.then(() => next())
+						.catch(next);
+				},
+
+				next =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Liked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Liked Songs' playlist does not exist.");
+
+							let isLiked = false;
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'liked songs' playlist
+								if (song.youtubeId === youtubeId) isLiked = true;
+							});
+
+							return next(null, isLiked);
+						}
+					),
+
+				(isLiked, next) =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Disliked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Disliked Songs' playlist does not exist.");
+
+							const ratings = { isLiked, isDisliked: false };
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'disliked songs' playlist
+								if (song.youtubeId === youtubeId) ratings.isDisliked = true;
+							});
+
+							return next(null, ratings);
+						}
+					)
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MEDIA_GET_OWN_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { isLiked, isDisliked } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						youtubeId,
+						liked: isLiked,
+						disliked: isDisliked
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets importJobs, used in the admin import 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
+	 */
+	getImportJobs: isAdminRequired(async function getImportJobs(
+		session,
+		page,
+		pageSize,
+		properties,
+		sort,
+		queries,
+		operator,
+		cb
+	) {
+		async.waterfall(
+			[
+				next => {
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "importJob",
+							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", "MEDIA_GET_IMPORT_JOBS", `Failed to get import jobs. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "MEDIA_GET_IMPORT_JOBS", `Fetched import jobs successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched import jobs.",
+					data: response
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Remove import jobs
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	removeImportJobs: isAdminRequired(function removeImportJobs(session, jobIds, cb) {
+		MediaModule.runJob("REMOVE_IMPORT_JOBS", { jobIds }, this)
+			.then(() => {
+				this.log("SUCCESS", "MEDIA_REMOVE_IMPORT_JOBS", `Removing import jobs was successful.`);
+
+				return cb({ status: "success", message: "Successfully removed import jobs" });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "MEDIA_REMOVE_IMPORT_JOBS", `Removing import jobs failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	})
+};

+ 203 - 131
backend/logic/actions/playlists.js

@@ -10,11 +10,11 @@ const DBModule = moduleManager.modules.db;
 const UtilsModule = moduleManager.modules.utils;
 const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 const WSModule = moduleManager.modules.ws;
 const SongsModule = moduleManager.modules.songs;
 const SongsModule = moduleManager.modules.songs;
-const StationsModule = moduleManager.modules.stations;
 const CacheModule = moduleManager.modules.cache;
 const CacheModule = moduleManager.modules.cache;
 const PlaylistsModule = moduleManager.modules.playlists;
 const PlaylistsModule = moduleManager.modules.playlists;
 const YouTubeModule = moduleManager.modules.youtube;
 const YouTubeModule = moduleManager.modules.youtube;
 const ActivitiesModule = moduleManager.modules.activities;
 const ActivitiesModule = moduleManager.modules.activities;
+const MediaModule = moduleManager.modules.media;
 
 
 CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "playlist.create",
 	channel: "playlist.create",
@@ -1111,17 +1111,6 @@ export default {
 						.catch(next);
 						.catch(next);
 				},
 				},
 
 
-				(playlist, next) => {
-					async.each(
-						playlist.songs,
-						(song, nextSong) => {
-							if (song.youtubeId === youtubeId) return next("That song is already in the playlist");
-							return nextSong();
-						},
-						err => next(err, playlist)
-					);
-				},
-
 				(playlist, next) => {
 				(playlist, next) => {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
 						const oppositeType = playlist.type === "user-liked" ? "user-disliked" : "user-liked";
 						const oppositeType = playlist.type === "user-liked" ? "user-disliked" : "user-liked";
@@ -1141,66 +1130,12 @@ export default {
 				},
 				},
 
 
 				next => {
 				next => {
-					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-						.then(UserModel => {
-							UserModel.findOne(
-								{ _id: session.userId },
-								{ "preferences.anonymousSongRequests": 1 },
-								next
-							);
-						})
-						.catch(next);
-				},
-
-				(user, next) => {
-					SongsModule.runJob(
-						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
-						{
-							youtubeId,
-							userId: user.preferences.anonymousSongRequests ? null : session.userId,
-							automaticallyRequested: true
-						},
-						this
-					)
-						.then(response => {
-							const { song } = response;
-							const { _id, title, artists, thumbnail, duration, status } = song;
-							next(null, {
-								_id,
-								youtubeId,
-								title,
-								artists,
-								thumbnail,
-								duration,
-								status
-							});
+					PlaylistsModule.runJob("ADD_SONG_TO_PLAYLIST", { playlistId, youtubeId }, this)
+						.then(res => {
+							const { playlist, song, ratings } = res;
+							next(null, playlist, song, ratings);
 						})
 						})
 						.catch(next);
 						.catch(next);
-				},
-				(newSong, next) => {
-					playlistModel.updateOne(
-						{ _id: playlistId },
-						{ $push: { songs: newSong } },
-						{ runValidators: true },
-						err => {
-							if (err) return next(err);
-							return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-								.then(playlist => next(null, playlist, newSong))
-								.catch(next);
-						}
-					);
-				},
-				(playlist, newSong, next) => {
-					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
-							songId: newSong._id,
-							youtubeId: newSong.youtubeId
-						})
-							.then(ratings => next(null, playlist, newSong, ratings))
-							.catch(next);
-					} else {
-						next(null, playlist, newSong, null);
-					}
 				}
 				}
 			],
 			],
 			async (err, playlist, newSong, ratings) => {
 			async (err, playlist, newSong, ratings) => {
@@ -1237,14 +1172,6 @@ export default {
 					});
 					});
 				}
 				}
 
 
-				StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
-					.then(response => {
-						response.stationIds.forEach(stationId => {
-							PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
-						});
-					})
-					.catch();
-
 				CacheModule.runJob("PUB", {
 				CacheModule.runJob("PUB", {
 					channel: "playlist.addSong",
 					channel: "playlist.addSong",
 					value: {
 					value: {
@@ -1327,16 +1254,34 @@ export default {
 	 * @param {boolean} musicOnly - whether to only add music to the playlist
 	 * @param {boolean} musicOnly - whether to only add music to the playlist
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	addSetToPlaylist: isLoginRequired(function addSetToPlaylist(session, url, playlistId, musicOnly, cb) {
+	addSetToPlaylist: isLoginRequired(async function addSetToPlaylist(session, url, playlistId, musicOnly, cb) {
 		let videosInPlaylistTotal = 0;
 		let videosInPlaylistTotal = 0;
 		let songsInPlaylistTotal = 0;
 		let songsInPlaylistTotal = 0;
 		let addSongsStats = null;
 		let addSongsStats = null;
 
 
 		const addedSongs = [];
 		const addedSongs = [];
 
 
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Import YouTube playlist",
+			message: "Importing YouTube 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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
+					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 1)` });
 					YouTubeModule.runJob("GET_PLAYLIST", { url, musicOnly }, this)
 					YouTubeModule.runJob("GET_PLAYLIST", { url, musicOnly }, this)
 						.then(res => {
 						.then(res => {
 							if (res.filteredSongs) {
 							if (res.filteredSongs) {
@@ -1352,6 +1297,7 @@ export default {
 						});
 						});
 				},
 				},
 				(youtubeIds, next) => {
 				(youtubeIds, next) => {
+					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 2)` });
 					let successful = 0;
 					let successful = 0;
 					let failed = 0;
 					let failed = 0;
 					let alreadyInPlaylist = 0;
 					let alreadyInPlaylist = 0;
@@ -1414,12 +1360,14 @@ export default {
 				},
 				},
 
 
 				next => {
 				next => {
+					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 3)` });
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 						.then(playlist => next(null, playlist))
 						.then(playlist => next(null, playlist))
 						.catch(next);
 						.catch(next);
 				},
 				},
 
 
 				(playlist, next) => {
 				(playlist, next) => {
+					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 4)` });
 					if (!playlist || playlist.createdBy !== session.userId) {
 					if (!playlist || playlist.createdBy !== session.userId) {
 						return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
 						return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
 							userModel.findOne({ _id: session.userId }, (err, user) => {
 							userModel.findOne({ _id: session.userId }, (err, user) => {
@@ -1440,6 +1388,10 @@ export default {
 						"PLAYLIST_IMPORT",
 						"PLAYLIST_IMPORT",
 						`Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
 						`Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
 					);
 					);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 
 
@@ -1458,7 +1410,10 @@ export default {
 					"PLAYLIST_IMPORT",
 					"PLAYLIST_IMPORT",
 					`Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}, already in liked ${addSongsStats.alreadyInLikedPlaylist}, already in disliked ${addSongsStats.alreadyInDislikedPlaylist}.`
 					`Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}, already in liked ${addSongsStats.alreadyInLikedPlaylist}, already in disliked ${addSongsStats.alreadyInDislikedPlaylist}.`
 				);
 				);
-
+				this.publishProgress({
+					status: "success",
+					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`
+				});
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`,
 					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`,
@@ -1485,8 +1440,6 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	removeSongFromPlaylist: isLoginRequired(async function removeSongFromPlaylist(session, youtubeId, playlistId, cb) {
 	removeSongFromPlaylist: isLoginRequired(async function removeSongFromPlaylist(session, youtubeId, playlistId, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -1501,38 +1454,18 @@ export default {
 							if (!playlist || playlist.createdBy !== session.userId) {
 							if (!playlist || playlist.createdBy !== session.userId) {
 								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
 								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
 									userModel.findOne({ _id: session.userId }, (err, user) => {
 									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user && user.role === "admin") return next();
+										if (user && user.role === "admin") return next(null, playlist);
 										return next("Something went wrong when trying to get the playlist");
 										return next("Something went wrong when trying to get the playlist");
 									});
 									});
 								});
 								});
 							}
 							}
-							return next();
+							return next(null, playlist);
 						})
 						})
 						.catch(next);
 						.catch(next);
 				},
 				},
 
 
-				// remove song from playlist
-				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { youtubeId } } }, next),
-
-				// update cache representation of the playlist
-				(res, next) => {
-					if (res.modifiedCount === 1)
-						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-							.then(playlist => next(null, playlist))
-							.catch(next);
-					else next("Song wasn't in playlist.");
-				},
-
 				(playlist, next) => {
 				(playlist, next) => {
-					StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
-						.then(response => {
-							response.stationIds.forEach(stationId => {
-								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
-							});
-						})
-						.catch();
-
-					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
+					MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
 						.then(res =>
 						.then(res =>
 							next(null, playlist, {
 							next(null, playlist, {
 								_id: res.song._id,
 								_id: res.song._id,
@@ -1542,24 +1475,16 @@ export default {
 								youtubeId: res.song.youtubeId
 								youtubeId: res.song.youtubeId
 							})
 							})
 						)
 						)
-						.catch(() => {
-							YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
-								.then(response => next(null, playlist, response.song))
-								.catch(next);
-						});
+						.catch(next);
 				},
 				},
 
 
 				(playlist, newSong, next) => {
 				(playlist, newSong, next) => {
-					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
-						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
-							songId: newSong._id,
-							youtubeId: newSong.youtubeId
+					PlaylistsModule.runJob("REMOVE_FROM_PLAYLIST", { playlistId, youtubeId }, this)
+						.then(res => {
+							const { ratings } = res;
+							next(null, playlist, newSong, ratings);
 						})
 						})
-							.then(ratings => next(null, playlist, newSong, ratings))
-							.catch(next);
-					} else {
-						next(null, playlist, newSong, null);
-					}
+						.catch(next);
 				},
 				},
 
 
 				(playlist, newSong, ratings, next) => {
 				(playlist, newSong, ratings, next) => {
@@ -1586,7 +1511,7 @@ export default {
 
 
 						if (playlist.type === "user-liked") {
 						if (playlist.type === "user-liked") {
 							CacheModule.runJob("PUB", {
 							CacheModule.runJob("PUB", {
-								channel: "song.unlike",
+								channel: "ratings.unlike",
 								value: JSON.stringify({
 								value: JSON.stringify({
 									youtubeId: newSong.youtubeId,
 									youtubeId: newSong.youtubeId,
 									userId: session.userId,
 									userId: session.userId,
@@ -1661,11 +1586,6 @@ export default {
 					}
 					}
 				});
 				});
 
 
-				CacheModule.runJob("PUB", {
-					channel: "playlist.updated",
-					value: { playlistId }
-				});
-
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Song has been successfully removed from playlist",
 					message: "Song has been successfully removed from playlist",
@@ -2072,6 +1992,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	deleteOrphanedStationPlaylists: isAdminRequired(async function index(session, cb) {
 	deleteOrphanedStationPlaylists: isAdminRequired(async function index(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Delete orphaned station playlists",
+			message: "Deleting orphaned station playlists.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2088,6 +2025,10 @@ export default {
 						"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
 						"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
 						`Deleting orphaned station playlists failed. "${err}"`
 						`Deleting orphaned station playlists failed. "${err}"`
 					);
 					);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 				this.log(
 				this.log(
@@ -2095,6 +2036,10 @@ export default {
 					"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
 					"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
 					"Deleting orphaned station playlists successful."
 					"Deleting orphaned station playlists successful."
 				);
 				);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully deleted orphaned station playlists."
+				});
 				return cb({ status: "success", message: "Successfully deleted orphaned station playlists." });
 				return cb({ status: "success", message: "Successfully deleted orphaned station playlists." });
 			}
 			}
 		);
 		);
@@ -2107,6 +2052,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	deleteOrphanedGenrePlaylists: isAdminRequired(async function index(session, cb) {
 	deleteOrphanedGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Delete orphaned genre playlists",
+			message: "Deleting orphaned genre playlists.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2123,6 +2085,10 @@ export default {
 						"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
 						"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
 						`Deleting orphaned genre playlists failed. "${err}"`
 						`Deleting orphaned genre playlists failed. "${err}"`
 					);
 					);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 				this.log(
 				this.log(
@@ -2130,6 +2096,10 @@ export default {
 					"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
 					"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
 					"Deleting orphaned genre playlists successful."
 					"Deleting orphaned genre playlists successful."
 				);
 				);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully deleted orphaned genre playlists."
+				});
 				return cb({ status: "success", message: "Successfully deleted orphaned genre playlists." });
 				return cb({ status: "success", message: "Successfully deleted orphaned genre playlists." });
 			}
 			}
 		);
 		);
@@ -2177,6 +2147,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	requestOrphanedPlaylistSongs: isAdminRequired(async function index(session, cb) {
 	requestOrphanedPlaylistSongs: isAdminRequired(async function index(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Request orphaned playlist songs",
+			message: "Requesting orphaned playlist songs.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2193,6 +2180,10 @@ export default {
 						"REQUEST_ORPHANED_PLAYLIST_SONGS",
 						"REQUEST_ORPHANED_PLAYLIST_SONGS",
 						`Requesting orphaned playlist songs failed. "${err}"`
 						`Requesting orphaned playlist songs failed. "${err}"`
 					);
 					);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 				this.log(
 				this.log(
@@ -2200,6 +2191,10 @@ export default {
 					"REQUEST_ORPHANED_PLAYLIST_SONGS",
 					"REQUEST_ORPHANED_PLAYLIST_SONGS",
 					"Requesting orphaned playlist songs was successful."
 					"Requesting orphaned playlist songs was successful."
 				);
 				);
+				this.publishProgress({
+					status: "success",
+					message: "Successfully requested orphaned playlist songs."
+				});
 				return cb({ status: "success", message: "Successfully requested orphaned playlist songs." });
 				return cb({ status: "success", message: "Successfully requested orphaned playlist songs." });
 			}
 			}
 		);
 		);
@@ -2362,6 +2357,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	clearAndRefillAllStationPlaylists: isAdminRequired(async function index(session, cb) {
 	clearAndRefillAllStationPlaylists: isAdminRequired(async function index(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Clear and refill all station playlists",
+			message: "Clearing and refilling all station playlists.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2379,6 +2391,10 @@ export default {
 						playlists,
 						playlists,
 						1,
 						1,
 						(playlist, next) => {
 						(playlist, next) => {
+							this.publishProgress({
+								status: "update",
+								message: `Clearing and refilling "${playlist._id}"`
+							});
 							PlaylistsModule.runJob(
 							PlaylistsModule.runJob(
 								"CLEAR_AND_REFILL_STATION_PLAYLIST",
 								"CLEAR_AND_REFILL_STATION_PLAYLIST",
 								{ playlistId: playlist._id },
 								{ playlistId: playlist._id },
@@ -2404,7 +2420,10 @@ export default {
 						"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
 						"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
 						`Clearing and refilling all station playlists failed for user "${session.userId}". "${err}"`
 						`Clearing and refilling all station playlists failed for user "${session.userId}". "${err}"`
 					);
 					);
-
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 
 
@@ -2413,7 +2432,10 @@ export default {
 					"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
 					"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
 					`Successfully cleared and refilled all station playlists for user "${session.userId}".`
 					`Successfully cleared and refilled all station playlists for user "${session.userId}".`
 				);
 				);
-
+				this.publishProgress({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled."
+				});
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Playlists have been successfully cleared and refilled"
 					message: "Playlists have been successfully cleared and refilled"
@@ -2429,6 +2451,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	clearAndRefillAllGenrePlaylists: isAdminRequired(async function index(session, cb) {
 	clearAndRefillAllGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Clear and refill all genre playlists",
+			message: "Clearing and refilling all genre playlists.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2446,6 +2485,10 @@ export default {
 						playlists,
 						playlists,
 						1,
 						1,
 						(playlist, next) => {
 						(playlist, next) => {
+							this.publishProgress({
+								status: "update",
+								message: `Clearing and refilling "${playlist._id}"`
+							});
 							PlaylistsModule.runJob(
 							PlaylistsModule.runJob(
 								"CLEAR_AND_REFILL_GENRE_PLAYLIST",
 								"CLEAR_AND_REFILL_GENRE_PLAYLIST",
 								{ playlistId: playlist._id },
 								{ playlistId: playlist._id },
@@ -2471,7 +2514,10 @@ export default {
 						"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
 						"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
 						`Clearing and refilling all genre playlists failed for user "${session.userId}". "${err}"`
 						`Clearing and refilling all genre playlists failed for user "${session.userId}". "${err}"`
 					);
 					);
-
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 
 
@@ -2480,7 +2526,10 @@ export default {
 					"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
 					"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
 					`Successfully cleared and refilled all genre playlists for user "${session.userId}".`
 					`Successfully cleared and refilled all genre playlists for user "${session.userId}".`
 				);
 				);
-
+				this.publishProgress({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled."
+				});
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Playlists have been successfully cleared and refilled"
 					message: "Playlists have been successfully cleared and refilled"
@@ -2581,6 +2630,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	createMissingGenrePlaylists: isAdminRequired(async function index(session, cb) {
 	createMissingGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Create missing genre playlists",
+			message: "Creating missing genre playlists.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2602,7 +2668,10 @@ export default {
 						"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
 						"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
 						`Creating missing genre playlists failed for user "${session.userId}". "${err}"`
 						`Creating missing genre playlists failed for user "${session.userId}". "${err}"`
 					);
 					);
-
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 
 
@@ -2611,7 +2680,10 @@ export default {
 					"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
 					"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
 					`Successfully created missing genre playlists for user "${session.userId}".`
 					`Successfully created missing genre playlists for user "${session.userId}".`
 				);
 				);
-
+				this.publishProgress({
+					status: "success",
+					message: "Missing genre playlists have been successfully created."
+				});
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Missing genre playlists have been successfully created"
 					message: "Missing genre playlists have been successfully created"

File diff suppressed because it is too large
+ 330 - 984
backend/logic/actions/songs.js


+ 46 - 287
backend/logic/actions/stations.js

@@ -10,7 +10,6 @@ import moduleManager from "../../index";
 const DBModule = moduleManager.modules.db;
 const DBModule = moduleManager.modules.db;
 const UtilsModule = moduleManager.modules.utils;
 const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 const WSModule = moduleManager.modules.ws;
-const SongsModule = moduleManager.modules.songs;
 const PlaylistsModule = moduleManager.modules.playlists;
 const PlaylistsModule = moduleManager.modules.playlists;
 const CacheModule = moduleManager.modules.cache;
 const CacheModule = moduleManager.modules.cache;
 const NotificationsModule = moduleManager.modules.notifications;
 const NotificationsModule = moduleManager.modules.notifications;
@@ -878,26 +877,7 @@ export default {
 
 
 					data.currentSong.skipVotes = data.currentSong.skipVotes.length;
 					data.currentSong.skipVotes = data.currentSong.skipVotes.length;
 
 
-					return SongsModule.runJob(
-						"GET_SONG_FROM_YOUTUBE_ID",
-						{ youtubeId: data.currentSong.youtubeId },
-						this
-					)
-						.then(response => {
-							const { song } = response;
-							if (song) {
-								data.currentSong.likes = song.likes;
-								data.currentSong.dislikes = song.dislikes;
-							} else {
-								data.currentSong.likes = -1;
-								data.currentSong.dislikes = -1;
-							}
-						})
-						.catch(() => {
-							data.currentSong.likes = -1;
-							data.currentSong.dislikes = -1;
-						})
-						.finally(() => next(null, data));
+					return next(null, data);
 				},
 				},
 
 
 				(data, next) => {
 				(data, next) => {
@@ -1174,9 +1154,6 @@ export default {
 	voteSkip: isLoginRequired(async function voteSkip(session, stationId, cb) {
 	voteSkip: isLoginRequired(async function voteSkip(session, stationId, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 
 
-		let skipVotes = 0;
-		let shouldSkip = false;
-
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -1220,43 +1197,10 @@ export default {
 
 
 				(station, next) => {
 				(station, next) => {
 					if (!station) return next("Station not found.");
 					if (!station) return next("Station not found.");
-					return next(null, station);
-				},
 
 
-				(station, next) => {
-					skipVotes = station.currentSong.skipVotes.length;
-					WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${stationId}` }, this)
-						.then(sockets => next(null, sockets))
+					return StationsModule.runJob("PROCESS_VOTE_SKIPS", { stationId }, this)
+						.then(() => next())
 						.catch(next);
 						.catch(next);
-				},
-
-				(sockets, next) => {
-					if (sockets.length <= skipVotes) {
-						shouldSkip = true;
-						return next();
-					}
-
-					const users = [];
-
-					return async.each(
-						sockets,
-						(socketId, next) => {
-							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
-								.then(socket => {
-									if (socket && socket.session && socket.session.userId) {
-										if (!users.includes(socket.session.userId)) users.push(socket.session.userId);
-									}
-									return next();
-								})
-								.catch(next);
-						},
-						err => {
-							if (err) return next(err);
-
-							if (users.length <= skipVotes) shouldSkip = true;
-							return next();
-						}
-					);
 				}
 				}
 			],
 			],
 			async err => {
 			async err => {
@@ -1272,10 +1216,6 @@ export default {
 					value: stationId
 					value: stationId
 				});
 				});
 
 
-				if (shouldSkip) {
-					StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
-				}
-
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Successfully voted to skip the song."
 					message: "Successfully voted to skip the song."
@@ -1505,9 +1445,7 @@ export default {
 
 
 				(res, next) => {
 				(res, next) => {
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
-						.then(station => {
-							next(null, station);
-						})
+						.then(() => next())
 						.catch(next);
 						.catch(next);
 				}
 				}
 			],
 			],
@@ -1574,9 +1512,13 @@ export default {
 
 
 				(res, next) => {
 				(res, next) => {
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
 					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
-						.then(station => {
-							next(null, station);
-						})
+						.then(() => next())
+						.catch(next);
+				},
+
+				next => {
+					StationsModule.runJob("PROCESS_VOTE_SKIPS", { stationId }, this)
+						.then(() => next())
 						.catch(next);
 						.catch(next);
 				}
 				}
 			],
 			],
@@ -1863,14 +1805,6 @@ export default {
 	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
 	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 
-		const stationModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "station"
-			},
-			this
-		);
-
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -1910,185 +1844,23 @@ export default {
 						this
 						this
 					)
 					)
 						.then(canView => {
 						.then(canView => {
-							if (canView) return next(null, station);
+							if (canView) return next();
 							return next("Insufficient permissions.");
 							return next("Insufficient permissions.");
 						})
 						})
 						.catch(err => next(err)),
 						.catch(err => next(err)),
 
 
-				(station, next) => {
-					if (station.currentSong && station.currentSong.youtubeId === youtubeId)
-						return next("That song is currently playing.");
-
-					return async.each(
-						station.queue,
-						(queueSong, next) => {
-							if (queueSong.youtubeId === youtubeId) return next("That song is already in the queue.");
-							return next();
-						},
-						err => next(err, station)
-					);
-				},
-
-				(station, next) => {
-					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-						.then(UserModel => {
-							UserModel.findOne(
-								{ _id: session.userId },
-								{ "preferences.anonymousSongRequests": 1 },
-								(err, user) => next(err, station, user)
-							);
-						})
-						.catch(next);
-				},
-
-				(station, user, next) => {
-					SongsModule.runJob(
-						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+				next =>
+					StationsModule.runJob(
+						"ADD_TO_QUEUE",
 						{
 						{
+							stationId,
 							youtubeId,
 							youtubeId,
-							userId: user.preferences.anonymousSongRequests ? null : session.userId,
-							automaticallyRequested: true
+							requestUser: session.userId
 						},
 						},
 						this
 						this
 					)
 					)
-						.then(response => {
-							const { song } = response;
-							const { _id, title, skipDuration, artists, thumbnail, duration, verified } = song;
-							next(
-								null,
-								{
-									_id,
-									youtubeId,
-									title,
-									skipDuration,
-									artists,
-									thumbnail,
-									duration,
-									verified
-								},
-								station
-							);
-						})
-						.catch(next);
-				},
-
-				(song, station, next) => {
-					const blacklist = [];
-					async.eachLimit(
-						station.blacklist,
-						1,
-						(playlistId, next) => {
-							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-								.then(playlist => {
-									blacklist.push(playlist);
-									next();
-								})
-								.catch(next);
-						},
-						err => {
-							next(err, song, station, blacklist);
-						}
-					);
-				},
-
-				(song, station, blacklist, next) => {
-					const blacklistedSongs = blacklist
-						.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
-						.reduce(
-							(items, item) =>
-								items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
-							[]
-						);
-
-					if (
-						blacklistedSongs.find(blacklistedSong => blacklistedSong._id.toString() === song._id.toString())
-					)
-						next("That song is in an blacklisted playlist and cannot be played.");
-					else next(null, song, station);
-				},
-
-				(song, station, next) => {
-					song.requestedBy = session.userId;
-					song.requestedAt = Date.now();
-					return next(null, song, station);
-				},
-
-				(song, station, next) => {
-					if (station.queue.length === 0) return next(null, song);
-					let totalSongs = 0;
-					station.queue.forEach(song => {
-						if (session.userId === song.requestedBy) {
-							totalSongs += 1;
-						}
-					});
-
-					if (totalSongs >= station.requests.limit)
-						return next(`The max amount of songs per user is ${station.requests.limit}.`);
-
-					return next(null, song);
-				},
-
-				// (song, station, next) => {
-				// 	song.requestedBy = session.userId;
-				// 	song.requestedAt = Date.now();
-				// 	let totalDuration = 0;
-				// 	station.queue.forEach(song => {
-				// 		totalDuration += song.duration;
-				// 	});
-				// 	if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
-				// 	return next(null, song, station);
-				// },
-
-				// (song, station, next) => {
-				// 	if (station.queue.length === 0) return next(null, song, station);
-				// 	let totalDuration = 0;
-				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
-				// 	station.queue.forEach(song => {
-				// 		if (userId === song.requestedBy) {
-				// 			totalDuration += song.duration;
-				// 		}
-				// 	});
-
-				// 	if (totalDuration >= 900) return next("The max length of songs per user is 15 minutes.");
-				// 	return next(null, song, station);
-				// },
-
-				// (song, station, next) => {
-				// 	if (station.queue.length === 0) return next(null, song);
-				// 	let totalSongs = 0;
-				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
-				// 	station.queue.forEach(song => {
-				// 		if (userId === song.requestedBy) {
-				// 			totalSongs += 1;
-				// 		}
-				// 	});
-
-				// 	if (totalSongs <= 2) return next(null, song);
-				// 	if (totalSongs > 3)
-				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
-				// 	if (
-				// 		station.queue[station.queue.length - 2].requestedBy !== userId ||
-				// 		station.queue[station.queue.length - 3] !== userId
-				// 	)
-				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
-
-				// 	return next(null, song);
-				// },
-
-				(song, next) => {
-					stationModel.updateOne(
-						{ _id: stationId },
-						{ $push: { queue: song } },
-						{ runValidators: true },
-						next
-					);
-				},
-
-				(res, next) => {
-					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
-						.then(station => next(null, station))
-						.catch(next);
-				}
+						.then(() => next())
+						.catch(next)
 			],
 			],
 			async err => {
 			async err => {
 				if (err) {
 				if (err) {
@@ -2107,11 +1879,6 @@ export default {
 					`Added song "${youtubeId}" to station "${stationId}" successfully.`
 					`Added song "${youtubeId}" to station "${stationId}" successfully.`
 				);
 				);
 
 
-				CacheModule.runJob("PUB", {
-					channel: "station.queueUpdate",
-					value: stationId
-				});
-
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Successfully added song to queue."
 					message: "Successfully added song to queue."
@@ -2129,40 +1896,12 @@ export default {
 	 * @param cb
 	 * @param cb
 	 */
 	 */
 	removeFromQueue: isOwnerRequired(async function removeFromQueue(session, stationId, youtubeId, cb) {
 	removeFromQueue: isOwnerRequired(async function removeFromQueue(session, stationId, youtubeId, cb) {
-		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
-
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
 					if (!youtubeId) return next("Invalid youtube id.");
 					if (!youtubeId) return next("Invalid youtube id.");
-					return StationsModule.runJob("GET_STATION", { stationId }, this)
-						.then(station => next(null, station))
-						.catch(next);
-				},
-
-				(station, next) => {
-					if (!station) return next("Station not found.");
-
-					return async.each(
-						station.queue,
-						(queueSong, next) => {
-							if (queueSong.youtubeId === youtubeId) return next(true);
-							return next();
-						},
-						err => {
-							if (err === true) return next();
-							return next("Song is not currently in the queue.");
-						}
-					);
-				},
-
-				next => {
-					stationModel.updateOne({ _id: stationId }, { $pull: { queue: { youtubeId } } }, next);
-				},
-
-				(res, next) => {
-					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
-						.then(station => next(null, station))
+					return StationsModule.runJob("REMOVE_FROM_QUEUE", { stationId, youtubeId }, this)
+						.then(() => next())
 						.catch(next);
 						.catch(next);
 				}
 				}
 			],
 			],
@@ -2183,11 +1922,6 @@ export default {
 					`Removed song "${youtubeId}" from station "${stationId}" successfully.`
 					`Removed song "${youtubeId}" from station "${stationId}" successfully.`
 				);
 				);
 
 
-				CacheModule.runJob("PUB", {
-					channel: "station.queueUpdate",
-					value: stationId
-				});
-
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
 					message: "Successfully removed song from queue."
 					message: "Successfully removed song from queue."
@@ -2735,6 +2469,23 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	clearEveryStationQueue: isAdminRequired(async function clearEveryStationQueue(session, cb) {
 	clearEveryStationQueue: isAdminRequired(async function clearEveryStationQueue(session, cb) {
+		this.keepLongJob();
+		this.publishProgress({
+			status: "started",
+			title: "Clear every station queue",
+			message: "Clearing every station queue.",
+			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(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2747,9 +2498,17 @@ export default {
 				if (err) {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
 					this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 				this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successful.");
 				this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successful.");
+				this.publishProgress({
+					status: "success",
+					message: "Successfully cleared every station queue."
+				});
 				return cb({ status: "success", message: "Successfully cleared every station queue." });
 				return cb({ status: "success", message: "Successfully cleared every station queue." });
 			}
 			}
 		);
 		);

+ 238 - 44
backend/logic/actions/users.js

@@ -1,6 +1,7 @@
 import config from "config";
 import config from "config";
 
 
 import async from "async";
 import async from "async";
+import mongoose from "mongoose";
 
 
 import axios from "axios";
 import axios from "axios";
 import bcrypt from "bcrypt";
 import bcrypt from "bcrypt";
@@ -16,9 +17,9 @@ const WSModule = moduleManager.modules.ws;
 const CacheModule = moduleManager.modules.cache;
 const CacheModule = moduleManager.modules.cache;
 const MailModule = moduleManager.modules.mail;
 const MailModule = moduleManager.modules.mail;
 const PunishmentsModule = moduleManager.modules.punishments;
 const PunishmentsModule = moduleManager.modules.punishments;
-const SongsModule = moduleManager.modules.songs;
 const ActivitiesModule = moduleManager.modules.activities;
 const ActivitiesModule = moduleManager.modules.activities;
 const PlaylistsModule = moduleManager.modules.playlists;
 const PlaylistsModule = moduleManager.modules.playlists;
+const MediaModule = moduleManager.modules.media;
 
 
 CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "user.updatePreferences",
 	channel: "user.updatePreferences",
@@ -201,6 +202,36 @@ CacheModule.runJob("SUB", {
 	}
 	}
 });
 });
 
 
+CacheModule.runJob("SUB", {
+	channel: "longJob.removed",
+	cb: ({ jobId, userId }) => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:longJob.removed", {
+					data: {
+						jobId
+					}
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "longJob.added",
+	cb: ({ jobId, userId }) => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:longJob.added", {
+					data: {
+						jobId
+					}
+				});
+			});
+		});
+	}
+});
+
 export default {
 export default {
 	/**
 	/**
 	 * Gets users, used in the admin users page by the AdvancedTable component
 	 * Gets users, used in the admin users page by the AdvancedTable component
@@ -357,9 +388,7 @@ export default {
 				(playlist, next) => {
 				(playlist, next) => {
 					if (!playlist) return next();
 					if (!playlist) return next();
 
 
-					playlist.songs.forEach(song =>
-						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
-					);
+					playlist.songs.forEach(song => songsToAdjustRatings.push({ youtubeId: song.youtubeId }));
 
 
 					return next();
 					return next();
 				},
 				},
@@ -373,9 +402,9 @@ export default {
 					async.each(
 					async.each(
 						songsToAdjustRatings,
 						songsToAdjustRatings,
 						(song, next) => {
 						(song, next) => {
-							const { songId, youtubeId } = song;
+							const { youtubeId } = song;
 
 
-							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+							MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
 								.then(() => next())
 								.then(() => next())
 								.catch(next);
 								.catch(next);
 						},
 						},
@@ -585,9 +614,7 @@ export default {
 				(playlist, next) => {
 				(playlist, next) => {
 					if (!playlist) return next();
 					if (!playlist) return next();
 
 
-					playlist.songs.forEach(song =>
-						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
-					);
+					playlist.songs.forEach(song => songsToAdjustRatings.push({ youtubeId: song.youtubeId }));
 
 
 					return next();
 					return next();
 				},
 				},
@@ -601,9 +628,9 @@ export default {
 					async.each(
 					async.each(
 						songsToAdjustRatings,
 						songsToAdjustRatings,
 						(song, next) => {
 						(song, next) => {
-							const { songId, youtubeId } = song;
+							const { youtubeId } = song;
 
 
-							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+							MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
 								.then(() => next())
 								.then(() => next())
 								.catch(next);
 								.catch(next);
 						},
 						},
@@ -1560,19 +1587,20 @@ export default {
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Gets user object from username (only a few properties)
+	 * Gets user object from ObjectId or username (only a few properties)
 	 *
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} username - the username of the user we are trying to find
+	 * @param {string} identifier - the ObjectId or username of the user we are trying to find
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	findByUsername: async function findByUsername(session, username, cb) {
+	getBasicUser: async function getBasicUser(session, identifier, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
-					userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
+					if (mongoose.Types.ObjectId.isValid(identifier)) userModel.findOne({ _id: identifier }, next);
+					else userModel.findOne({ username: new RegExp(`^${identifier}$`, "i") }, next);
 				},
 				},
 
 
 				(account, next) => {
 				(account, next) => {
@@ -1584,12 +1612,12 @@ export default {
 				if (err && err !== true) {
 				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
 
-					this.log("ERROR", "FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
+					this.log("ERROR", "GET_BASIC_USER", `User not found for "${identifier}". "${err}"`);
 
 
 					return cb({ status: "error", message: err });
 					return cb({ status: "error", message: err });
 				}
 				}
 
 
-				this.log("SUCCESS", "FIND_BY_USERNAME", `User found for username "${username}".`);
+				this.log("SUCCESS", "GET_BASIC_USER", `User found for "${identifier}".`);
 
 
 				return cb({
 				return cb({
 					status: "success",
 					status: "success",
@@ -1609,49 +1637,215 @@ export default {
 	},
 	},
 
 
 	/**
 	/**
-	 * Gets a username from an userId
+	 * Gets a list of long jobs, including onprogress events when those long jobs have progress
 	 *
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} userId - the userId of the person we are trying to get the username from
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	async getUsernameFromId(session, userId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		userModel
-			.findById(userId)
-			.then(user => {
-				if (user) {
-					this.log("SUCCESS", "GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+	getLongJobs: isLoginRequired(async function getLongJobs(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"LRANGE",
+						{
+							key: `longJobs.${session.userId}`
+						},
+						this
+					)
+						.then(longJobUuids => next(null, longJobUuids))
+						.catch(next);
+				},
 
 
-					return cb({
-						status: "success",
-						data: { username: user.username }
+				(longJobUuids, next) => {
+					next(
+						null,
+						longJobUuids
+							.map(longJobUuid => moduleManager.jobManager.getJob(longJobUuid))
+							.filter(longJob => !!longJob)
+					);
+				},
+
+				(longJobs, next) => {
+					longJobs.forEach(longJob => {
+						if (longJob.onProgress)
+							longJob.onProgress.on("progress", data => {
+								this.publishProgress(
+									{
+										id: longJob.toString(),
+										...data
+									},
+									true
+								);
+							});
 					});
 					});
+
+					next(
+						null,
+						longJobs.map(longJob => ({
+							id: longJob.toString(),
+							name: longJob.longJobTitle,
+							status: longJob.lastProgressData.status,
+							message: longJob.lastProgressData.message
+						}))
+					);
 				}
 				}
+			],
+			async (err, longJobs) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
 
-				this.log(
-					"ERROR",
-					"GET_USERNAME_FROM_ID",
-					`Getting the username from userId "${userId}" failed. User not found.`
-				);
+					this.log("ERROR", "GET_LONG_JOBS", `Couldn't get long jobs for user "${session.userId}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "GET_LONG_JOBS", `Got long jobs for user "${session.userId}".`);
 
 
 				return cb({
 				return cb({
-					status: "error",
-					message: "Couldn't find the user."
+					status: "success",
+					data: {
+						longJobs
+					}
 				});
 				});
-			})
-			.catch(async err => {
-				if (err && err !== true) {
+			}
+		);
+	}),
+
+	/**
+	 * Gets a specific long job, including onprogress events when that long job has progress
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} jobId - the if id the long job
+	 * @param {Function} cb - gets called with the result
+	 */
+	getLongJob: isLoginRequired(async function getLongJobs(session, jobId, cb) {
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"LRANGE",
+						{
+							key: `longJobs.${session.userId}`
+						},
+						this
+					)
+						.then(longJobUuids => next(null, longJobUuids))
+						.catch(next);
+				},
+
+				(longJobUuids, next) => {
+					if (longJobUuids.indexOf(jobId) === -1) return next("Long job not found.");
+					const longJob = moduleManager.jobManager.getJob(jobId);
+					if (!longJob) return next("Long job not found.");
+					return next(null, longJob);
+				},
+
+				(longJob, next) => {
+					if (longJob.onProgress)
+						longJob.onProgress.on("progress", data => {
+							this.publishProgress(
+								{
+									id: longJob.toString(),
+									...data
+								},
+								true
+							);
+						});
+
+					next(null, {
+						id: longJob.toString(),
+						name: longJob.longJobTitle,
+						status: longJob.lastProgressData.status,
+						message: longJob.lastProgressData.message
+					});
+				}
+			],
+			async (err, longJob) => {
+				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
 					this.log(
 					this.log(
 						"ERROR",
 						"ERROR",
-						"GET_USERNAME_FROM_ID",
-						`Getting the username from userId "${userId}" failed. "${err}"`
+						"GET_LONG_JOB",
+						`Couldn't get long job for user "${session.userId}" with id "${jobId}". "${err}"`
 					);
 					);
-					cb({ status: "error", message: err });
+
+					return cb({ status: "error", message: err });
 				}
 				}
-			});
-	},
+
+				this.log("SUCCESS", "GET_LONG_JOB", `Got long job for user "${session.userId}" with id "${jobId}".`);
+
+				return cb({
+					status: "success",
+					data: {
+						longJob
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes active long job for a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} jobId - array of playlist ids (with a specific order)
+	 * @param {Function} cb - gets called with the result
+	 */
+	removeLongJob: isLoginRequired(async function removeLongJob(session, jobId, cb) {
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"LREM",
+						{
+							key: `longJobs.${session.userId}`,
+							value: jobId
+						},
+						this
+					)
+						.then(() => next())
+						.catch(next);
+				},
+
+				next => {
+					const job = moduleManager.jobManager.getJob(jobId);
+					if (job && job.status === "FINISHED") job.forgetLongJob();
+					next();
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"REMOVE_LONG_JOB",
+						`Couldn't remove long job for user "${session.userId}" with id ${jobId}. "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"REMOVE_LONG_JOB",
+					`Removed long job for user "${session.userId}" with id ${jobId}.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "longJob.removed",
+					value: { jobId, userId: session.userId }
+				});
+
+				return cb({
+					status: "success",
+					message: "Removed long job successfully."
+				});
+			}
+		);
+	}),
 
 
 	/**
 	/**
 	 * Gets a user from a userId
 	 * Gets a user from a userId

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

@@ -0,0 +1,555 @@
+import mongoose from "mongoose";
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+// 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: isAdminRequired(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: isAdminRequired(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: isAdminRequired(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: isAdminRequired(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: isAdminRequired(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: isAdminRequired(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
+	 */
+	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: {},
+							specialFilters: {
+								importJob: importJobId => [
+									{
+										$lookup: {
+											from: "importjobs",
+											let: { youtubeId: "$youtubeId" },
+											pipeline: [
+												{
+													$match: {
+														_id: mongoose.Types.ObjectId(importJobId)
+													}
+												},
+												{
+													$addFields: {
+														importJob: {
+															$in: ["$$youtubeId", "$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", "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: isLoginRequired(function getVideo(session, identifier, createMissing, cb) {
+		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: isAdminRequired(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: isAdminRequired(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
+				});
+			}
+		);
+	})
+};

+ 136 - 1
backend/logic/cache/index.js

@@ -1,3 +1,4 @@
+import async from "async";
 import config from "config";
 import config from "config";
 import redis from "redis";
 import redis from "redis";
 import mongoose from "mongoose";
 import mongoose from "mongoose";
@@ -37,7 +38,8 @@ class _CacheModule extends CoreClass {
 			officialPlaylist: await importSchema("officialPlaylist"),
 			officialPlaylist: await importSchema("officialPlaylist"),
 			song: await importSchema("song"),
 			song: await importSchema("song"),
 			punishment: await importSchema("punishment"),
 			punishment: await importSchema("punishment"),
-			recentActivity: await importSchema("recentActivity")
+			recentActivity: await importSchema("recentActivity"),
+			ratings: await importSchema("ratings")
 		};
 		};
 
 
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
@@ -63,6 +65,15 @@ class _CacheModule extends CoreClass {
 				}
 				}
 			});
 			});
 
 
+			// TODO move to a better place
+			CacheModule.runJob("KEYS", { pattern: "longJobs.*" }).then(keys => {
+				async.eachLimit(keys, 1, (key, next) => {
+					CacheModule.runJob("DEL", { key }).finally(() => {
+						next();
+					});
+				});
+			});
+
 			this.client.on("error", err => {
 			this.client.on("error", err => {
 				if (this.getStatus() === "INITIALIZING") reject(err);
 				if (this.getStatus() === "INITIALIZING") reject(err);
 				if (this.getStatus() === "LOCKDOWN") return;
 				if (this.getStatus() === "LOCKDOWN") return;
@@ -225,6 +236,34 @@ class _CacheModule extends CoreClass {
 		});
 		});
 	}
 	}
 
 
+	/**
+	 * Deletes a single value
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key - name of the key to delete
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	DEL(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+
+			if (!key) {
+				reject(new Error("Invalid key!"));
+				return;
+			}
+
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+			CacheModule.client.del(key, err => {
+				if (err) {
+					reject(new Error(err));
+					return;
+				}
+				resolve();
+			});
+		});
+	}
+
 	/**
 	/**
 	 * Publish a message to a channel, caches the redis client connection
 	 * Publish a message to a channel, caches the redis client connection
 	 *
 	 *
@@ -302,6 +341,102 @@ class _CacheModule extends CoreClass {
 		});
 		});
 	}
 	}
 
 
+	/**
+	 * Gets a full list from Redis
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key - name of the table to get the value from (table === redis hash)
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	LRANGE(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+
+			if (!key) {
+				reject(new Error("Invalid key!"));
+				return;
+			}
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+			CacheModule.client.LRANGE(key, 0, -1, (err, list) => {
+				if (err) {
+					reject(new Error(err));
+					return;
+				}
+
+				resolve(list);
+			});
+		});
+	}
+
+	/**
+	 * Adds a value to a list in Redis
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key -  name of the list
+	 * @param {*} payload.value - the value we want to set
+	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	RPUSH(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+			let { value } = payload;
+
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+			// automatically stringify objects and arrays into JSON
+			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
+
+			CacheModule.client.RPUSH(key, value, err => {
+				if (err) return reject(new Error(err));
+				return resolve();
+			});
+		});
+	}
+
+	/**
+	 * Removes a value from a list in Redis
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.key -  name of the list
+	 * @param {*} payload.value - the value we want to remove
+	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	LREM(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+			let { value } = payload;
+
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+			// automatically stringify objects and arrays into JSON
+			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
+
+			CacheModule.client.LREM(key, 1, value, err => {
+				if (err) return reject(new Error(err));
+				return resolve();
+			});
+		});
+	}
+
+	/**
+	 * Gets a list of keys in Redis with a matching pattern
+	 *
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.pattern -  pattern to search for
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	KEYS(payload) {
+		return new Promise((resolve, reject) => {
+			const { pattern } = payload;
+
+			CacheModule.client.KEYS(pattern, (err, keys) => {
+				if (err) return reject(new Error(err));
+				return resolve(keys);
+			});
+		});
+	}
+
 	/**
 	/**
 	 * Returns a redis schema
 	 * Returns a redis schema
 	 *
 	 *

+ 1 - 0
backend/logic/cache/schemas/ratings.js

@@ -0,0 +1 @@
+export default ratings => ratings;

+ 27 - 5
backend/logic/db/index.js

@@ -12,9 +12,13 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	punishment: 1,
 	punishment: 1,
 	queueSong: 1,
 	queueSong: 1,
 	report: 5,
 	report: 5,
-	song: 8,
+	song: 9,
 	station: 8,
 	station: 8,
-	user: 3
+	user: 3,
+	youtubeApiRequest: 1,
+	youtubeVideo: 1,
+	ratings: 1,
+	importJob: 1
 };
 };
 
 
 const regex = {
 const regex = {
@@ -68,7 +72,10 @@ class _DBModule extends CoreClass {
 						playlist: {},
 						playlist: {},
 						news: {},
 						news: {},
 						report: {},
 						report: {},
-						punishment: {}
+						punishment: {},
+						youtubeApiRequest: {},
+						youtubeVideo: {},
+						ratings: {}
 					};
 					};
 
 
 					const importSchema = schemaName =>
 					const importSchema = schemaName =>
@@ -89,6 +96,10 @@ class _DBModule extends CoreClass {
 					await importSchema("news");
 					await importSchema("news");
 					await importSchema("report");
 					await importSchema("report");
 					await importSchema("punishment");
 					await importSchema("punishment");
+					await importSchema("youtubeApiRequest");
+					await importSchema("youtubeVideo");
+					await importSchema("ratings");
+					await importSchema("importJob");
 
 
 					this.models = {
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
 						song: mongoose.model("song", this.schemas.song),
@@ -100,7 +111,11 @@ class _DBModule extends CoreClass {
 						playlist: mongoose.model("playlist", this.schemas.playlist),
 						playlist: mongoose.model("playlist", this.schemas.playlist),
 						news: mongoose.model("news", this.schemas.news),
 						news: mongoose.model("news", this.schemas.news),
 						report: mongoose.model("report", this.schemas.report),
 						report: mongoose.model("report", this.schemas.report),
-						punishment: mongoose.model("punishment", this.schemas.punishment)
+						punishment: mongoose.model("punishment", this.schemas.punishment),
+						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),
+						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),
+						ratings: mongoose.model("ratings", this.schemas.ratings),
+						importJob: mongoose.model("importJob", this.schemas.importJob)
 					};
 					};
 
 
 					mongoose.connection.on("error", err => {
 					mongoose.connection.on("error", err => {
@@ -239,6 +254,10 @@ class _DBModule extends CoreClass {
 					this.models.song.syncIndexes();
 					this.models.song.syncIndexes();
 					this.models.station.syncIndexes();
 					this.models.station.syncIndexes();
 					this.models.user.syncIndexes();
 					this.models.user.syncIndexes();
+					this.models.youtubeApiRequest.syncIndexes();
+					this.models.youtubeVideo.syncIndexes();
+					this.models.ratings.syncIndexes();
+					this.models.importJob.syncIndexes();
 
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {
 					else {
@@ -386,7 +405,7 @@ class _DBModule extends CoreClass {
 
 
 					// Adds the match stage to aggregation pipeline, which is responsible for filtering
 					// Adds the match stage to aggregation pipeline, which is responsible for filtering
 					(pipeline, next) => {
 					(pipeline, next) => {
-						const { queries, operator, specialQueries } = payload;
+						const { queries, operator, specialQueries, specialFilters } = payload;
 
 
 						let queryError;
 						let queryError;
 						const newQueries = queries.flatMap(query => {
 						const newQueries = queries.flatMap(query => {
@@ -417,6 +436,9 @@ class _DBModule extends CoreClass {
 								newQuery[filter.property] = { $eq: Number(data) };
 								newQuery[filter.property] = { $eq: Number(data) };
 							} else if (filterType === "boolean") {
 							} else if (filterType === "boolean") {
 								newQuery[filter.property] = { $eq: !!data };
 								newQuery[filter.property] = { $eq: !!data };
+							} else if (filterType === "special") {
+								pipeline.push(...specialFilters[filter.property](data));
+								newQuery[filter.property] = { $eq: true };
 							}
 							}
 
 
 							if (specialQueries[filter.property]) {
 							if (specialQueries[filter.property]) {

+ 9 - 0
backend/logic/db/schemas/importJob.js

@@ -0,0 +1,9 @@
+export default {
+	type: { type: String, required: true, enum: ["youtube"] },
+	query: { type: Object, required: true },
+	status: { type: String, required: true, enum: ["success", "error", "in-progress"] },
+	response: { type: Object, required: true },
+	requestedBy: { type: String, required: true },
+	requestedAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 1 - 1
backend/logic/db/schemas/playlist.js

@@ -4,7 +4,7 @@ export default {
 	displayName: { type: String, min: 2, max: 96, trim: true, required: true },
 	displayName: { type: String, min: 2, max: 96, trim: true, required: true },
 	songs: [
 	songs: [
 		{
 		{
-			_id: { type: mongoose.Schema.Types.ObjectId, required: true },
+			_id: { type: mongoose.Schema.Types.ObjectId },
 			youtubeId: { type: String, required: true },
 			youtubeId: { type: String, required: true },
 			title: { type: String },
 			title: { type: String },
 			artists: [{ type: String }],
 			artists: [{ type: String }],

+ 6 - 0
backend/logic/db/schemas/ratings.js

@@ -0,0 +1,6 @@
+export default {
+	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
+	likes: { type: Number, default: 0, required: true },
+	dislikes: { type: Number, default: 0, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

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

@@ -7,8 +7,6 @@ export default {
 	duration: { type: Number, min: 1, required: true },
 	duration: { type: Number, min: 1, required: true },
 	skipDuration: { type: Number, required: true, default: 0 },
 	skipDuration: { type: Number, required: true, default: 0 },
 	thumbnail: { type: String, trim: true },
 	thumbnail: { type: String, trim: true },
-	likes: { type: Number, default: 0, required: true },
-	dislikes: { type: Number, default: 0, required: true },
 	explicit: { type: Boolean },
 	explicit: { type: Boolean },
 	requestedBy: { type: String },
 	requestedBy: { type: String },
 	requestedAt: { type: Date },
 	requestedAt: { type: Date },
@@ -16,5 +14,5 @@ export default {
 	verifiedBy: { type: String },
 	verifiedBy: { type: String },
 	verifiedAt: { type: Date },
 	verifiedAt: { type: Date },
 	discogs: { type: Object },
 	discogs: { type: Object },
-	documentVersion: { type: Number, default: 8, required: true }
+	documentVersion: { type: Number, default: 9, required: true }
 };
 };

+ 1 - 1
backend/logic/db/schemas/station.js

@@ -27,7 +27,7 @@ export default {
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
 	queue: [
 	queue: [
 		{
 		{
-			_id: { type: mongoose.Schema.Types.ObjectId, required: true },
+			_id: { type: mongoose.Schema.Types.ObjectId },
 			youtubeId: { type: String, required: true },
 			youtubeId: { type: String, required: true },
 			title: { type: String },
 			title: { type: String },
 			artists: [{ type: String }],
 			artists: [{ type: String }],

+ 6 - 0
backend/logic/db/schemas/youtubeApiRequest.js

@@ -0,0 +1,6 @@
+export default {
+	url: { type: String, required: true },
+	date: { type: Date, default: Date.now, required: true },
+	quotaCost: { type: Number, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 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 }
+};

+ 502 - 0
backend/logic/media.js

@@ -0,0 +1,502 @@
+import async from "async";
+import CoreClass from "../core";
+
+let MediaModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+let YouTubeModule;
+let SongsModule;
+let WSModule;
+
+class _MediaModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("media");
+
+		MediaModule = this;
+	}
+
+	/**
+	 * Initialises the media module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		YouTubeModule = this.moduleManager.modules.youtube;
+		SongsModule = this.moduleManager.modules.songs;
+		WSModule = this.moduleManager.modules.ws;
+
+		this.RatingsModel = await DBModule.runJob("GET_MODEL", { modelName: "ratings" });
+		this.RatingsSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "ratings" });
+		this.ImportJobModel = await DBModule.runJob("GET_MODEL", { modelName: "importJob" });
+
+		this.setStage(2);
+
+		return new Promise((resolve, reject) => {
+			CacheModule.runJob("SUB", {
+				channel: "importJob.updated",
+				cb: importJob => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.import",
+						args: ["event:admin.importJob.updated", { data: { importJob } }]
+					});
+				}
+			});
+
+			CacheModule.runJob("SUB", {
+				channel: "importJob.removed",
+				cb: jobId => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.import",
+						args: ["event:admin.importJob.removed", { data: { jobId } }]
+					});
+				}
+			});
+
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "ratings" })
+							.then(ratings => {
+								next(null, ratings);
+							})
+							.catch(next);
+					},
+
+					(ratings, next) => {
+						this.setStage(3);
+
+						if (!ratings) return next();
+
+						const youtubeIds = Object.keys(ratings);
+
+						return async.each(
+							youtubeIds,
+							(youtubeId, next) => {
+								MediaModule.RatingsModel.findOne({ youtubeId }, (err, rating) => {
+									if (err) next(err);
+									else if (!rating)
+										CacheModule.runJob("HDEL", {
+											table: "ratings",
+											key: youtubeId
+										})
+											.then(() => next())
+											.catch(next);
+									else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						MediaModule.RatingsModel.find({}, next);
+					},
+
+					(ratings, next) => {
+						this.setStage(5);
+						async.each(
+							ratings,
+							(rating, next) => {
+								CacheModule.runJob("HSET", {
+									table: "ratings",
+									key: rating.youtubeId,
+									value: MediaModule.RatingsSchemaCache(rating)
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err });
+						reject(new Error(err));
+					} else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Recalculates dislikes and likes
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async RECALCULATE_RATINGS(payload) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-liked" },
+							(err, likes) => {
+								if (err) return next(err);
+								return next(null, likes);
+							}
+						);
+					},
+
+					(likes, next) => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-disliked" },
+							(err, dislikes) => {
+								if (err) return next(err);
+								return next(err, { likes, dislikes });
+							}
+						);
+					},
+
+					({ likes, dislikes }, next) => {
+						MediaModule.RatingsModel.findOneAndUpdate(
+							{ youtubeId: payload.youtubeId },
+							{
+								$set: {
+									likes,
+									dislikes
+								}
+							},
+							{ new: true, upsert: true },
+							next
+						);
+					},
+
+					(ratings, next) => {
+						CacheModule.runJob(
+							"HSET",
+							{
+								table: "ratings",
+								key: payload.youtubeId,
+								value: ratings
+							},
+							this
+						)
+							.then(ratings => next(null, ratings))
+							.catch(next);
+					}
+				],
+				(err, { likes, dislikes }) => {
+					if (err) return reject(new Error(err));
+					return resolve({ likes, dislikes });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Recalculates all dislikes and likes
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	RECALCULATE_ALL_RATINGS() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({}, { youtubeId: true }, next);
+					},
+
+					(songs, next) => {
+						YouTubeModule.youtubeVideoModel.find({}, { youtubeId: true }, (err, videos) => {
+							if (err) next(err);
+							else
+								next(null, [
+									...songs.map(song => song.youtubeId),
+									...videos.map(video => video.youtubeId)
+								]);
+						});
+					},
+
+					(youtubeIds, next) => {
+						async.eachLimit(
+							youtubeIds,
+							2,
+							(youtubeId, next) => {
+								this.publishProgress({
+									status: "update",
+									message: `Recalculating ratings for ${youtubeId}`
+								});
+								MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets ratings by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.createMissing - whether to create missing ratings
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_RATINGS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next =>
+						CacheModule.runJob("HGET", { table: "ratings", key: payload.youtubeId }, this)
+							.then(ratings => next(null, ratings))
+							.catch(next),
+
+					(ratings, next) => {
+						if (ratings) return next(true, ratings);
+						return MediaModule.RatingsModel.findOne({ youtubeId: payload.youtubeId }, next);
+					},
+
+					(ratings, next) => {
+						if (ratings)
+							return CacheModule.runJob(
+								"HSET",
+								{
+									table: "ratings",
+									key: payload.youtubeId,
+									value: ratings
+								},
+								this
+							).then(ratings => next(true, ratings));
+
+						if (!payload.createMissing) return next("Ratings not found.");
+
+						return MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId: payload.youtubeId }, this)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next =>
+						MediaModule.runJob("GET_RATINGS", { youtubeId: payload.youtubeId }, this)
+							.then(res => next(null, res.ratings))
+							.catch(next)
+				],
+				(err, ratings) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ ratings });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove ratings by id from the cache and Mongo
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.youtubeIds - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_RATINGS(payload) {
+		return new Promise((resolve, reject) => {
+			let { youtubeIds } = payload;
+			if (!Array.isArray(youtubeIds)) youtubeIds = [youtubeIds];
+
+			async.eachLimit(
+				youtubeIds,
+				1,
+				(youtubeId, next) => {
+					async.waterfall(
+						[
+							next => {
+								MediaModule.RatingsModel.deleteOne({ youtubeId }, err => {
+									if (err) next(err);
+									else next();
+								});
+							},
+
+							next => {
+								CacheModule.runJob("HDEL", { table: "ratings", key: youtubeId }, this)
+									.then(() => {
+										next();
+									})
+									.catch(next);
+							}
+						],
+						next
+					);
+				},
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Get song or youtube video by youtubeId
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id of the song/video
+	 * @param {string} payload.userId - the user id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_MEDIA(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
+					},
+
+					(song, next) => {
+						if (song && song.duration > 0) next(true, song);
+						else {
+							YouTubeModule.runJob(
+								"GET_VIDEO",
+								{ identifier: payload.youtubeId, createMissing: true },
+								this
+							)
+								.then(response => {
+									const { youtubeId, title, author, duration } = response.video;
+									next(null, song, { youtubeId, title, artists: [author], duration });
+								})
+								.catch(next);
+						}
+					},
+
+					(song, youtubeVideo, next) => {
+						if (song && song.duration <= 0) {
+							song.duration = youtubeVideo.duration;
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) next(err, song);
+								next(null, song);
+							});
+						} else {
+							next(null, {
+								...youtubeVideo,
+								skipDuration: 0,
+								requestedBy: payload.userId,
+								requestedAt: Date.now(),
+								verified: false
+							});
+						}
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove import job by id from Mongo
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.jobIds - the job ids
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_IMPORT_JOBS(payload) {
+		return new Promise((resolve, reject) => {
+			let { jobIds } = payload;
+			if (!Array.isArray(jobIds)) jobIds = [jobIds];
+
+			async.waterfall(
+				[
+					next => {
+						MediaModule.ImportJobModel.find({ _id: { $in: jobIds } }, next);
+					},
+
+					(importJobs, next) => {
+						async.eachLimit(
+							importJobs,
+							1,
+							(importJob, next) => {
+								CacheModule.runJob("PUB", {
+									channel: "importJob.updated",
+									value: importJob
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else next(null, importJobs);
+							}
+						);
+					}
+				],
+				(err, importJobs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ importJobs });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove import job by id from Mongo
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.jobIds - the job ids
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_IMPORT_JOBS(payload) {
+		return new Promise((resolve, reject) => {
+			let { jobIds } = payload;
+			if (!Array.isArray(jobIds)) jobIds = [jobIds];
+
+			async.waterfall(
+				[
+					next => {
+						MediaModule.ImportJobModel.deleteMany({ _id: { $in: jobIds } }, err => {
+							if (err) next(err);
+							else next();
+						});
+					},
+
+					next => {
+						async.eachLimit(
+							jobIds,
+							1,
+							(jobId, next) => {
+								CacheModule.runJob("PUB", {
+									channel: "importJob.removed",
+									value: jobId
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
+}
+
+export default new _MediaModule();

+ 2 - 1
backend/logic/migration/index.js

@@ -67,7 +67,8 @@ class _MigrationModule extends CoreClass {
 						playlist: mongoose.model("playlist", new mongoose.Schema({}, { strict: false })),
 						playlist: mongoose.model("playlist", new mongoose.Schema({}, { strict: false })),
 						news: mongoose.model("news", new mongoose.Schema({}, { strict: false })),
 						news: mongoose.model("news", new mongoose.Schema({}, { strict: false })),
 						report: mongoose.model("report", new mongoose.Schema({}, { strict: false })),
 						report: mongoose.model("report", new mongoose.Schema({}, { strict: false })),
-						punishment: mongoose.model("punishment", new mongoose.Schema({}, { strict: false }))
+						punishment: mongoose.model("punishment", new mongoose.Schema({}, { strict: false })),
+						ratings: mongoose.model("ratings", new mongoose.Schema({}, { strict: false }))
 					};
 					};
 
 
 					const files = fs.readdirSync(path.join(__dirname, "migrations"));
 					const files = fs.readdirSync(path.join(__dirname, "migrations"));

+ 102 - 0
backend/logic/migration/migrations/migration21.js

@@ -0,0 +1,102 @@
+import async from "async";
+
+/**
+ * Migration 21
+ *
+ * Migration for song ratings
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const ratingsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "ratings" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 21. Finding songs with document version 8.`);
+					songModel.find({ documentVersion: 8 }, { youtubeId: true }, next);
+				},
+
+				(songs, next) => {
+					async.eachLimit(
+						songs.map(song => song.youtubeId),
+						2,
+						(youtubeId, next) => {
+							async.waterfall(
+								[
+									next => {
+										playlistModel.countDocuments(
+											{ songs: { $elemMatch: { youtubeId } }, type: "user-liked" },
+											(err, likes) => {
+												if (err) return next(err);
+												return next(null, likes);
+											}
+										);
+									},
+
+									(likes, next) => {
+										playlistModel.countDocuments(
+											{ songs: { $elemMatch: { youtubeId } }, type: "user-disliked" },
+											(err, dislikes) => {
+												if (err) return next(err);
+												return next(err, { likes, dislikes });
+											}
+										);
+									},
+
+									({ likes, dislikes }, next) => {
+										ratingsModel.updateOne(
+											{ youtubeId },
+											{
+												$set: {
+													likes,
+													dislikes,
+													documentVersion: 1
+												}
+											},
+											{ upsert: true },
+											next
+										);
+									}
+								],
+								next
+							);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					songModel.updateMany(
+						{ documentVersion: 8 },
+						{
+							$set: { documentVersion: 9 },
+							$unset: { likes: true, dislikes: true }
+						},
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 21 (songs). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 205 - 23
backend/logic/playlists.js

@@ -8,6 +8,8 @@ let SongsModule;
 let CacheModule;
 let CacheModule;
 let DBModule;
 let DBModule;
 let UtilsModule;
 let UtilsModule;
+let MediaModule;
+let WSModule;
 
 
 class _PlaylistsModule extends CoreClass {
 class _PlaylistsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
@@ -30,10 +32,39 @@ class _PlaylistsModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
 		UtilsModule = this.moduleManager.modules.utils;
 		SongsModule = this.moduleManager.modules.songs;
 		SongsModule = this.moduleManager.modules.songs;
+		MediaModule = this.moduleManager.modules.media;
+		WSModule = this.moduleManager.modules.ws;
 
 
 		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
 		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
 		this.playlistSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "playlist" });
 		this.playlistSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "playlist" });
 
 
+		CacheModule.runJob("SUB", {
+			channel: "playlist.updated",
+			cb: async data => {
+				PlaylistsModule.playlistModel.findOne(
+					{ _id: data.playlistId },
+					["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
+					(err, playlist) => {
+						const newPlaylist = {
+							...playlist._doc,
+							songsCount: playlist.songs.length,
+							songsLength: playlist.songs.reduce(
+								(previous, current) => ({
+									duration: previous.duration + current.duration
+								}),
+								{ duration: 0 }
+							).duration
+						};
+						delete newPlaylist.songs;
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: "admin.playlists",
+							args: ["event:admin.playlist.updated", { data: { playlist: newPlaylist } }]
+						});
+					}
+				);
+			}
+		});
+
 		this.setStage(2);
 		this.setStage(2);
 
 
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
@@ -503,35 +534,184 @@ class _PlaylistsModule extends CoreClass {
 	 *
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
 	 * @param {string} payload.playlistId - the playlist id
-	 * @param {string} payload.song - the song
+	 * @param {string} payload.youtubeId - the youtube id
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
 	ADD_SONG_TO_PLAYLIST(payload) {
 	ADD_SONG_TO_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
-			const { _id, youtubeId, title, artists, thumbnail, duration, verified } = payload.song;
-			const trimmedSong = {
-				_id,
-				youtubeId,
-				title,
-				artists,
-				thumbnail,
-				duration,
-				verified
-			};
+			const { playlistId, youtubeId } = payload;
 
 
-			PlaylistsModule.playlistModel.updateOne(
-				{ _id: payload.playlistId },
-				{ $push: { songs: trimmedSong } },
-				{ runValidators: true },
-				err => {
-					if (err) reject(new Error(err));
-					else {
-						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
-							.then(() => resolve())
-							.catch(err => {
-								reject(new Error(err));
-							});
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) return next("Playlist not found.");
+						if (playlist.songs.find(song => song.youtubeId === youtubeId))
+							return next("That song is already in the playlist.");
+						return next();
+					},
+
+					next => {
+						MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+							.then(response => {
+								const { song } = response;
+								const { _id, title, artists, thumbnail, duration, verified } = song;
+								next(null, {
+									_id,
+									youtubeId,
+									title,
+									artists,
+									thumbnail,
+									duration,
+									verified
+								});
+							})
+							.catch(next);
+					},
+
+					(newSong, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: playlistId },
+							{ $push: { songs: newSong } },
+							{ runValidators: true },
+							err => {
+								if (err) return next(err);
+								return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+									.then(playlist => next(null, playlist, newSong))
+									.catch(next);
+							}
+						);
+					},
+
+					(playlist, newSong, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
+							.then(response => {
+								async.each(
+									response.stationIds,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
+											.then()
+											.catch();
+										next();
+									},
+									err => {
+										if (err) next(err);
+										else next(null, playlist, newSong);
+									}
+								);
+							})
+							.catch(next);
+					},
+
+					(playlist, newSong, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							MediaModule.runJob("RECALCULATE_RATINGS", {
+								youtubeId: newSong.youtubeId
+							})
+								.then(ratings => next(null, playlist, newSong, ratings))
+								.catch(next);
+						} else {
+							next(null, playlist, newSong, null);
+						}
 					}
 					}
+				],
+				(err, playlist, song, ratings) => {
+					if (err) reject(err);
+					else resolve({ playlist, song, ratings });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove from playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FROM_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId, youtubeId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) return next("Playlist not found.");
+						if (!playlist.songs.find(song => song.youtubeId === youtubeId))
+							return next("That song is not currently in the playlist.");
+
+						return PlaylistsModule.playlistModel.updateOne(
+							{ _id: playlistId },
+							{ $pull: { songs: { youtubeId } } },
+							next
+						);
+					},
+
+					(res, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
+							.then(response => {
+								async.each(
+									response.stationIds,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
+											.then()
+											.catch();
+										next();
+									},
+									err => {
+										if (err) next(err);
+										else next(null, playlist);
+									}
+								);
+							})
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+							MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
+								.then(ratings => next(null, playlist, ratings))
+								.catch(next);
+						} else next(null, playlist, null);
+					},
+
+					(playlist, ratings, next) =>
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "playlist.updated",
+								value: { playlistId }
+							},
+							this
+						)
+							.then(() => next(null, playlist, ratings))
+							.catch(next)
+				],
+				(err, playlist, ratings) => {
+					if (err) reject(err);
+					else resolve({ playlist, ratings });
 				}
 				}
 			);
 			);
 		});
 		});
@@ -726,6 +906,7 @@ class _PlaylistsModule extends CoreClass {
 						response.playlists,
 						response.playlists,
 						1,
 						1,
 						(playlist, next) => {
 						(playlist, next) => {
+							this.publishProgress({ status: "update", message: `Deleting "${playlist._id}"` });
 							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
 							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
 								.then(() => {
 								.then(() => {
 									this.log("INFO", "Deleting orphaned genre playlist");
 									this.log("INFO", "Deleting orphaned genre playlist");
@@ -980,6 +1161,7 @@ class _PlaylistsModule extends CoreClass {
 						response.playlists,
 						response.playlists,
 						1,
 						1,
 						(playlist, next) => {
 						(playlist, next) => {
+							this.publishProgress({ status: "update", message: `Deleting "${playlist._id}"` });
 							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
 							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
 								.then(() => {
 								.then(() => {
 									this.log("INFO", "Deleting orphaned station playlist");
 									this.log("INFO", "Deleting orphaned station playlist");

+ 180 - 412
backend/logic/songs.js

@@ -9,17 +9,8 @@ let UtilsModule;
 let YouTubeModule;
 let YouTubeModule;
 let StationsModule;
 let StationsModule;
 let PlaylistsModule;
 let PlaylistsModule;
-
-class ErrorWithData extends Error {
-	/**
-	 * @param {string} message - the error message
-	 * @param {object} data - the error data
-	 */
-	constructor(message, data) {
-		super(message);
-		this.data = data;
-	}
-}
+let MediaModule;
+let WSModule;
 
 
 class _SongsModule extends CoreClass {
 class _SongsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
@@ -43,6 +34,8 @@ class _SongsModule extends CoreClass {
 		YouTubeModule = this.moduleManager.modules.youtube;
 		YouTubeModule = this.moduleManager.modules.youtube;
 		StationsModule = this.moduleManager.modules.stations;
 		StationsModule = this.moduleManager.modules.stations;
 		PlaylistsModule = this.moduleManager.modules.playlists;
 		PlaylistsModule = this.moduleManager.modules.playlists;
+		MediaModule = this.moduleManager.modules.media;
+		WSModule = this.moduleManager.modules.ws;
 
 
 		this.SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
 		this.SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
 		this.SongSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" });
 		this.SongSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" });
@@ -50,6 +43,15 @@ class _SongsModule extends CoreClass {
 		this.setStage(2);
 		this.setStage(2);
 
 
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
+			CacheModule.runJob("SUB", {
+				channel: "song.created",
+				cb: async data =>
+					WSModule.runJob("EMIT_TO_ROOMS", {
+						rooms: ["import-album", `edit-song.${data.song._id}`, "edit-songs"],
+						args: ["event:admin.song.created", { data }]
+					})
+			});
+
 			async.waterfall(
 			async.waterfall(
 				[
 				[
 					next => {
 					next => {
@@ -169,31 +171,45 @@ class _SongsModule extends CoreClass {
 	 * Gets songs by id from Mongo
 	 * Gets songs by id from Mongo
 	 *
 	 *
 	 * @param {object} payload - object containing the payload
 	 * @param {object} payload - object containing the payload
-	 * @param {string} payload.songIds - the ids of the songs we are trying to get
-	 * @param {string} payload.properties - the properties to return
+	 * @param {string} payload.youtubeIds - the youtube ids of the songs we are trying to get
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
 	GET_SONGS(payload) {
 	GET_SONGS(payload) {
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 			async.waterfall(
 				[
 				[
-					next => {
-						if (!payload.songIds.every(songId => mongoose.Types.ObjectId.isValid(songId)))
-							next("One or more songIds are not a valid ObjectId.");
-						else next();
-					},
+					next => SongsModule.SongModel.find({ youtubeId: { $in: payload.youtubeIds } }, next),
 
 
-					next => {
-						const includeProperties = {};
-						payload.properties.forEach(property => {
-							includeProperties[property] = true;
-						});
-						return SongsModule.SongModel.find(
-							{
-								_id: { $in: payload.songIds }
-							},
-							includeProperties,
-							next
+					(songs, next) => {
+						const youtubeIds = payload.youtubeIds.filter(
+							youtubeId => !songs.find(song => song.youtubeId === youtubeId)
+						);
+						return YouTubeModule.youtubeVideoModel.find(
+							{ youtubeId: { $in: youtubeIds } },
+							(err, videos) => {
+								if (err) next(err);
+								else {
+									const youtubeVideos = videos.map(video => {
+										const { youtubeId, title, author, duration, thumbnail } = video;
+										return {
+											youtubeId,
+											title,
+											artists: [author],
+											genres: [],
+											tags: [],
+											duration,
+											skipDuration: 0,
+											thumbnail:
+												thumbnail || `https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`,
+											requestedBy: null,
+											requestedAt: Date.now(),
+											verified: false,
+											youtubeVideoId: video._id
+										};
+									});
+									next(null, [...songs, ...youtubeVideos]);
+								}
+							}
 						);
 						);
 					}
 					}
 				],
 				],
@@ -205,85 +221,6 @@ class _SongsModule extends CoreClass {
 		});
 		});
 	}
 	}
 
 
-	/**
-	 * Makes sure that if a song is not currently in the songs db, to add it
-	 *
-	 * @param {object} payload - an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube song id of the song we are trying to ensure is in the songs db
-	 * @param {string} payload.userId - the youtube song id of the song we are trying to ensure is in the songs db
-	 * @param {string} payload.automaticallyRequested - whether the song was automatically requested or not
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	ENSURE_SONG_EXISTS_BY_YOUTUBE_ID(payload) {
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
-					},
-
-					(song, next) => {
-						if (song && song.duration > 0) next(true, song);
-						else {
-							YouTubeModule.runJob("GET_SONG", { youtubeId: payload.youtubeId }, this)
-								.then(response => {
-									next(null, song, response.song);
-								})
-								.catch(next);
-						}
-					},
-
-					(song, youtubeSong, next) => {
-						if (song && song.duration <= 0) {
-							song.duration = youtubeSong.duration;
-							song.save({ validateBeforeSave: true }, err => {
-								if (err) return next(err, song);
-								return next(null, song);
-							});
-						} else {
-							const song = new SongsModule.SongModel({
-								...youtubeSong,
-								requestedBy: payload.userId,
-								requestedAt: Date.now()
-							});
-							song.save({ validateBeforeSave: true }, err => {
-								if (err) return next(err, song);
-								return next(null, song);
-							});
-						}
-					}
-				],
-				(err, song) => {
-					if (err && err !== true) return reject(new Error(err));
-					return resolve({ song });
-				}
-			);
-		});
-	}
-
-	/**
-	 * Gets a song by youtube id
-	 *
-	 * @param {object} payload - an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube id of the song we are trying to get
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	GET_SONG_FROM_YOUTUBE_ID(payload) {
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
-					}
-				],
-				(err, song) => {
-					if (err && err !== true) return reject(new Error(err));
-					return resolve({ song });
-				}
-			);
-		});
-	}
-
 	/**
 	/**
 	 * Create song
 	 * Create song
 	 *
 	 *
@@ -327,6 +264,21 @@ class _SongsModule extends CoreClass {
 					(song, next) => {
 					(song, next) => {
 						SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 						SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 						return next(null, song);
 						return next(null, song);
+					},
+
+					(song, next) => {
+						MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId: song.youtubeId }, this)
+							.then(() => next(null, song))
+							.catch(next);
+					},
+
+					(song, next) => {
+						CacheModule.runJob("PUB", {
+							channel: "song.created",
+							value: { song }
+						})
+							.then(() => next(null, song))
+							.catch(next);
 					}
 					}
 				],
 				],
 				(err, song) => {
 				(err, song) => {
@@ -345,7 +297,10 @@ class _SongsModule extends CoreClass {
 	 * @param {string} payload.oldStatus - old status of song being updated (optional)
 	 * @param {string} payload.oldStatus - old status of song being updated (optional)
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	UPDATE_SONG(payload) {
+	async UPDATE_SONG(payload) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 			async.waterfall(
 				[
 				[
@@ -371,120 +326,113 @@ class _SongsModule extends CoreClass {
 							},
 							},
 							this
 							this
 						)
 						)
-							.then(song => {
-								next(null, song);
+							.then(() => {
+								const { _id, youtubeId, title, artists, thumbnail, duration, skipDuration, verified } =
+									song;
+								next(null, {
+									_id,
+									youtubeId,
+									title,
+									artists,
+									thumbnail,
+									duration,
+									skipDuration,
+									verified
+								});
 							})
 							})
 							.catch(next);
 							.catch(next);
 					},
 					},
 
 
 					(song, next) => {
 					(song, next) => {
-						const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
-						const trimmedSong = {
-							_id,
-							youtubeId,
-							title,
-							artists,
-							thumbnail,
-							duration,
-							verified
-						};
-						this.log("INFO", `Going to update playlists now for song ${_id}`);
-						DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this)
-							.then(playlistModel => {
-								playlistModel.updateMany(
-									{ "songs._id": song._id },
-									{ $set: { "songs.$": trimmedSong } },
+						playlistModel.updateMany({ "songs._id": song._id }, { $set: { "songs.$": song } }, err => {
+							if (err) next(err);
+							else next(null, song);
+						});
+					},
+
+					(song, next) => {
+						playlistModel.updateMany(
+							{ "songs.youtubeId": song.youtubeId },
+							{ $set: { "songs.$": song } },
+							err => {
+								if (err) next(err);
+								else next(null, song);
+							}
+						);
+					},
+
+					(song, next) => {
+						playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
+							if (err) next(err);
+							else {
+								async.eachLimit(
+									playlists,
+									1,
+									(playlist, next) => {
+										PlaylistsModule.runJob(
+											"UPDATE_PLAYLIST",
+											{
+												playlistId: playlist._id
+											},
+											this
+										)
+											.then(() => {
+												next();
+											})
+											.catch(err => {
+												next(err);
+											});
+									},
 									err => {
 									err => {
 										if (err) next(err);
 										if (err) next(err);
-										else
-											playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
-												if (err) next(err);
-												else {
-													async.eachLimit(
-														playlists,
-														1,
-														(playlist, next) => {
-															PlaylistsModule.runJob(
-																"UPDATE_PLAYLIST",
-																{
-																	playlistId: playlist._id
-																},
-																this
-															)
-																.then(() => {
-																	next();
-																})
-																.catch(err => {
-																	next(err);
-																});
-														},
-														err => {
-															if (err) next(err);
-															else next(null, song);
-														}
-													);
-												}
-											});
+										else next(null, song);
 									}
 									}
 								);
 								);
-							})
-							.catch(err => {
-								next(err);
-							});
+							}
+						});
 					},
 					},
 
 
 					(song, next) => {
 					(song, next) => {
-						const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
-						this.log("INFO", `Going to update stations now for song ${_id}`);
-						DBModule.runJob("GET_MODEL", { modelName: "station" }, this)
-							.then(stationModel => {
-								stationModel.updateMany(
-									{ "queue._id": song._id },
-									{
-										$set: {
-											"queue.$.youtubeId": youtubeId,
-											"queue.$.title": title,
-											"queue.$.artists": artists,
-											"queue.$.thumbnail": thumbnail,
-											"queue.$.duration": duration,
-											"queue.$.verified": verified
-										}
+						stationModel.updateMany({ "queue._id": song._id }, { $set: { "queue.$": song } }, err => {
+							if (err) next(err);
+							else next(null, song);
+						});
+					},
+
+					(song, next) => {
+						stationModel.updateMany(
+							{ "queue.youtubeId": song.youtubeId },
+							{ $set: { "queue.$": song } },
+							err => {
+								if (err) next(err);
+								else next(null, song);
+							}
+						);
+					},
+
+					(song, next) => {
+						stationModel.find({ "queue._id": song._id }, (err, stations) => {
+							if (err) next(err);
+							else {
+								async.eachLimit(
+									stations,
+									1,
+									(station, next) => {
+										StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
+											.then(() => {
+												next();
+											})
+											.catch(err => {
+												next(err);
+											});
 									},
 									},
 									err => {
 									err => {
-										if (err) this.log("ERROR", err);
-										else
-											stationModel.find({ "queue._id": song._id }, (err, stations) => {
-												if (err) next(err);
-												else {
-													async.eachLimit(
-														stations,
-														1,
-														(station, next) => {
-															StationsModule.runJob(
-																"UPDATE_STATION",
-																{ stationId: station._id },
-																this
-															)
-																.then(() => {
-																	next();
-																})
-																.catch(err => {
-																	next(err);
-																});
-														},
-														err => {
-															if (err) next(err);
-															else next(null, song);
-														}
-													);
-												}
-											});
+										if (err) next(err);
+										else next(null, song);
 									}
 									}
 								);
 								);
-							})
-							.catch(err => {
-								next(err);
-							});
+							}
+						});
 					},
 					},
 
 
 					(song, next) => {
 					(song, next) => {
@@ -557,6 +505,8 @@ class _SongsModule extends CoreClass {
 					next => {
 					next => {
 						const { songIds } = payload;
 						const { songIds } = payload;
 
 
+						this.publishProgress({ status: "update", message: `Updating songs (stage 1)` });
+
 						SongsModule.SongModel.find({ _id: songIds }, next);
 						SongsModule.SongModel.find({ _id: songIds }, next);
 					},
 					},
 
 
@@ -564,6 +514,8 @@ class _SongsModule extends CoreClass {
 					(songs, next) => {
 					(songs, next) => {
 						const { songIds } = payload;
 						const { songIds } = payload;
 
 
+						this.publishProgress({ status: "update", message: `Updating songs (stage 2)` });
+
 						async.eachLimit(
 						async.eachLimit(
 							songIds,
 							songIds,
 							1,
 							1,
@@ -585,6 +537,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Adds/updates all songs in the cache
 					// Adds/updates all songs in the cache
 					(songs, next) => {
 					(songs, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 3)` });
+
 						async.eachLimit(
 						async.eachLimit(
 							songs,
 							songs,
 							1,
 							1,
@@ -611,6 +565,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Updates all playlists that the songs are in by setting the new trimmed song
 					// Updates all playlists that the songs are in by setting the new trimmed song
 					(songs, next) => {
 					(songs, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 4)` });
+
 						const trimmedSongs = songs.map(song => {
 						const trimmedSongs = songs.map(song => {
 							const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 							const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 							return {
 							return {
@@ -663,6 +619,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Updates all playlists that the songs are in
 					// Updates all playlists that the songs are in
 					(songs, playlistsToUpdate, next) => {
 					(songs, playlistsToUpdate, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 5)` });
+
 						async.eachLimit(
 						async.eachLimit(
 							playlistsToUpdate,
 							playlistsToUpdate,
 							1,
 							1,
@@ -689,6 +647,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Updates all station queues that the songs are in by setting the new trimmed song
 					// Updates all station queues that the songs are in by setting the new trimmed song
 					(songs, next) => {
 					(songs, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 6)` });
+
 						const stationsToUpdate = new Set();
 						const stationsToUpdate = new Set();
 
 
 						async.eachLimit(
 						async.eachLimit(
@@ -738,6 +698,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Updates all playlists that the songs are in
 					// Updates all playlists that the songs are in
 					(songs, stationsToUpdate, next) => {
 					(songs, stationsToUpdate, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 7)` });
+
 						async.eachLimit(
 						async.eachLimit(
 							stationsToUpdate,
 							stationsToUpdate,
 							1,
 							1,
@@ -764,6 +726,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Autofill the genre playlists of all genres of all songs
 					// Autofill the genre playlists of all genres of all songs
 					(songs, next) => {
 					(songs, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 8)` });
+
 						const genresToAutofill = new Set();
 						const genresToAutofill = new Set();
 
 
 						songs.forEach(song => {
 						songs.forEach(song => {
@@ -791,6 +755,8 @@ class _SongsModule extends CoreClass {
 
 
 					// Send event that the song was updated
 					// Send event that the song was updated
 					(songs, next) => {
 					(songs, next) => {
+						this.publishProgress({ status: "update", message: `Updating songs (stage 9)` });
+
 						async.eachLimit(
 						async.eachLimit(
 							songs,
 							songs,
 							1,
 							1,
@@ -838,6 +804,7 @@ class _SongsModule extends CoreClass {
 							(song, next) => {
 							(song, next) => {
 								index += 1;
 								index += 1;
 								console.log(`Updating song #${index} out of ${length}: ${song._id}`);
 								console.log(`Updating song #${index} out of ${length}: ${song._id}`);
+								this.publishProgress({ status: "update", message: `Updating song "${song._id}"` });
 								SongsModule.runJob("UPDATE_SONG", { songId: song._id }, this)
 								SongsModule.runJob("UPDATE_SONG", { songId: song._id }, this)
 									.then(() => {
 									.then(() => {
 										next();
 										next();
@@ -1038,101 +1005,6 @@ class _SongsModule extends CoreClass {
 		});
 		});
 	}
 	}
 
 
-	/**
-	 * Recalculates dislikes and likes for a song
-	 *
-	 * @param {object} payload - returns an object containing the payload
-	 * @param {string} payload.youtubeId - the youtube id of the song
-	 * @param {string} payload.songId - the song id of the song
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	async RECALCULATE_SONG_RATINGS(payload) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-liked" },
-							(err, likes) => {
-								if (err) return next(err);
-								return next(null, likes);
-							}
-						);
-					},
-
-					(likes, next) => {
-						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-disliked" },
-							(err, dislikes) => {
-								if (err) return next(err);
-								return next(err, { likes, dislikes });
-							}
-						);
-					},
-
-					({ likes, dislikes }, next) => {
-						SongsModule.SongModel.updateOne(
-							{ _id: payload.songId },
-							{
-								$set: {
-									likes,
-									dislikes
-								}
-							},
-							err => next(err, { likes, dislikes })
-						);
-					}
-				],
-				(err, { likes, dislikes }) => {
-					if (err) return reject(new Error(err));
-					return resolve({ likes, dislikes });
-				}
-			);
-		});
-	}
-
-	/**
-	 * Recalculates dislikes and likes for all songs
-	 *
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	RECALCULATE_ALL_SONG_RATINGS() {
-		return new Promise((resolve, reject) => {
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.find({}, { _id: true }, next);
-					},
-
-					(songs, next) => {
-						async.eachLimit(
-							songs,
-							2,
-							(song, next) => {
-								SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id }, this)
-									.then(() => {
-										next();
-									})
-									.catch(err => {
-										next(err);
-									});
-							},
-							err => {
-								next(err);
-							}
-						);
-					}
-				],
-				err => {
-					if (err) return reject(new Error(err));
-					return resolve();
-				}
-			);
-		});
-	}
-
 	/**
 	/**
 	 * Gets an array of all genres
 	 * Gets an array of all genres
 	 *
 	 *
@@ -1305,104 +1177,6 @@ class _SongsModule extends CoreClass {
 		});
 		});
 	}
 	}
 
 
-	/**
-	 * Requests a song, adding it to the DB
-	 *
-	 * @param {object} payload - The payload
-	 * @param {string} payload.youtubeId - The YouTube song id of the song
-	 * @param {string} payload.userId - The user id of the person requesting the song
-	 * @returns {Promise} - returns promise (reject, resolve)
-	 */
-	REQUEST_SONG(payload) {
-		return new Promise((resolve, reject) => {
-			const { youtubeId, userId } = payload;
-			const requestedAt = Date.now();
-
-			async.waterfall(
-				[
-					next => {
-						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-							.then(UserModel => {
-								UserModel.findOne({ _id: userId }, { "preferences.anonymousSongRequests": 1 }, next);
-							})
-							.catch(next);
-					},
-
-					(user, next) => {
-						SongsModule.SongModel.findOne({ youtubeId }, (err, song) => next(err, user, song));
-					},
-
-					// Get YouTube data from id
-					(user, song, next) => {
-						if (song) return next("This song is already in the database.", song);
-						// TODO Add err object as first param of callback
-
-						return YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
-							.then(response => {
-								const { song } = response;
-								song.artists = [];
-								song.genres = [];
-								song.skipDuration = 0;
-								song.explicit = false;
-								song.requestedBy = user.preferences.anonymousSongRequests ? null : userId;
-								song.requestedAt = requestedAt;
-								song.verified = false;
-								next(null, song);
-							})
-							.catch(next);
-					},
-					(newSong, next) => {
-						const song = new SongsModule.SongModel(newSong);
-						song.save({ validateBeforeSave: false }, err => {
-							if (err) return next(err, song);
-							return next(null, song);
-						});
-					},
-					(song, next) => {
-						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-							.then(UserModel => {
-								UserModel.findOne({ _id: userId }, (err, user) => {
-									if (err) return next(err);
-									if (!user) return next(null, song);
-
-									user.statistics.songsRequested += 1;
-
-									return user.save(err => {
-										if (err) return next(err);
-										return next(null, song);
-									});
-								});
-							})
-							.catch(next);
-					}
-				],
-				async (err, song) => {
-					if (err && err !== "This song is already in the database.") return reject(err);
-
-					const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
-					const trimmedSong = {
-						_id,
-						youtubeId,
-						title,
-						artists,
-						thumbnail,
-						duration,
-						verified
-					};
-
-					if (err && err === "This song is already in the database.")
-						return reject(new ErrorWithData(err, { song: trimmedSong }));
-
-					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
-
-					return resolve({ song: trimmedSong });
-				}
-			);
-		});
-	}
-
-	// runjob songs REQUEST_ORPHANED_PLAYLIST_SONGS {}
-
 	/**
 	/**
 	 * Requests all orphaned playlist songs, adding them to the database
 	 * Requests all orphaned playlist songs, adding them to the database
 	 *
 	 *
@@ -1423,6 +1197,10 @@ class _SongsModule extends CoreClass {
 								async.waterfall(
 								async.waterfall(
 									[
 									[
 										next => {
 										next => {
+											this.publishProgress({
+												status: "update",
+												message: `Requesting "${youtubeId}"`
+											});
 											console.log(
 											console.log(
 												youtubeId,
 												youtubeId,
 												`this is song ${youtubeIds.indexOf(youtubeId) + 1}/${youtubeIds.length}`
 												`this is song ${youtubeIds.indexOf(youtubeId) + 1}/${youtubeIds.length}`
@@ -1431,21 +1209,11 @@ class _SongsModule extends CoreClass {
 										},
 										},
 
 
 										next => {
 										next => {
-											SongsModule.runJob(
-												"ENSURE_SONG_EXISTS_BY_SONG_ID",
-												{ youtubeId, automaticallyRequested: true },
-												this
-											)
-												.then(() => next())
+											MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+												.then(res => next(null, res.song))
 												.catch(next);
 												.catch(next);
 										},
 										},
 
 
-										next => {
-											console.log(444, youtubeId);
-
-											SongsModule.SongModel.findOne({ youtubeId }, next);
-										},
-
 										(song, next) => {
 										(song, next) => {
 											const { _id, title, artists, thumbnail, duration, verified } = song;
 											const { _id, title, artists, thumbnail, duration, verified } = song;
 											const trimmedSong = {
 											const trimmedSong = {

+ 439 - 108
backend/logic/stations.js

@@ -7,9 +7,9 @@ let CacheModule;
 let DBModule;
 let DBModule;
 let UtilsModule;
 let UtilsModule;
 let WSModule;
 let WSModule;
-let SongsModule;
 let PlaylistsModule;
 let PlaylistsModule;
 let NotificationsModule;
 let NotificationsModule;
+let MediaModule;
 
 
 class _StationsModule extends CoreClass {
 class _StationsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
@@ -29,9 +29,9 @@ class _StationsModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
 		UtilsModule = this.moduleManager.modules.utils;
 		WSModule = this.moduleManager.modules.ws;
 		WSModule = this.moduleManager.modules.ws;
-		SongsModule = this.moduleManager.modules.songs;
 		PlaylistsModule = this.moduleManager.modules.playlists;
 		PlaylistsModule = this.moduleManager.modules.playlists;
 		NotificationsModule = this.moduleManager.modules.notifications;
 		NotificationsModule = this.moduleManager.modules.notifications;
+		MediaModule = this.moduleManager.modules.media;
 
 
 		this.userList = {};
 		this.userList = {};
 		this.usersPerStation = {};
 		this.usersPerStation = {};
@@ -534,25 +534,40 @@ class _StationsModule extends CoreClass {
 					},
 					},
 
 
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
-						SongsModule.runJob("GET_SONGS", {
-							songIds: songsToAdd.map(song => song._id),
-							properties: [
-								"youtubeId",
-								"title",
-								"duration",
-								"skipDuration",
-								"artists",
-								"thumbnail",
-								"verified"
-							]
-						})
-							.then(response => {
-								const newSongsToAdd = songsToAdd.map(song =>
-									response.songs.find(newSong => newSong._id.toString() === song._id.toString())
-								);
-								next(null, currentSongs, newSongsToAdd, currentSongIndex);
-							})
-							.catch(err => next(err));
+						const songs = [];
+						async.eachLimit(
+							songsToAdd.map(song => song.youtubeId),
+							2,
+							(youtubeId, next) => {
+								MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+									.then(response => {
+										const { song } = response;
+										const { _id, title, artists, thumbnail, duration, skipDuration, verified } =
+											song;
+										songs.push({
+											_id,
+											youtubeId,
+											title,
+											artists,
+											thumbnail,
+											duration,
+											skipDuration,
+											verified
+										});
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else {
+									const newSongsToAdd = songsToAdd.map(song =>
+										songs.find(newSong => newSong._id.toString() === song._id.toString())
+									);
+									next(null, currentSongs, newSongsToAdd, currentSongIndex);
+								}
+							}
+						);
 					},
 					},
 
 
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
@@ -624,36 +639,33 @@ class _StationsModule extends CoreClass {
 					},
 					},
 
 
 					(queueSong, next) => {
 					(queueSong, next) => {
-						if (!queueSong._id) next(null, queueSong);
-						else
-							SongsModule.runJob("GET_SONG", { songId: queueSong._id }, this)
-								.then(response => {
-									const { song } = response;
-
-									if (song) {
-										const newSong = {
-											_id: song._id,
-											youtubeId: song.youtubeId,
-											title: song.title,
-											artists: song.artists,
-											duration: song.duration,
-											skipDuration: song.skipDuration,
-											thumbnail: song.thumbnail,
-											requestedAt: queueSong.requestedAt,
-											requestedBy: queueSong.requestedBy,
-											likes: song.likes,
-											dislikes: song.dislikes,
-											verified: song.verified
-										};
-
-										return next(null, newSong);
-									}
-
-									return next(null, song);
-								})
-								.catch(err => {
-									next(err);
+						MediaModule.runJob(
+							"GET_MEDIA",
+							{
+								youtubeId: queueSong.youtubeId
+							},
+							this
+						)
+							.then(response => {
+								const { song } = response;
+								const { _id, youtubeId, title, skipDuration, artists, thumbnail, duration, verified } =
+									song;
+								next(null, {
+									_id,
+									youtubeId,
+									title,
+									skipDuration,
+									artists,
+									thumbnail,
+									duration,
+									verified,
+									requestedAt: queueSong.requestedAt,
+									requestedBy: queueSong.requestedBy,
+									likes: song.likes || 0,
+									dislikes: song.dislikes || 0
 								});
 								});
+							})
+							.catch(next);
 					}
 					}
 				],
 				],
 				(err, song) => {
 				(err, song) => {
@@ -708,6 +720,98 @@ class _StationsModule extends CoreClass {
 		});
 		});
 	}
 	}
 
 
+	/**
+	 * Process vote to skips for a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station to process
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	PROCESS_VOTE_SKIPS(payload) {
+		return new Promise((resolve, reject) => {
+			StationsModule.log("INFO", `Processing vote skips for station ${payload.stationId}.`);
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob(
+							"GET_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(station => next(null, station))
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						return next(null, station);
+					},
+
+					(station, next) => {
+						WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${station._id}` }, this)
+							.then(sockets => next(null, station, sockets))
+							.catch(next);
+					},
+
+					(station, sockets, next) => {
+						const skipVotes = station.currentSong.skipVotes.length;
+						let shouldSkip = false;
+
+						if (sockets.length <= skipVotes) {
+							if (!station.paused) shouldSkip = true;
+							return next(null, shouldSkip);
+						}
+
+						const users = [];
+
+						return async.each(
+							sockets,
+							(socketId, next) => {
+								WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
+									.then(socket => {
+										if (socket && socket.session && socket.session.userId) {
+											if (!users.includes(socket.session.userId))
+												users.push(socket.session.userId);
+										}
+										return next();
+									})
+									.catch(next);
+							},
+							err => {
+								if (err) return next(err);
+
+								if (!station.paused && users.length <= skipVotes) shouldSkip = true;
+								return next(null, shouldSkip);
+							}
+						);
+					},
+
+					(shouldSkip, next) => {
+						if (shouldSkip)
+							StationsModule.runJob(
+								"SKIP_STATION",
+								{
+									stationId: payload.stationId,
+									natural: false
+								},
+								this
+							)
+								.then(() => next())
+								.catch(next);
+						else next();
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
 	/**
 	/**
 	 * Skips a station
 	 * Skips a station
 	 *
 	 *
@@ -802,25 +906,23 @@ class _StationsModule extends CoreClass {
 						$set.startedAt = Date.now();
 						$set.startedAt = Date.now();
 						$set.timePaused = 0;
 						$set.timePaused = 0;
 						if (station.paused) $set.pausedAt = Date.now();
 						if (station.paused) $set.pausedAt = Date.now();
-						next(null, $set, song, station);
+						next(null, $set, station);
 					},
 					},
 
 
-					($set, song, station, next) => {
+					($set, station, next) => {
 						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
 						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
 							if (err) return next(err);
 							if (err) return next(err);
 
 
 							return StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
 							return StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
 								.then(station => {
 								.then(station => {
-									next(null, station, song);
+									next(null, station);
 								})
 								})
 								.catch(next);
 								.catch(next);
 						});
 						});
 					},
 					},
 
 
-					(station, song, next) => {
+					(station, next) => {
 						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
 						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
-							station.currentSong.likes = song.likes;
-							station.currentSong.dislikes = song.dislikes;
 							station.currentSong.skipVotes = 0;
 							station.currentSong.skipVotes = 0;
 						}
 						}
 						next(null, station);
 						next(null, station);
@@ -1206,19 +1308,11 @@ class _StationsModule extends CoreClass {
 					},
 					},
 
 
 					next => {
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $push: { "autofill.playlists": payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $push: { "autofill.playlists": payload.playlistId } },
+							next
+						);
 					},
 					},
 
 
 					(res, next) => {
 					(res, next) => {
@@ -1280,19 +1374,11 @@ class _StationsModule extends CoreClass {
 					},
 					},
 
 
 					next => {
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $pull: { "autofill.playlists": payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $pull: { "autofill.playlists": payload.playlistId } },
+							next
+						);
 					},
 					},
 
 
 					(res, next) => {
 					(res, next) => {
@@ -1364,19 +1450,11 @@ class _StationsModule extends CoreClass {
 					},
 					},
 
 
 					next => {
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $push: { blacklist: payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $push: { blacklist: payload.playlistId } },
+							next
+						);
 					},
 					},
 
 
 					(res, next) => {
 					(res, next) => {
@@ -1438,19 +1516,11 @@ class _StationsModule extends CoreClass {
 					},
 					},
 
 
 					next => {
 					next => {
-						DBModule.runJob(
-							"GET_MODEL",
-							{
-								modelName: "station"
-							},
-							this
-						).then(stationModel => {
-							stationModel.updateOne(
-								{ _id: payload.stationId },
-								{ $pull: { blacklist: payload.playlistId } },
-								next
-							);
-						});
+						StationsModule.stationModel.updateOne(
+							{ _id: payload.stationId },
+							{ $pull: { blacklist: payload.playlistId } },
+							next
+						);
 					},
 					},
 
 
 					(res, next) => {
 					(res, next) => {
@@ -1574,6 +1644,10 @@ class _StationsModule extends CoreClass {
 											stations,
 											stations,
 											1,
 											1,
 											(station, next) => {
 											(station, next) => {
+												this.publishProgress({
+													status: "update",
+													message: `Updating station "${station._id}"`
+												});
 												StationsModule.runJob("UPDATE_STATION", {
 												StationsModule.runJob("UPDATE_STATION", {
 													stationId: station._id
 													stationId: station._id
 												})
 												})
@@ -1660,6 +1734,263 @@ class _StationsModule extends CoreClass {
 			);
 			);
 		});
 		});
 	}
 	}
+
+	/**
+	 * Add to a station queue
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @param {string} payload.requestUser - the requesting user id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	ADD_TO_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, youtubeId, requestUser } = payload;
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						if (!station.requests.enabled) return next("Requests are disabled in this station.");
+						if (station.currentSong && station.currentSong.youtubeId === youtubeId)
+							return next("That song is currently playing.");
+						if (station.queue.find(song => song.youtubeId === youtubeId))
+							return next("That song is already in the queue.");
+
+						return next(null, station);
+					},
+
+					(station, next) => {
+						MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
+							.then(response => {
+								const { song } = response;
+								const { _id, title, skipDuration, artists, thumbnail, duration, verified } = song;
+								next(
+									null,
+									{
+										_id,
+										youtubeId,
+										title,
+										skipDuration,
+										artists,
+										thumbnail,
+										duration,
+										verified
+									},
+									station
+								);
+							})
+							.catch(next);
+					},
+
+					(song, station, next) => {
+						const blacklist = [];
+						async.eachLimit(
+							station.blacklist,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										blacklist.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, song, station, blacklist);
+							}
+						);
+					},
+
+					(song, station, blacklist, next) => {
+						const blacklistedSongs = blacklist
+							.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
+							.reduce(
+								(items, item) =>
+									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+								[]
+							);
+
+						if (blacklistedSongs.find(blacklistedSong => blacklistedSong.youtubeId === song.youtubeId))
+							next("That song is in an blacklisted playlist and cannot be played.");
+						else next(null, song, station);
+					},
+
+					(song, station, next) => {
+						song.requestedBy = requestUser;
+						song.requestedAt = Date.now();
+						if (station.queue.length === 0) return next(null, song);
+						if (
+							requestUser &&
+							station.queue.filter(queueSong => queueSong.requestedBy === song.requestedBy).length >=
+								station.requests.limit
+						)
+							return next(`The max amount of songs per user is ${station.requests.limit}.`);
+						return next(null, song);
+					},
+
+					// (song, station, next) => {
+					// 	song.requestedBy = session.userId;
+					// 	song.requestedAt = Date.now();
+					// 	let totalDuration = 0;
+					// 	station.queue.forEach(song => {
+					// 		totalDuration += song.duration;
+					// 	});
+					// 	if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
+					// 	return next(null, song, station);
+					// },
+
+					// (song, station, next) => {
+					// 	if (station.queue.length === 0) return next(null, song, station);
+					// 	let totalDuration = 0;
+					// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+					// 	station.queue.forEach(song => {
+					// 		if (userId === song.requestedBy) {
+					// 			totalDuration += song.duration;
+					// 		}
+					// 	});
+
+					// 	if (totalDuration >= 900) return next("The max length of songs per user is 15 minutes.");
+					// 	return next(null, song, station);
+					// },
+
+					// (song, station, next) => {
+					// 	if (station.queue.length === 0) return next(null, song);
+					// 	let totalSongs = 0;
+					// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+					// 	station.queue.forEach(song => {
+					// 		if (userId === song.requestedBy) {
+					// 			totalSongs += 1;
+					// 		}
+					// 	});
+
+					// 	if (totalSongs <= 2) return next(null, song);
+					// 	if (totalSongs > 3)
+					// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
+					// 	if (
+					// 		station.queue[station.queue.length - 2].requestedBy !== userId ||
+					// 		station.queue[station.queue.length - 3] !== userId
+					// 	)
+					// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
+
+					// 	return next(null, song);
+					// },
+
+					(song, next) => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $push: { queue: song } },
+							{ runValidators: true },
+							next
+						);
+					},
+
+					(res, next) => {
+						StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next => {
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "station.queueUpdate",
+								value: stationId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove from a station queue
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FROM_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, youtubeId } = payload;
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						if (!station.queue.find(song => song.youtubeId === youtubeId))
+							return next("That song is not currently in the queue.");
+
+						return StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $pull: { queue: { youtubeId } } },
+							next
+						);
+					},
+
+					(res, next) => {
+						StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+							.then(station => {
+								if (station.autofill.enabled)
+									StationsModule.runJob("AUTOFILL_STATION", { stationId }, this)
+										.then(() => next())
+										.catch(err => {
+											if (
+												err === "Autofill is disabled in this station" ||
+												err === "Autofill limit reached"
+											)
+												return next();
+											return next(err);
+										});
+								else next();
+							})
+							.catch(next);
+					},
+
+					next =>
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "station.queueUpdate",
+								value: stationId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next)
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
 }
 }
 
 
 export default new _StationsModule();
 export default new _StationsModule();

+ 13 - 9
backend/logic/ws.js

@@ -458,7 +458,7 @@ class _WSModule extends CoreClass {
 			const { socket, req } = payload;
 			const { socket, req } = payload;
 			let SID = "";
 			let SID = "";
 
 
-			socket.ip = req.headers["x-forwarded-for"] || "0..0.0";
+			socket.ip = req.headers["x-forwarded-for"] || "0.0.0.0";
 
 
 			async.waterfall(
 			async.waterfall(
 				[
 				[
@@ -634,17 +634,21 @@ class _WSModule extends CoreClass {
 
 
 				if (!namespace) return socket.dispatch("ERROR", "Invalid namespace.");
 				if (!namespace) return socket.dispatch("ERROR", "Invalid namespace.");
 				if (!action) return socket.dispatch("ERROR", "Invalid action.");
 				if (!action) return socket.dispatch("ERROR", "Invalid action.");
-				if (!WSModule.actions[namespace]) return socket.dispatch("ERROR", "Namespace not found.");
-				if (!WSModule.actions[namespace][action]) return socket.dispatch("ERROR", "Action not found.");
+				if (!WSModule.actions[namespace]) return socket.dispatch("ERROR", `Namespace ${namespace} not found.`);
+				if (!WSModule.actions[namespace][action])
+					return socket.dispatch("ERROR", `Action ${namespace}.${action} not found.`);
 
 
 				if (data[data.length - 1].CB_REF) {
 				if (data[data.length - 1].CB_REF) {
-					const { CB_REF } = data[data.length - 1];
+					const { CB_REF, onProgress } = data[data.length - 1];
 					data.pop();
 					data.pop();
 
 
-					return socket.actions.emit(data.shift(0), [...data, res => socket.dispatch("CB_REF", CB_REF, res)]);
+					return socket.actions.emit(data.shift(0), {
+						args: [...data, res => socket.dispatch("CB_REF", CB_REF, res)],
+						onProgress: onProgress ? res => socket.dispatch("PROGRESS_CB_REF", CB_REF, res) : null
+					});
 				}
 				}
 
 
-				return socket.actions.emit(data.shift(0), data);
+				return socket.actions.emit(data.shift(0), { args: data });
 			};
 			};
 
 
 			// have the socket listen for each action
 			// have the socket listen for each action
@@ -654,9 +658,9 @@ class _WSModule extends CoreClass {
 					const name = `${namespace}.${action}`;
 					const name = `${namespace}.${action}`;
 
 
 					// listen for this action to be called
 					// listen for this action to be called
-					socket.listen(name, async args =>
-						WSModule.runJob("RUN_ACTION", { socket, namespace, action, args })
-					);
+					socket.listen(name, async ({ args, onProgress }) => {
+						WSModule.runJob("RUN_ACTION", { socket, namespace, action, args }, { onProgress });
+					});
 				});
 				});
 			});
 			});
 
 

+ 1463 - 168
backend/logic/youtube.js

@@ -1,3 +1,4 @@
+import mongoose from "mongoose";
 import async from "async";
 import async from "async";
 import config from "config";
 import config from "config";
 
 
@@ -38,12 +39,54 @@ class RateLimitter {
 }
 }
 
 
 let YouTubeModule;
 let YouTubeModule;
+let CacheModule;
+let DBModule;
+let MediaModule;
+let SongsModule;
+let StationsModule;
+let PlaylistsModule;
+let WSModule;
+
+const isQuotaExceeded = apiCalls => {
+	const reversedApiCalls = apiCalls.slice().reverse();
+	const quotas = config.get("apis.youtube.quotas").slice();
+	const sortedQuotas = quotas.sort((a, b) => a.limit > b.limit);
+
+	let quotaExceeded = false;
+
+	sortedQuotas.forEach(quota => {
+		let quotaUsed = 0;
+		let dateCutoff = null;
+
+		if (quota.type === "QUERIES_PER_MINUTE") dateCutoff = new Date() - 1000 * 60;
+		else if (quota.type === "QUERIES_PER_100_SECONDS") dateCutoff = new Date() - 1000 * 100;
+		else if (quota.type === "QUERIES_PER_DAY") {
+			// Quota resets at midnight PT, this is my best guess to convert the current date to the last midnight PT
+			dateCutoff = new Date();
+			dateCutoff.setUTCMilliseconds(0);
+			dateCutoff.setUTCSeconds(0);
+			dateCutoff.setUTCMinutes(0);
+			dateCutoff.setUTCHours(dateCutoff.getUTCHours() - 7);
+			dateCutoff.setUTCHours(0);
+		}
+
+		reversedApiCalls.forEach(apiCall => {
+			if (apiCall.date >= dateCutoff) quotaUsed += apiCall.quotaCost;
+		});
+
+		if (quotaUsed >= quota.limit) {
+			quotaExceeded = true;
+		}
+	});
+
+	return quotaExceeded;
+};
 
 
 class _YouTubeModule extends CoreClass {
 class _YouTubeModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
 	constructor() {
 		super("youtube", {
 		super("youtube", {
-			concurrency: 1,
+			concurrency: 10,
 			priorities: {
 			priorities: {
 				GET_PLAYLIST: 11
 				GET_PLAYLIST: 11
 			}
 			}
@@ -57,8 +100,62 @@ class _YouTubeModule extends CoreClass {
 	 *
 	 *
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	initialize() {
+	async initialize() {
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		MediaModule = this.moduleManager.modules.media;
+		SongsModule = this.moduleManager.modules.songs;
+		StationsModule = this.moduleManager.modules.stations;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+		WSModule = this.moduleManager.modules.ws;
+
+		this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "youtubeApiRequest"
+		});
+
+		this.youtubeVideoModel = this.YoutubeVideoModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "youtubeVideo"
+		});
+
 		return new Promise(resolve => {
 		return new Promise(resolve => {
+			CacheModule.runJob("SUB", {
+				channel: "youtube.removeYoutubeApiRequest",
+				cb: requestId => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `view-api-request.${requestId}`,
+						args: ["event:youtubeApiRequest.removed"]
+					});
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.youtube",
+						args: ["event:admin.youtubeApiRequest.removed", { data: { requestId } }]
+					});
+				}
+			});
+
+			CacheModule.runJob("SUB", {
+				channel: "youtube.removeVideos",
+				cb: videoIds => {
+					const videos = Array.isArray(videoIds) ? videoIds : [videoIds];
+					videos.forEach(videoId => {
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `view-youtube-video.${videoId}`,
+							args: ["event:youtubeVideo.removed"]
+						});
+
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: "admin.youtubeVideos",
+							args: ["event:admin.youtubeVideo.removed", { data: { videoId } }]
+						});
+
+						WSModule.runJob("EMIT_TO_ROOMS", {
+							rooms: ["import-album", "edit-songs"],
+							args: ["event:admin.youtubeVideo.removed", { videoId }]
+						});
+					});
+				}
+			});
+
 			this.rateLimiter = new RateLimitter(config.get("apis.youtube.rateLimit"));
 			this.rateLimiter = new RateLimitter(config.get("apis.youtube.rateLimit"));
 			this.requestTimeout = config.get("apis.youtube.requestTimeout");
 			this.requestTimeout = config.get("apis.youtube.requestTimeout");
 
 
@@ -70,7 +167,19 @@ class _YouTubeModule extends CoreClass {
 			};
 			};
 			rax.attach(this.axios);
 			rax.attach(this.axios);
 
 
-			resolve();
+			this.youtubeApiRequestModel
+				.find(
+					{ date: { $gte: new Date() - 2 * 24 * 60 * 60 * 1000 } },
+					{ date: true, quotaCost: true, _id: false }
+				)
+				.sort({ date: 1 })
+				.exec((err, youtubeApiRequests) => {
+					if (err) console.log("Couldn't load YouTube API requests.");
+					else {
+						this.apiCalls = youtubeApiRequests;
+						resolve();
+					}
+				});
 		});
 		});
 	}
 	}
 
 
@@ -86,7 +195,6 @@ class _YouTubeModule extends CoreClass {
 		const params = {
 		const params = {
 			part: "snippet",
 			part: "snippet",
 			q: payload.query,
 			q: payload.query,
-			key: config.get("apis.youtube.key"),
 			type: "video",
 			type: "video",
 			maxResults: 10
 			maxResults: 10
 		};
 		};
@@ -94,123 +202,401 @@ class _YouTubeModule extends CoreClass {
 		if (payload.pageToken) params.pageToken = payload.pageToken;
 		if (payload.pageToken) params.pageToken = payload.pageToken;
 
 
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
-			YouTubeModule.rateLimiter.continue().then(() => {
-				YouTubeModule.rateLimiter.restart();
-				YouTubeModule.axios
-					.get("https://www.googleapis.com/youtube/v3/search", {
-						params,
-						raxConfig: {
-							onRetryAttempt: err => {
-								const cfg = rax.getConfig(err);
-								YouTubeModule.log(
-									"ERROR",
-									"SEARCH",
-									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
-								);
-							}
-						}
-					})
-					.then(res => {
-						if (res.data.err) {
-							YouTubeModule.log("ERROR", "SEARCH", `${res.data.error.message}`);
-							return reject(new Error("An error has occured. Please try again later."));
-						}
+			YouTubeModule.runJob(
+				"API_SEARCH",
+				{
+					params
+				},
+				this
+			)
+				.then(({ response }) => {
+					const { data } = response;
 
 
-						return resolve(res.data);
-					})
-					.catch(err => {
-						YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
-						return reject(new Error("An error has occured. Please try again later."));
-					});
-			});
+					return resolve(data);
+				})
+				.catch(err => {
+					YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
+					return reject(new Error("An error has occured. Please try again later."));
+				});
 		});
 		});
 	}
 	}
 
 
 	/**
 	/**
-	 * Gets the details of a song using the YouTube API
+	 * Returns details about the YouTube quota usage
 	 *
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload - object that contains the payload
-	 * @param {string} payload.youtubeId - the YouTube API id of the song
+	 * @param {string} payload.fromDate - date to select requests up to
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	GET_SONG(payload) {
+	GET_QUOTA_STATUS(payload) {
 		return new Promise((resolve, reject) => {
 		return new Promise((resolve, reject) => {
-			const params = {
-				part: "snippet,contentDetails,statistics,status",
-				id: payload.youtubeId,
-				key: config.get("apis.youtube.key")
-			};
+			const fromDate = payload.fromDate ? new Date(payload.fromDate) : new Date();
 
 
-			if (payload.pageToken) params.pageToken = payload.pageToken;
+			YouTubeModule.youtubeApiRequestModel
+				.find(
+					{ date: { $gte: fromDate - 2 * 24 * 60 * 60 * 1000, $lte: fromDate } },
+					{ date: true, quotaCost: true, _id: false }
+				)
+				.sort({ date: 1 })
+				.exec((err, youtubeApiRequests) => {
+					if (err) reject(new Error("Couldn't load YouTube API requests."));
+					else {
+						const reversedApiCalls = youtubeApiRequests.slice().reverse();
+						const quotas = config.get("apis.youtube.quotas").slice();
+						const sortedQuotas = quotas.sort((a, b) => a.limit > b.limit);
+						const status = {};
 
 
-			YouTubeModule.rateLimiter.continue().then(() => {
-				YouTubeModule.rateLimiter.restart();
-				YouTubeModule.axios
-					.get("https://www.googleapis.com/youtube/v3/videos", {
-						params,
-						timeout: YouTubeModule.requestTimeout,
-						raxConfig: {
-							onRetryAttempt: err => {
-								const cfg = rax.getConfig(err);
-								YouTubeModule.log(
-									"ERROR",
-									"GET_SONG",
-									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
-								);
+						sortedQuotas.forEach(quota => {
+							status[quota.type] = {
+								title: quota.title,
+								quotaUsed: 0,
+								limit: quota.limit,
+								quotaExceeded: false
+							};
+							let dateCutoff = null;
+
+							if (quota.type === "QUERIES_PER_MINUTE") dateCutoff = new Date(fromDate) - 1000 * 60;
+							else if (quota.type === "QUERIES_PER_100_SECONDS")
+								dateCutoff = new Date(fromDate) - 1000 * 100;
+							else if (quota.type === "QUERIES_PER_DAY") {
+								// Quota resets at midnight PT, this is my best guess to convert the current date to the last midnight PT
+								dateCutoff = new Date(fromDate);
+								dateCutoff.setUTCMilliseconds(0);
+								dateCutoff.setUTCSeconds(0);
+								dateCutoff.setUTCMinutes(0);
+								dateCutoff.setUTCHours(dateCutoff.getUTCHours() - 7);
+								dateCutoff.setUTCHours(0);
 							}
 							}
-						}
-					})
-					.then(res => {
-						if (res.data.error) {
-							YouTubeModule.log("ERROR", "GET_SONG", `${res.data.error.message}`);
-							return reject(new Error("An error has occured. Please try again later."));
+
+							reversedApiCalls.forEach(apiCall => {
+								if (apiCall.date >= dateCutoff) status[quota.type].quotaUsed += apiCall.quotaCost;
+							});
+
+							if (status[quota.type].quotaUsed >= quota.limit && !status[quota.type].quotaExceeded)
+								status[quota.type].quotaExceeded = true;
+						});
+
+						resolve({ status });
+					}
+				});
+		});
+	}
+
+	/**
+	 * Returns YouTube quota chart data
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.timePeriod - either hours or days
+	 * @param {string} payload.startDate - beginning date
+	 * @param {string} payload.endDate - end date
+	 * @param {string} payload.dataType - either usage or count
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_QUOTA_CHART_DATA(payload) {
+		return new Promise((resolve, reject) => {
+			const { timePeriod, startDate, endDate, dataType } = payload;
+
+			// const timePeriod = "hours";
+			// const startDate = new Date("2022-05-20 00:00:00");
+			// const endDate = new Date("2022-05-21 00:00:00");
+
+			// const timePeriod = "days";
+			// const startDate = new Date("2022-05-15 00:00:00");
+			// const endDate = new Date("2022-05-21 00:00:00");
+			// const endDate = new Date("2022-05-30 00:00:00");
+
+			// const dataType = "usage";
+			// const dataType = "count";
+
+			async.waterfall(
+				[
+					next => {
+						let timeRanges = [];
+						if (timePeriod === "hours") {
+							startDate.setMinutes(0, 0, 0);
+							endDate.setMinutes(0, 0, 0);
+							const lastDate = new Date(startDate);
+							do {
+								const newDate = new Date(lastDate.getTime() + 1000 * 60 * 60);
+
+								timeRanges.push({
+									startDate: new Date(lastDate),
+									endDate: newDate
+								});
+
+								lastDate.setTime(newDate.getTime());
+							} while (lastDate.getTime() < endDate.getTime());
+							if (timeRanges.length === 0 || timeRanges.length > 24)
+								return next("No valid time ranges specified.");
+							timeRanges = timeRanges.map(timeRange => ({
+								...timeRange,
+								label: `${timeRange.startDate.getHours().toString().padStart(2, "0")}:00`
+							}));
+						} else if (timePeriod === "days") {
+							startDate.setHours(0, 0, 0, 0);
+							endDate.setHours(0, 0, 0, 0);
+							const lastDate = new Date(startDate);
+							do {
+								const newDate = new Date(lastDate.getTime() + 1000 * 60 * 60 * 24);
+
+								timeRanges.push({
+									startDate: new Date(lastDate),
+									endDate: newDate
+								});
+
+								lastDate.setTime(newDate.getTime());
+							} while (lastDate.getTime() < endDate.getTime());
+							if (timeRanges.length === 0 || timeRanges.length > 31)
+								return next("No valid time ranges specified.");
+							const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+							if (timeRanges.length <= 7)
+								timeRanges = timeRanges.map(timeRange => ({
+									...timeRange,
+									label: days[timeRange.startDate.getDay()]
+								}));
+							else
+								timeRanges = timeRanges.map(timeRange => ({
+									...timeRange,
+									label: `${timeRange.startDate.getDate().toString().padStart(2, "0")}-${(
+										timeRange.startDate.getMonth() + 1
+									)
+										.toString()
+										.padStart(2, "0")}`
+								}));
 						}
 						}
 
 
-						if (res.data.items[0] === undefined)
-							return reject(
-								new Error("The specified video does not exist or cannot be publicly accessed.")
-							);
+						return next(null, timeRanges);
+					},
 
 
-						// TODO Clean up duration converter
-						let dur = res.data.items[0].contentDetails.duration;
+					(timeRanges, next) => {
+						YouTubeModule.youtubeApiRequestModel
+							.find({
+								date: { $gte: startDate, $lte: endDate }
+							})
+							.sort({ date: 1 })
+							.exec((err, youtubeApiRequests) => {
+								next(err, timeRanges, youtubeApiRequests);
+							});
+					},
 
 
-						dur = dur.replace("PT", "");
+					(timeRanges, youtubeApiRequests, next) => {
+						const regex = /https:\/\/www\.googleapis\.com\/youtube\/v3\/(.+)/;
+						const requestTypes = Object.fromEntries(
+							youtubeApiRequests
+								.map(youtubeApiRequest => regex.exec(youtubeApiRequest.url)[1])
+								.filter((requestType, index, array) => array.indexOf(requestType) === index)
+								.map(requestType => [requestType, 0])
+						);
+						timeRanges = timeRanges.map(timeRange => ({
+							...timeRange,
+							data: { total: 0, ...requestTypes }
+						}));
 
 
-						let duration = 0;
+						youtubeApiRequests.forEach(youtubeApiRequest => {
+							timeRanges.forEach(timeRange => {
+								if (
+									timeRange.startDate <= youtubeApiRequest.date &&
+									timeRange.endDate >= youtubeApiRequest.date
+								) {
+									const requestType = regex.exec(youtubeApiRequest.url)[1];
+									if (!timeRange.data[requestType]) timeRange.data[requestType] = 0;
 
 
-						dur = dur.replace(/([\d]*)H/, (v, v2) => {
-							v2 = Number(v2);
-							duration = v2 * 60 * 60;
-							return "";
+									if (dataType === "usage") {
+										timeRange.data[requestType] += youtubeApiRequest.quotaCost;
+										timeRange.data.total += youtubeApiRequest.quotaCost;
+									} else if (dataType === "count") {
+										timeRange.data[requestType] += 1;
+										timeRange.data.total += 1;
+									}
+								}
+							});
 						});
 						});
 
 
-						dur = dur.replace(/([\d]*)M/, (v, v2) => {
-							v2 = Number(v2);
-							duration += v2 * 60;
-							return "";
-						});
+						next(null, timeRanges);
+					},
 
 
-						// eslint-disable-next-line no-unused-vars
-						dur = dur.replace(/([\d]*)S/, (v, v2) => {
-							v2 = Number(v2);
-							duration += v2;
-							return "";
+					(timeRanges, next) => {
+						const chartData = {};
+						chartData.labels = timeRanges.map(timeRange => timeRange.label);
+						const datasetTypes = Object.keys(timeRanges[0].data);
+						const colors = {
+							total: "rgb(2, 166, 242)",
+							videos: "rgb(166, 2, 242)",
+							search: "rgb(242, 2, 166)",
+							channels: "rgb(2, 242, 166)",
+							playlistItems: "rgb(242, 166, 2)"
+						};
+						chartData.datasets = datasetTypes.map(datasetType => {
+							let label;
+							switch (datasetType) {
+								case "total":
+									label = "Total";
+									break;
+								case "videos":
+									label = "Videos";
+									break;
+								case "search":
+									label = "Search";
+									break;
+								case "playlistItems":
+									label = "Playlist Items";
+									break;
+								default:
+									label = datasetType;
+							}
+							return {
+								label,
+								borderColor: colors[datasetType],
+								backgroundColor: colors[datasetType],
+								data: timeRanges.map(timeRange => timeRange.data[datasetType])
+							};
 						});
 						});
 
 
-						const song = {
-							youtubeId: res.data.items[0].id,
-							title: res.data.items[0].snippet.title,
-							thumbnail: res.data.items[0].snippet.thumbnails.default.url,
-							duration
+						next(null, chartData);
+					}
+				],
+				(err, chartData) => {
+					if (err) reject(err);
+					else resolve(chartData);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets the id of the channel upload playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.id - the id of the YouTube channel. Optional: can be left out if specifying a username.
+	 * @param {string} payload.username - the username of the YouTube channel. Only gets used if no id is specified.
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_CHANNEL_UPLOADS_PLAYLIST_ID(payload) {
+		return new Promise((resolve, reject) => {
+			const params = {
+				part: "id,contentDetails"
+			};
+
+			if (payload.id) params.id = payload.id;
+			else params.forUsername = payload.username;
+
+			YouTubeModule.runJob(
+				"API_GET_CHANNELS",
+				{
+					params
+				},
+				this
+			)
+				.then(({ response }) => {
+					const { data } = response;
+
+					if (data.pageInfo.totalResults === 0) return reject(new Error("Channel not found."));
+
+					const playlistId = data.items[0].contentDetails.relatedPlaylists.uploads;
+
+					return resolve({ playlistId });
+				})
+				.catch(err => {
+					YouTubeModule.log("ERROR", "GET_CHANNEL_UPLOADS_PLAYLIST_ID", `${err.message}`);
+					if (err.message === "Request failed with status code 404") {
+						return reject(new Error("Channel not found. Is the channel public/unlisted?"));
+					}
+					return reject(new Error("An error has occured. Please try again later."));
+				});
+		});
+	}
+
+	/**
+	 * Gets the id of the channel from the custom URL
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.customUrl - the customUrl of the YouTube channel
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_CHANNEL_ID_FROM_CUSTOM_URL(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const params = {
+							part: "snippet",
+							type: "channel",
+							maxResults: 50
 						};
 						};
 
 
-						return resolve({ song });
-					})
-					.catch(err => {
-						YouTubeModule.log("ERROR", "GET_SONG", `${err.message}`);
+						params.q = payload.customUrl;
+
+						YouTubeModule.runJob(
+							"API_SEARCH",
+							{
+								params
+							},
+							this
+						)
+							.then(({ response }) => {
+								const { data } = response;
+
+								if (data.pageInfo.totalResults === 0) return next("Channel not found.");
+
+								const channelIds = data.items.map(item => item.id.channelId);
+
+								return next(null, channelIds);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(channelIds, next) => {
+						const params = {
+							part: "snippet",
+							id: channelIds.join(","),
+							maxResults: 50
+						};
+
+						YouTubeModule.runJob(
+							"API_GET_CHANNELS",
+							{
+								params
+							},
+							this
+						)
+							.then(({ response }) => {
+								const { data } = response;
+
+								if (data.pageInfo.totalResults === 0) return next("Channel not found.");
+
+								let channelId = null;
+								data.items.forEach(item => {
+									if (
+										item.snippet.customUrl &&
+										item.snippet.customUrl.toLowerCase() === payload.customUrl.toLowerCase()
+									) {
+										channelId = item.id;
+									}
+								});
+
+								if (!channelId) return next("Channel not found.");
+
+								return next(null, channelId);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				(err, channelId) => {
+					if (err) {
+						YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message}`);
+						if (err.message === "Request failed with status code 404") {
+							return reject(new Error("Channel not found. Is the channel public/unlisted?"));
+						}
 						return reject(new Error("An error has occured. Please try again later."));
 						return reject(new Error("An error has occured. Please try again later."));
-					});
-			});
+					}
+
+					return resolve({ channelId });
+				}
+			);
 		});
 		});
 	}
 	}
 
 
@@ -292,7 +678,7 @@ class _YouTubeModule extends CoreClass {
 	}
 	}
 
 
 	/**
 	/**
-	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST.
+	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL.
 	 *
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload - object that contains the payload
 	 * @param {boolean} payload.playlistId - the playlist id to get videos from
 	 * @param {boolean} payload.playlistId - the playlist id to get videos from
@@ -305,49 +691,34 @@ class _YouTubeModule extends CoreClass {
 			const params = {
 			const params = {
 				part: "contentDetails",
 				part: "contentDetails",
 				playlistId: payload.playlistId,
 				playlistId: payload.playlistId,
-				key: config.get("apis.youtube.key"),
 				maxResults: 50
 				maxResults: 50
 			};
 			};
 
 
 			if (payload.nextPageToken) params.pageToken = payload.nextPageToken;
 			if (payload.nextPageToken) params.pageToken = payload.nextPageToken;
 
 
-			YouTubeModule.rateLimiter.continue().then(() => {
-				YouTubeModule.rateLimiter.restart();
-				YouTubeModule.axios
-					.get("https://www.googleapis.com/youtube/v3/playlistItems", {
-						params,
-						timeout: YouTubeModule.requestTimeout,
-						raxConfig: {
-							onRetryAttempt: err => {
-								const cfg = rax.getConfig(err);
-								YouTubeModule.log(
-									"ERROR",
-									"GET_PLAYLIST_PAGE",
-									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
-								);
-							}
-						}
-					})
-					.then(res => {
-						if (res.data.err) {
-							YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${res.data.error.message}`);
-							return reject(new Error("An error has occured. Please try again later."));
-						}
+			YouTubeModule.runJob(
+				"API_GET_PLAYLIST_ITEMS",
+				{
+					params
+				},
+				this
+			)
+				.then(({ response }) => {
+					const { data } = response;
 
 
-						const songs = res.data.items;
+					const songs = data.items;
 
 
-						if (res.data.nextPageToken) return resolve({ nextPageToken: res.data.nextPageToken, songs });
+					if (data.nextPageToken) return resolve({ nextPageToken: data.nextPageToken, songs });
 
 
-						return resolve({ songs });
-					})
-					.catch(err => {
-						YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
-						if (err.message === "Request failed with status code 404") {
-							return reject(new Error("Playlist not found. Is the playlist public/unlisted?"));
-						}
-						return reject(new Error("An error has occured. Please try again later."));
-					});
-			});
+					return resolve({ songs });
+				})
+				.catch(err => {
+					YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
+					if (err.message === "Request failed with status code 404") {
+						return reject(new Error("Playlist not found. Is the playlist public/unlisted?"));
+					}
+					return reject(new Error("An error has occured. Please try again later."));
+				});
 		});
 		});
 	}
 	}
 
 
@@ -375,56 +746,980 @@ class _YouTubeModule extends CoreClass {
 			const params = {
 			const params = {
 				part: "topicDetails",
 				part: "topicDetails",
 				id: localVideoIds.join(","),
 				id: localVideoIds.join(","),
-				key: config.get("apis.youtube.key"),
 				maxResults: videosPerPage
 				maxResults: videosPerPage
 			};
 			};
 
 
-			YouTubeModule.rateLimiter.continue().then(() => {
-				YouTubeModule.rateLimiter.restart();
-				YouTubeModule.axios
-					.get("https://www.googleapis.com/youtube/v3/videos", {
-						params,
-						timeout: YouTubeModule.requestTimeout,
-						raxConfig: {
-							onRetryAttempt: err => {
-								const cfg = rax.getConfig(err);
+			YouTubeModule.runJob("API_GET_VIDEOS", { params }, this)
+				.then(({ response }) => {
+					const { data } = response;
+
+					const videoIds = [];
+
+					data.items.forEach(item => {
+						const videoId = item.id;
+
+						if (!item.topicDetails) return;
+						if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
+							videoIds.push(videoId);
+					});
+
+					return YouTubeModule.runJob(
+						"FILTER_MUSIC_VIDEOS",
+						{ videoIds: payload.videoIds, page: page + 1 },
+						this
+					)
+						.then(result => resolve({ videoIds: videoIds.concat(result.videoIds) }))
+						.catch(err => reject(err));
+				})
+				.catch(err => {
+					YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
+					return reject(new Error("Failed to find playlist from YouTube"));
+				});
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a YouTube channel
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the channel
+	 * @param {string} payload.url - the url of the YouTube channel
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_CHANNEL(payload) {
+		return new Promise((resolve, reject) => {
+			const regex =
+				/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
+			const splitQuery = regex.exec(payload.url);
+
+			if (!splitQuery) {
+				YouTubeModule.log("ERROR", "GET_CHANNEL", "Invalid YouTube channel URL query.");
+				reject(new Error("Invalid playlist URL."));
+				return;
+			}
+			const channelId = splitQuery[1];
+			const channelUsername = splitQuery[2];
+			const channelCustomUrl = splitQuery[3];
+			const channelUsernameOrCustomUrl = splitQuery[4];
+
+			console.log(`Channel id: ${channelId}`);
+			console.log(`Channel username: ${channelUsername}`);
+			console.log(`Channel custom URL: ${channelCustomUrl}`);
+			console.log(`Channel username or custom URL: ${channelUsernameOrCustomUrl}`);
+
+			async.waterfall(
+				[
+					next => {
+						const payload = {};
+						if (channelId) payload.id = channelId;
+						else if (channelUsername) payload.username = channelUsername;
+						else return next(null, true, null);
+
+						return YouTubeModule.runJob("GET_CHANNEL_UPLOADS_PLAYLIST_ID", payload, this)
+							.then(({ playlistId }) => {
+								next(null, false, playlistId);
+							})
+							.catch(err => {
+								if (err.message === "Channel not found. Is the channel public/unlisted?")
+									next(null, true, null);
+								else next(err);
+							});
+					},
+
+					(getUsernameFromCustomUrl, playlistId, next) => {
+						if (!getUsernameFromCustomUrl) return next(null, playlistId);
+
+						const payload = {};
+						if (channelCustomUrl) payload.customUrl = channelCustomUrl;
+						else if (channelUsernameOrCustomUrl) payload.customUrl = channelUsernameOrCustomUrl;
+						else return next("No proper URL provided.");
+
+						return YouTubeModule.runJob("GET_CHANNEL_ID_FROM_CUSTOM_URL", payload, this)
+							.then(({ channelId }) => {
+								YouTubeModule.runJob("GET_CHANNEL_UPLOADS_PLAYLIST_ID", { id: channelId }, this)
+									.then(({ playlistId }) => {
+										next(null, playlistId);
+									})
+									.catch(err => next(err));
+							})
+							.catch(err => next(err));
+					},
+
+					(playlistId, next) => {
+						let songs = [];
+						let nextPageToken = "";
+
+						async.whilst(
+							next => {
 								YouTubeModule.log(
 								YouTubeModule.log(
-									"ERROR",
-									"FILTER_MUSIC_VIDEOS",
-									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+									"INFO",
+									`Getting channel progress for job (${this.toString()}): ${
+										songs.length
+									} songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
 								);
 								);
-							}
-						}
-					})
-					.then(res => {
-						if (res.data.err) {
-							YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${res.data.error.message}`);
-							return reject(new Error("An error has occured. Please try again later."));
-						}
+								next(null, nextPageToken !== undefined);
+							},
+							next => {
+								// Add 250ms delay between each job request
+								setTimeout(() => {
+									YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
+										.then(response => {
+											songs = songs.concat(response.songs);
+											nextPageToken = response.nextPageToken;
+											next();
+										})
+										.catch(err => next(err));
+								}, 250);
+							},
+							err => next(err, songs)
+						);
+					},
 
 
-						const videoIds = [];
+					(songs, next) =>
+						next(
+							null,
+							songs.map(song => song.contentDetails.videoId)
+						),
 
 
-						res.data.items.forEach(item => {
-							const videoId = item.id;
+					(songs, next) => {
+						if (!payload.musicOnly) return next(true, { songs });
+						return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
+							.then(filteredSongs => next(null, { filteredSongs, songs }))
+							.catch(next);
+					}
+				],
+				(err, response) => {
+					if (err && err !== true) {
+						YouTubeModule.log("ERROR", "GET_CHANNEL", "Some error has occurred.", err.message);
+						reject(new Error(err.message));
+					} else {
+						resolve({ songs: response.filteredSongs ? response.filteredSongs.videoIds : response.songs });
+					}
+				}
+			);
+		});
+	}
 
 
-							if (!item.topicDetails) return;
-							if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
-								videoIds.push(videoId);
-						});
+	/**
+	 * Perform YouTube API get videos request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_VIDEOS(payload) {
+		return new Promise((resolve, reject) => {
+			const { params } = payload;
 
 
-						return YouTubeModule.runJob(
-							"FILTER_MUSIC_VIDEOS",
-							{ videoIds: payload.videoIds, page: page + 1 },
-							this
-						)
-							.then(result => resolve({ videoIds: videoIds.concat(result.videoIds) }))
-							.catch(err => reject(err));
+			YouTubeModule.runJob(
+				"API_CALL",
+				{
+					url: "https://www.googleapis.com/youtube/v3/videos",
+					params: {
+						key: config.get("apis.youtube.key"),
+						...params
+					},
+					quotaCost: 1
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform YouTube API get playlist items request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_PLAYLIST_ITEMS(payload) {
+		return new Promise((resolve, reject) => {
+			const { params } = payload;
+
+			YouTubeModule.runJob(
+				"API_CALL",
+				{
+					url: "https://www.googleapis.com/youtube/v3/playlistItems",
+					params: {
+						key: config.get("apis.youtube.key"),
+						...params
+					},
+					quotaCost: 1
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform YouTube API get channels request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_CHANNELS(payload) {
+		return new Promise((resolve, reject) => {
+			const { params } = payload;
+
+			YouTubeModule.runJob(
+				"API_CALL",
+				{
+					url: "https://www.googleapis.com/youtube/v3/channels",
+					params: {
+						key: config.get("apis.youtube.key"),
+						...params
+					},
+					quotaCost: 1
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform YouTube API search request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_SEARCH(payload) {
+		return new Promise((resolve, reject) => {
+			const { params } = payload;
+
+			YouTubeModule.runJob(
+				"API_CALL",
+				{
+					url: "https://www.googleapis.com/youtube/v3/search",
+					params: {
+						key: config.get("apis.youtube.key"),
+						...params
+					},
+					quotaCost: 100
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform YouTube API call
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.url - request url
+	 * @param {object} payload.params - request parameters
+	 * @param {object} payload.quotaCost - request quotaCost
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_CALL(payload) {
+		return new Promise((resolve, reject) => {
+			const { url, params, quotaCost } = payload;
+
+			const quotaExceeded = isQuotaExceeded(YouTubeModule.apiCalls);
+
+			if (quotaExceeded) reject(new Error("Quota has been exceeded. Please wait a while."));
+			else {
+				const youtubeApiRequest = new YouTubeModule.YoutubeApiRequestModel({
+					url,
+					date: Date.now(),
+					quotaCost
+				});
+
+				youtubeApiRequest.save();
+
+				const { key, ...keylessParams } = payload.params;
+				CacheModule.runJob(
+					"HSET",
+					{
+						table: "youtubeApiRequestParams",
+						key: youtubeApiRequest._id.toString(),
+						value: JSON.stringify(keylessParams)
+					},
+					this
+				).then();
+
+				YouTubeModule.apiCalls.push({ date: youtubeApiRequest.date, quotaCost });
+
+				YouTubeModule.axios
+					.get(url, {
+						params,
+						timeout: YouTubeModule.requestTimeout
+					})
+					.then(response => {
+						if (response.data.error) {
+							reject(new Error(response.data.error));
+						} else {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "youtubeApiRequestResults",
+									key: youtubeApiRequest._id.toString(),
+									value: JSON.stringify(response.data)
+								},
+								this
+							).then();
+
+							resolve({ response });
+						}
 					})
 					})
 					.catch(err => {
 					.catch(err => {
-						YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
-						return reject(new Error("Failed to find playlist from YouTube"));
+						reject(err);
 					});
 					});
-			});
+			}
+		});
+	}
+
+	/**
+	 * Fetch all api requests
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.fromDate - data to fetch requests up to
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_API_REQUESTS(payload) {
+		return new Promise((resolve, reject) => {
+			const fromDate = payload.fromDate ? new Date(payload.fromDate) : new Date();
+
+			YouTubeModule.youtubeApiRequestModel
+				.find({ date: { $lte: fromDate } })
+				.sort({ date: -1 })
+				.exec((err, youtubeApiRequests) => {
+					if (err) reject(new Error("Couldn't load YouTube API requests."));
+					else {
+						resolve({ apiRequests: youtubeApiRequests });
+					}
+				});
+		});
+	}
+
+	/**
+	 * Fetch an api request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.apiRequestId - the api request id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_API_REQUEST(payload) {
+		return new Promise((resolve, reject) => {
+			const { apiRequestId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						YouTubeModule.youtubeApiRequestModel.findOne({ _id: apiRequestId }).exec(next);
+					},
+
+					(apiRequest, next) => {
+						CacheModule.runJob(
+							"HGET",
+							{
+								table: "youtubeApiRequestParams",
+								key: apiRequestId.toString()
+							},
+							this
+						)
+							.then(apiRequestParams => {
+								next(null, {
+									...apiRequest._doc,
+									params: apiRequestParams
+								});
+							})
+							.catch(err => next(err));
+					},
+
+					(apiRequest, next) => {
+						CacheModule.runJob(
+							"HGET",
+							{
+								table: "youtubeApiRequestResults",
+								key: apiRequestId.toString()
+							},
+							this
+						)
+							.then(apiRequestResults => {
+								next(null, {
+									...apiRequest,
+									results: apiRequestResults
+								});
+							})
+							.catch(err => next(err));
+					}
+				],
+				(err, apiRequest) => {
+					if (err) reject(new Error(err));
+					else resolve({ apiRequest });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removed all stored api requests from mongo and redis
+	 *
+	 * 	 @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RESET_STORED_API_REQUESTS() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						this.publishProgress({ status: "update", message: `Resetting stored API requests (stage 1)` });
+						YouTubeModule.youtubeApiRequestModel.find({}, next);
+					},
+
+					(apiRequests, next) => {
+						this.publishProgress({ status: "update", message: `Resetting stored API requests (stage 2)` });
+						YouTubeModule.youtubeApiRequestModel.deleteMany({}, err => {
+							if (err) next("Couldn't reset stored YouTube API requests.");
+							else {
+								next(null, apiRequests);
+							}
+						});
+					},
+
+					(apiRequests, next) => {
+						this.publishProgress({ status: "update", message: `Resetting stored API requests (stage 3)` });
+						CacheModule.runJob("DEL", { key: "youtubeApiRequestParams" }, this)
+							.then(() => next(null, apiRequests))
+							.catch(err => next(err));
+					},
+
+					(apiRequests, next) => {
+						this.publishProgress({ status: "update", message: `Resetting stored API requests (stage 4)` });
+						CacheModule.runJob("DEL", { key: "youtubeApiRequestResults" }, this)
+							.then(() => next(null, apiRequests))
+							.catch(err => next(err));
+					},
+
+					(apiRequests, next) => {
+						this.publishProgress({ status: "update", message: `Resetting stored API requests (stage 5)` });
+						async.eachLimit(
+							apiRequests.map(apiRequest => apiRequest._id),
+							1,
+							(requestId, next) => {
+								CacheModule.runJob(
+									"PUB",
+									{
+										channel: "youtube.removeYoutubeApiRequest",
+										value: requestId
+									},
+									this
+								)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								if (err) next(err);
+								else next();
+							}
+						);
+					}
+				],
+				err => {
+					if (err) reject(new Error(err));
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove a stored api request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.requestId - the api request id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REMOVE_STORED_API_REQUEST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						YouTubeModule.youtubeApiRequestModel.deleteOne({ _id: payload.requestId }, err => {
+							if (err) next("Couldn't remove stored YouTube API request.");
+							else {
+								next();
+							}
+						});
+					},
+
+					next => {
+						CacheModule.runJob(
+							"HDEL",
+							{
+								table: "youtubeApiRequestParams",
+								key: payload.requestId.toString()
+							},
+							this
+						)
+							.then(next)
+							.catch(err => next(err));
+					},
+
+					next => {
+						CacheModule.runJob(
+							"HDEL",
+							{
+								table: "youtubeApiRequestResults",
+								key: payload.requestId.toString()
+							},
+							this
+						)
+							.then(next)
+							.catch(err => next(err));
+					},
+
+					next => {
+						CacheModule.runJob("PUB", {
+							channel: "youtube.removeYoutubeApiRequest",
+							value: payload.requestId.toString()
+						})
+							.then(next)
+							.catch(err => next(err));
+					}
+				],
+				err => {
+					if (err) reject(new Error(err));
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * 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;
+						if (typeof youtubeVideos !== "object") next("Invalid youtubeVideos type");
+						else {
+							if (!Array.isArray(youtubeVideos)) youtubeVideos = [youtubeVideos];
+							YouTubeModule.youtubeVideoModel.insertMany(youtubeVideos, next);
+						}
+					},
+
+					(youtubeVideos, next) => {
+						const youtubeIds = youtubeVideos.map(video => video.youtubeId);
+						async.eachLimit(
+							youtubeIds,
+							2,
+							(youtubeId, next) => {
+								MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId }, this)
+									.then(() => next())
+									.catch(next);
+							},
+							err => {
+								if (err) next(err);
+								else next(null, youtubeVideos);
+							}
+						);
+					}
+				],
+				(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
+	 * @param {string} payload.createMissing - attempt to fetch and create video if not in db
+	 * @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);
+					},
+
+					(video, next) => {
+						if (video) return next(null, video, false);
+						if (mongoose.Types.ObjectId.isValid(payload.identifier) || !payload.createMissing)
+							return next("YouTube video not found.");
+
+						const params = {
+							part: "snippet,contentDetails,statistics,status",
+							id: payload.identifier
+						};
+
+						return YouTubeModule.runJob("API_GET_VIDEOS", { params }, this)
+							.then(({ response }) => {
+								const { data } = response;
+								if (data.items[0] === undefined)
+									return next("The specified video does not exist or cannot be publicly accessed.");
+
+								// TODO Clean up duration converter
+								let dur = data.items[0].contentDetails.duration;
+
+								dur = dur.replace("PT", "");
+
+								let duration = 0;
+
+								dur = dur.replace(/([\d]*)H/, (v, v2) => {
+									v2 = Number(v2);
+									duration = v2 * 60 * 60;
+									return "";
+								});
+
+								dur = dur.replace(/([\d]*)M/, (v, v2) => {
+									v2 = Number(v2);
+									duration += v2 * 60;
+									return "";
+								});
+
+								dur.replace(/([\d]*)S/, (v, v2) => {
+									v2 = Number(v2);
+									duration += v2;
+									return "";
+								});
+
+								const youtubeVideo = {
+									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
+								};
+
+								return next(null, false, youtubeVideo);
+							})
+							.catch(next);
+					},
+
+					(video, youtubeVideo, next) => {
+						if (video) return next(null, video, true);
+						return YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: youtubeVideo }, this)
+							.then(res => {
+								if (res.youtubeVideos.length === 1) next(null, res.youtubeVideos[0], false);
+								else next("YouTube video not found.");
+							})
+							.catch(next);
+					}
+				],
+				(err, video, existing) => {
+					if (err) reject(new Error(err));
+					else resolve({ video, existing });
+				}
+			);
+		});
+	}
+
+	/**
+	 * 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) => {
+			let { videoIds } = payload;
+			if (!Array.isArray(videoIds)) videoIds = [videoIds];
+
+			async.waterfall(
+				[
+					next => {
+						if (!videoIds.every(videoId => mongoose.Types.ObjectId.isValid(videoId)))
+							next("One or more videoIds are not a valid ObjectId.");
+						else {
+							this.publishProgress({ status: "update", message: `Removing video (stage 1)` });
+							YouTubeModule.youtubeVideoModel.find({ _id: { $in: videoIds } }, (err, videos) => {
+								if (err) next(err);
+								else
+									next(
+										null,
+										videos.map(video => video.youtubeId)
+									);
+							});
+						}
+					},
+
+					(youtubeIds, next) => {
+						this.publishProgress({ status: "update", message: `Removing video (stage 2)` });
+						SongsModule.SongModel.find({ youtubeId: { $in: youtubeIds } }, (err, songs) => {
+							if (err) next(err);
+							else {
+								const filteredIds = youtubeIds.filter(
+									youtubeId => !songs.find(song => song.youtubeId === youtubeId)
+								);
+								if (filteredIds.length < youtubeIds.length)
+									next("One or more videos are attached to songs.");
+								else next(null, filteredIds);
+							}
+						});
+					},
+
+					(youtubeIds, next) => {
+						this.publishProgress({ status: "update", message: `Removing video (stage 3)` });
+						MediaModule.runJob("REMOVE_RATINGS", { youtubeIds }, this)
+							.then(() => next(null, youtubeIds))
+							.catch(next);
+					},
+
+					(youtubeIds, next) => {
+						this.publishProgress({ status: "update", message: `Removing video (stage 4)` });
+						async.eachLimit(
+							youtubeIds,
+							2,
+							(youtubeId, next) => {
+								async.waterfall(
+									[
+										next => {
+											PlaylistsModule.playlistModel.find(
+												{ "songs.youtubeId": youtubeId },
+												(err, playlists) => {
+													if (err) next(err);
+													else {
+														async.eachLimit(
+															playlists,
+															1,
+															(playlist, next) => {
+																PlaylistsModule.runJob(
+																	"REMOVE_FROM_PLAYLIST",
+																	{ playlistId: playlist._id, youtubeId },
+																	this
+																)
+																	.then(() => next())
+																	.catch(next);
+															},
+															next
+														);
+													}
+												}
+											);
+										},
+
+										next => {
+											StationsModule.stationModel.find(
+												{ "queue.youtubeId": youtubeId },
+												(err, stations) => {
+													if (err) next(err);
+													else {
+														async.eachLimit(
+															stations,
+															1,
+															(station, next) => {
+																StationsModule.runJob(
+																	"REMOVE_FROM_QUEUE",
+																	{ stationId: station._id, youtubeId },
+																	this
+																)
+																	.then(() => next())
+																	.catch(err => {
+																		if (
+																			err === "Station not found" ||
+																			err ===
+																				"Song is not currently in the queue."
+																		)
+																			next();
+																		else next(err);
+																	});
+															},
+															next
+														);
+													}
+												}
+											);
+										},
+
+										next => {
+											StationsModule.stationModel.find(
+												{ "currentSong.youtubeId": youtubeId },
+												(err, stations) => {
+													if (err) next(err);
+													else {
+														async.eachLimit(
+															stations,
+															1,
+															(station, next) => {
+																StationsModule.runJob(
+																	"SKIP_STATION",
+																	{ stationId: station._id, natural: false },
+																	this
+																)
+																	.then(() => {
+																		next();
+																	})
+																	.catch(err => {
+																		if (err.message === "Station not found.")
+																			next();
+																		else next(err);
+																	});
+															},
+															next
+														);
+													}
+												}
+											);
+										}
+									],
+									next
+								);
+							},
+							next
+						);
+					},
+
+					next => {
+						this.publishProgress({ status: "update", message: `Removing video (stage 5)` });
+						YouTubeModule.youtubeVideoModel.deleteMany({ _id: { $in: videoIds } }, next);
+					},
+
+					(res, next) => {
+						this.publishProgress({ status: "update", message: `Removing video (stage 6)` });
+						CacheModule.runJob("PUB", {
+							channel: "youtube.removeVideos",
+							value: videoIds
+						})
+							.then(next)
+							.catch(err => next(err));
+					}
+				],
+				err => {
+					if (err) reject(new Error(err));
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Request a set of YouTube videos
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.url - the url of the the YouTube playlist or channel
+	 * @param {boolean} payload.musicOnly - whether to only get music from the playlist/channel
+	 * @param {boolean} payload.returnVideos - whether to return videos
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REQUEST_SET(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const playlistRegex = /[\\?&]list=([^&#]*)/;
+						const channelRegex =
+							/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
+						if (playlistRegex.exec(payload.url) || channelRegex.exec(payload.url))
+							YouTubeModule.runJob(
+								playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL",
+								{
+									url: payload.url,
+									musicOnly: payload.musicOnly
+								},
+								this
+							)
+								.then(res => {
+									next(null, res.songs);
+								})
+								.catch(next);
+						else next("Invalid YouTube URL.");
+					},
+
+					(youtubeIds, next) => {
+						let successful = 0;
+						let failed = 0;
+						let alreadyInDatabase = 0;
+
+						let videos = {};
+
+						const successfulVideoIds = [];
+						const failedVideoIds = [];
+
+						if (youtubeIds.length === 0) next();
+
+						async.eachOfLimit(
+							youtubeIds,
+							1,
+							(youtubeId, index, next2) => {
+								YouTubeModule.runJob("GET_VIDEO", { identifier: youtubeId, createMissing: true }, this)
+									.then(res => {
+										successful += 1;
+										successfulVideoIds.push(youtubeId);
+
+										if (res.existing) alreadyInDatabase += 1;
+										if (res.video) videos[index] = res.video;
+									})
+									.catch(() => {
+										failed += 1;
+										failedVideoIds.push(youtubeId);
+									})
+									.finally(() => {
+										next2();
+									});
+							},
+							() => {
+								if (payload.returnVideos)
+									videos = Object.keys(videos)
+										.sort()
+										.map(key => videos[key]);
+
+								next(null, {
+									successful,
+									failed,
+									alreadyInDatabase,
+									videos,
+									successfulVideoIds,
+									failedVideoIds
+								});
+							}
+						);
+					}
+				],
+				(err, response) => {
+					if (err) reject(new Error(err));
+					else resolve(response);
+				}
+			);
 		});
 		});
 	}
 	}
 }
 }

File diff suppressed because it is too large
+ 214 - 265
backend/package-lock.json


+ 10 - 10
backend/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "musare-backend",
   "name": "musare-backend",
   "private": true,
   "private": true,
-  "version": "3.5.2",
+  "version": "3.6.0-rc1",
   "type": "module",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
   "main": "index.js",
@@ -10,7 +10,7 @@
   "repository": "https://github.com/Musare/Musare",
   "repository": "https://github.com/Musare/Musare",
   "scripts": {
   "scripts": {
     "dev": "nodemon --es-module-specifier-resolution=node",
     "dev": "nodemon --es-module-specifier-resolution=node",
-    "docker:dev": "nodemon --es-module-specifier-resolution=node -L /opt/app",
+    "docker:dev": "nodemon --es-module-specifier-resolution=node --legacy-watch --no-stdin /opt/app",
     "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
     "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
     "lint": "npx eslint logic"
     "lint": "npx eslint logic"
   },
   },
@@ -23,24 +23,24 @@
     "config": "^3.3.7",
     "config": "^3.3.7",
     "cookie-parser": "^1.4.6",
     "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
-    "express": "^4.17.3",
-    "moment": "^2.29.2",
-    "mongoose": "^6.2.10",
-    "nodemailer": "^6.7.3",
+    "express": "^4.18.1",
+    "moment": "^2.29.3",
+    "mongoose": "^6.3.5",
+    "nodemailer": "^6.7.5",
     "oauth": "^0.9.15",
     "oauth": "^0.9.15",
     "redis": "^3.1.2",
     "redis": "^3.1.2",
     "retry-axios": "^2.6.0",
     "retry-axios": "^2.6.0",
     "sha256": "^0.2.0",
     "sha256": "^0.2.0",
     "socks": "^2.6.2",
     "socks": "^2.6.2",
-    "underscore": "^1.13.2",
-    "ws": "^8.5.0"
+    "underscore": "^1.13.4",
+    "ws": "^8.7.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "eslint": "^8.13.0",
+    "eslint": "^8.17.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jsdoc": "^39.2.0",
+    "eslint-plugin-jsdoc": "^39.3.2",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-prettier": "^4.0.0",
     "prettier": "2.6.2",
     "prettier": "2.6.2",
     "trace-unhandled": "^2.0.1"
     "trace-unhandled": "^2.0.1"

+ 4 - 4
docker-compose.dev.yml

@@ -1,7 +1,7 @@
 services:
 services:
   backend:
   backend:
     ports:
     ports:
-      - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
+      - "${BACKEND_HOST:-0.0.0.0}:${BACKEND_PORT:-8080}:8080"
     volumes:
     volumes:
       - ./backend:/opt/app
       - ./backend:/opt/app
 
 
@@ -11,10 +11,10 @@ services:
 
 
   mongo:
   mongo:
     ports:
     ports:
-      - "${MONGO_HOST}:${MONGO_PORT}:${MONGO_PORT}"
+      - "${MONGO_HOST:-0.0.0.0}:${MONGO_PORT:-27017}:${MONGO_PORT:-27017}"
 
 
   redis:
   redis:
     ports:
     ports:
-      - "${REDIS_HOST}:${REDIS_PORT}:6379"
+      - "${REDIS_HOST:-0.0.0.0}:${REDIS_PORT:-6379}:6379"
     volumes:
     volumes:
-      - ${REDIS_DATA_LOCATION}:/data
+      - ${REDIS_DATA_LOCATION:-./redis}:/data

+ 18 - 10
docker-compose.yml

@@ -2,8 +2,10 @@ version: "3.8"
 
 
 services:
 services:
   backend:
   backend:
-    build: ./backend
-    restart: ${RESTART_POLICY}
+    build:
+      context: ./backend
+      target: musare_backend
+    restart: ${RESTART_POLICY:-unless-stopped}
     volumes:
     volumes:
       - ./.git:/opt/app/.parent_git:ro
       - ./.git:/opt/app/.parent_git:ro
       - /opt/app/node_modules
       - /opt/app/node_modules
@@ -15,35 +17,41 @@ services:
     tty: true
     tty: true
 
 
   frontend:
   frontend:
-    build: ./frontend
-    restart: ${RESTART_POLICY}
+    build:
+      context: ./frontend
+      target: musare_frontend
+      args:
+        FRONTEND_MODE: "${FRONTEND_MODE:-prod}"
+    restart: ${RESTART_POLICY:-unless-stopped}
     ports:
     ports:
-      - "${FRONTEND_HOST}:${FRONTEND_PORT}:80"
+      - "${FRONTEND_HOST:-0.0.0.0}:${FRONTEND_PORT:-80}:80"
     volumes:
     volumes:
       - ./.git:/opt/app/.parent_git:ro
       - ./.git:/opt/app/.parent_git:ro
       - /opt/app/node_modules
       - /opt/app/node_modules
       - ./frontend/dist/config:/opt/app/dist/config
       - ./frontend/dist/config:/opt/app/dist/config
     environment:
     environment:
-      - FRONTEND_MODE=${FRONTEND_MODE}
+      - FRONTEND_MODE=${FRONTEND_MODE:-prod}
     links:
     links:
       - backend
       - backend
 
 
   mongo:
   mongo:
     image: docker.io/mongo:${MONGO_VERSION}
     image: docker.io/mongo:${MONGO_VERSION}
-    restart: ${RESTART_POLICY}
+    restart: ${RESTART_POLICY:-unless-stopped}
     environment:
     environment:
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_INITDB_DATABASE=musare
       - MONGO_INITDB_DATABASE=musare
-      - MONGO_PORT=${MONGO_PORT}
+      - MONGO_PORT=${MONGO_PORT:-27017}
       - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
       - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
     volumes:
     volumes:
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
-      - ${MONGO_DATA_LOCATION}:/data/db
+      - ${MONGO_DATA_LOCATION:-./db}:/data/db
 
 
   redis:
   redis:
     image: docker.io/redis:6.2
     image: docker.io/redis:6.2
-    restart: ${RESTART_POLICY}
+    restart: ${RESTART_POLICY:-unless-stopped}
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
+    volumes:
+      - /data

+ 11 - 3
frontend/Dockerfile

@@ -1,4 +1,7 @@
-FROM node:16.15
+FROM node:16.15 AS musare_frontend
+
+ARG FRONTEND_MODE=prod
+ENV FRONTEND_MODE=${FRONTEND_MODE}
 
 
 RUN apt-get update
 RUN apt-get update
 RUN apt-get install nginx -y
 RUN apt-get install nginx -y
@@ -17,6 +20,11 @@ COPY . /opt/app
 
 
 RUN mkdir -p /run/nginx
 RUN mkdir -p /run/nginx
 
 
-RUN chmod u+x bootstrap.sh
+RUN bash -c '[[ "${FRONTEND_MODE}" = "prod" ]] && npm run prod' || exit 0
+
+RUN chmod u+x entrypoint.sh
+
+ENTRYPOINT bash /opt/app/entrypoint.sh
 
 
-CMD bash /opt/app/bootstrap.sh
+EXPOSE 80/tcp
+EXPOSE 80/udp

+ 0 - 9
frontend/bootstrap.sh

@@ -1,9 +0,0 @@
-#!/bin/bash
-
-if [ "$FRONTEND_MODE" == "prod" ] ; then
-	cd /opt/app ; npm run $FRONTEND_MODE
-	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf -g "daemon off;"
-elif [ "$FRONTEND_MODE" == "dev" ] ; then
-	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf
-	cd /opt/app; npm run $FRONTEND_MODE
-fi

+ 8 - 0
frontend/entrypoint.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+if [[ "${FRONTEND_MODE}" = "prod" ]]; then
+    nginx -c /opt/app/prod.nginx.conf -g "daemon off;"
+elif [ "${FRONTEND_MODE}" == "dev" ]; then
+    nginx -c /opt/app/dev.nginx.conf
+    npm run dev
+fi

File diff suppressed because it is too large
+ 274 - 242
frontend/package-lock.json


+ 19 - 15
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
     "*.vue"
   ],
   ],
   "private": true,
   "private": true,
-  "version": "3.5.2",
+  "version": "3.6.0-rc1",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "main": "main.js",
   "author": "Musare Team",
   "author": "Musare Team",
@@ -17,46 +17,50 @@
     "prod": "npx webpack --config webpack.prod.js"
     "prod": "npx webpack --config webpack.prod.js"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@babel/core": "^7.17.9",
-    "@babel/eslint-parser": "^7.17.0",
-    "@babel/plugin-proposal-object-rest-spread": "^7.17.3",
+    "@babel/core": "^7.18.2",
+    "@babel/eslint-parser": "^7.18.2",
+    "@babel/plugin-proposal-object-rest-spread": "^7.18.0",
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
-    "@babel/plugin-transform-runtime": "^7.17.0",
-    "@babel/preset-env": "^7.16.11",
-    "@vue/compiler-sfc": "^3.2.32",
-    "babel-loader": "^8.2.4",
+    "@babel/plugin-transform-runtime": "^7.18.2",
+    "@babel/preset-env": "^7.18.2",
+    "@vue/compiler-sfc": "^3.2.36",
+    "babel-loader": "^8.2.5",
     "css-loader": "^6.7.1",
     "css-loader": "^6.7.1",
-    "eslint": "^8.13.0",
+    "eslint": "^8.17.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-vue": "^8.6.0",
+    "eslint-plugin-vue": "^8.7.1",
     "eslint-webpack-plugin": "^3.1.1",
     "eslint-webpack-plugin": "^3.1.1",
     "fetch": "^1.1.0",
     "fetch": "^1.1.0",
     "less": "^4.1.2",
     "less": "^4.1.2",
     "less-loader": "^10.2.0",
     "less-loader": "^10.2.0",
     "prettier": "^2.6.2",
     "prettier": "^2.6.2",
+    "style-loader": "^3.3.1",
     "style-resources-loader": "^1.5.0",
     "style-resources-loader": "^1.5.0",
     "vue-style-loader": "^4.1.3",
     "vue-style-loader": "^4.1.3",
     "webpack-cli": "^4.9.2",
     "webpack-cli": "^4.9.2",
-    "webpack-dev-server": "^4.8.1"
+    "webpack-dev-server": "^4.9.1"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@babel/runtime": "^7.17.9",
+    "@babel/runtime": "^7.18.3",
     "can-autoplay": "^3.0.2",
     "can-autoplay": "^3.0.2",
+    "chart.js": "^3.8.0",
     "config": "^3.3.7",
     "config": "^3.3.7",
     "date-fns": "^2.28.0",
     "date-fns": "^2.28.0",
-    "dompurify": "^2.3.6",
+    "dompurify": "^2.3.8",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "html-webpack-plugin": "^5.5.0",
     "html-webpack-plugin": "^5.5.0",
     "lofig": "^1.3.4",
     "lofig": "^1.3.4",
-    "marked": "^4.0.14",
+    "marked": "^4.0.16",
     "normalize.css": "^8.0.1",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
     "toasters": "^2.3.1",
     "vue": "3.2.31",
     "vue": "3.2.31",
+    "vue-chartjs": "^4.1.1",
     "vue-content-loader": "^2.0.1",
     "vue-content-loader": "^2.0.1",
+    "vue-json-pretty": "^2.1.0",
     "vue-loader": "^17.0.0",
     "vue-loader": "^17.0.0",
-    "vue-router": "^4.0.14",
+    "vue-router": "^4.0.15",
     "vue-tippy": "^6.0.0-alpha.57",
     "vue-tippy": "^6.0.0-alpha.57",
     "vuedraggable": "^4.1.0",
     "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
     "vuex": "^4.0.2",

+ 4 - 0
frontend/src/App.vue

@@ -10,6 +10,7 @@
 		</div>
 		</div>
 		<falling-snow v-if="christmas" />
 		<falling-snow v-if="christmas" />
 		<modal-manager />
 		<modal-manager />
+		<long-jobs />
 	</div>
 	</div>
 </template>
 </template>
 
 
@@ -27,6 +28,9 @@ export default {
 		ModalManager: defineAsyncComponent(() =>
 		ModalManager: defineAsyncComponent(() =>
 			import("@/components/ModalManager.vue")
 			import("@/components/ModalManager.vue")
 		),
 		),
+		LongJobs: defineAsyncComponent(() =>
+			import("@/components/LongJobs.vue")
+		),
 		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue")),
 		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue")),
 		FallingSnow: defineAsyncComponent(() =>
 		FallingSnow: defineAsyncComponent(() =>
 			import("@/components/FallingSnow.vue")
 			import("@/components/FallingSnow.vue")

+ 50 - 106
frontend/src/components/AdvancedTable.vue

@@ -707,8 +707,10 @@
 			v-if="hasCheckboxes && selectedRows.length > 0"
 			v-if="hasCheckboxes && selectedRows.length > 0"
 			class="bulk-popup"
 			class="bulk-popup"
 			:style="{
 			:style="{
-				top: bulkPopup.top + 'px',
-				left: bulkPopup.left + 'px'
+				top: dragBox.top + 'px',
+				left: dragBox.left + 'px',
+				width: dragBox.width + 'px',
+				height: dragBox.height + 'px'
 			}"
 			}"
 			ref="bulk-popup"
 			ref="bulk-popup"
 		>
 		>
@@ -729,7 +731,7 @@
 					class="material-icons drag-icon"
 					class="material-icons drag-icon"
 					@mousedown.left="onDragBox"
 					@mousedown.left="onDragBox"
 					@touchstart="onDragBox"
 					@touchstart="onDragBox"
-					@dblclick="resetBulkActionsPosition()"
+					@dblclick="resetBoxPosition()"
 				>
 				>
 					drag_indicator
 					drag_indicator
 				</span>
 				</span>
@@ -745,6 +747,8 @@ import draggable from "vuedraggable";
 import Toast from "toasters";
 import Toast from "toasters";
 import AutoSuggest from "@/components/AutoSuggest.vue";
 import AutoSuggest from "@/components/AutoSuggest.vue";
 
 
+import DragBox from "@/mixins/DragBox.vue";
+
 import keyboardShortcuts from "@/keyboardShortcuts";
 import keyboardShortcuts from "@/keyboardShortcuts";
 import ws from "@/ws";
 import ws from "@/ws";
 
 
@@ -753,6 +757,7 @@ export default {
 		draggable,
 		draggable,
 		AutoSuggest
 		AutoSuggest
 	},
 	},
+	mixins: [DragBox],
 	props: {
 	props: {
 		/*
 		/*
 		Column properties:
 		Column properties:
@@ -777,7 +782,8 @@ export default {
 		maxWidth: { type: Number, default: 1880 },
 		maxWidth: { type: Number, default: 1880 },
 		query: { type: Boolean, default: true },
 		query: { type: Boolean, default: true },
 		keyboardShortcuts: { type: Boolean, default: true },
 		keyboardShortcuts: { type: Boolean, default: true },
-		events: { type: Object, default: () => {} }
+		events: { type: Object, default: () => {} },
+		bulkActions: { type: Object, default: () => {} }
 	},
 	},
 	data() {
 	data() {
 		return {
 		return {
@@ -861,20 +867,11 @@ export default {
 				boolean: {
 				boolean: {
 					name: "boolean",
 					name: "boolean",
 					displayName: "Boolean"
 					displayName: "Boolean"
-				}
-			},
-			bulkPopup: {
-				top: 0,
-				left: 0,
-				pos1: 0,
-				pos2: 0,
-				pos3: 0,
-				pos4: 0,
-				initial: {
-					top: null,
-					left: null
 				},
 				},
-				debounceTimeout: null
+				special: {
+					name: "special",
+					displayName: "Special"
+				}
 			},
 			},
 			addFilterValue: null,
 			addFilterValue: null,
 			showFiltersDropdown: false,
 			showFiltersDropdown: false,
@@ -884,7 +881,10 @@ export default {
 			lastBulkActionsTappedDate: 0,
 			lastBulkActionsTappedDate: 0,
 			autosuggest: {
 			autosuggest: {
 				allItems: {}
 				allItems: {}
-			}
+			},
+			storeTableSettingsDebounceTimeout: null,
+			windowResizeDebounceTimeout: null,
+			lastSelectedItemIndex: 0
 		};
 		};
 	},
 	},
 	computed: {
 	computed: {
@@ -908,9 +908,6 @@ export default {
 		hidableSortedColumns() {
 		hidableSortedColumns() {
 			return this.orderedColumns.filter(column => column.hidable);
 			return this.orderedColumns.filter(column => column.hidable);
 		},
 		},
-		lastSelectedItemIndex() {
-			return this.rows.findIndex(item => item.highlighted);
-		},
 		selectedRows() {
 		selectedRows() {
 			return this.rows.filter(row => row.selected);
 			return this.rows.filter(row => row.selected);
 		},
 		},
@@ -1083,8 +1080,6 @@ export default {
 			}
 			}
 		}
 		}
 
 
-		this.resetBulkActionsPosition();
-
 		this.$nextTick(() => {
 		this.$nextTick(() => {
 			this.onWindowResize();
 			this.onWindowResize();
 			window.addEventListener("resize", this.onWindowResize);
 			window.addEventListener("resize", this.onWindowResize);
@@ -1304,6 +1299,8 @@ export default {
 		window.removeEventListener("resize", this.onWindowResize);
 		window.removeEventListener("resize", this.onWindowResize);
 		if (this.storeTableSettingsDebounceTimeout)
 		if (this.storeTableSettingsDebounceTimeout)
 			clearTimeout(this.storeTableSettingsDebounceTimeout);
 			clearTimeout(this.storeTableSettingsDebounceTimeout);
+		if (this.windowResizeDebounceTimeout)
+			clearTimeout(this.windowResizeDebounceTimeout);
 
 
 		if (this.keyboardShortcuts) {
 		if (this.keyboardShortcuts) {
 			const shortcutNames = [
 			const shortcutNames = [
@@ -1441,6 +1438,7 @@ export default {
 			if (shiftKey && !ctrlKey) {
 			if (shiftKey && !ctrlKey) {
 				// If the clicked item is already selected, prevent default, otherwise the checkbox will be unchecked
 				// If the clicked item is already selected, prevent default, otherwise the checkbox will be unchecked
 				if (this.rows[itemIndex].selected) event.preventDefault();
 				if (this.rows[itemIndex].selected) event.preventDefault();
+				this.rows[itemIndex].selected = true;
 				// If there is a last clicked item
 				// If there is a last clicked item
 				if (this.lastSelectedItemIndex >= 0) {
 				if (this.lastSelectedItemIndex >= 0) {
 					// Clicked item is lower than last item, so select upwards until it reaches the last selected item
 					// Clicked item is lower than last item, so select upwards until it reaches the last selected item
@@ -1471,6 +1469,7 @@ export default {
 			else if (!shiftKey && ctrlKey) {
 			else if (!shiftKey && ctrlKey) {
 				// If the clicked item is already unselected, prevent default, otherwise the checkbox will be checked
 				// If the clicked item is already unselected, prevent default, otherwise the checkbox will be checked
 				if (!this.rows[itemIndex].selected) event.preventDefault();
 				if (!this.rows[itemIndex].selected) event.preventDefault();
+				this.rows[itemIndex].selected = false;
 				// If there is a last clicked item
 				// If there is a last clicked item
 				if (this.lastSelectedItemIndex >= 0) {
 				if (this.lastSelectedItemIndex >= 0) {
 					// Clicked item is lower than last item, so unselect upwards until it reaches the last selected item
 					// Clicked item is lower than last item, so unselect upwards until it reaches the last selected item
@@ -1502,11 +1501,11 @@ export default {
 				this.rows[itemIndex].selected = !this.rows[itemIndex].selected;
 				this.rows[itemIndex].selected = !this.rows[itemIndex].selected;
 			}
 			}
 
 
+			this.rows[itemIndex].highlighted = this.rows[itemIndex].selected;
 			// Set the last clicked item to no longer be highlighted, if it exists
 			// Set the last clicked item to no longer be highlighted, if it exists
 			if (this.lastSelectedItemIndex >= 0)
 			if (this.lastSelectedItemIndex >= 0)
 				this.rows[this.lastSelectedItemIndex].highlighted = false;
 				this.rows[this.lastSelectedItemIndex].highlighted = false;
-			// Set the clicked item to be highlighted
-			this.rows[itemIndex].highlighted = true;
+			this.lastSelectedItemIndex = itemIndex;
 		},
 		},
 		toggleAllRows() {
 		toggleAllRows() {
 			if (
 			if (
@@ -1705,71 +1704,6 @@ export default {
 					this.editingFilters[index].filter.defaultFilterType
 					this.editingFilters[index].filter.defaultFilterType
 				];
 				];
 		},
 		},
-		onDragBox(e) {
-			const e1 = e || window.event;
-			const e1IsTouch = e1.type === "touchstart";
-			e1.preventDefault();
-
-			if (e1IsTouch) {
-				// Handle double click from touch (if this method is twice in a row within one second)
-				if (Date.now() - this.lastBulkActionsTappedDate <= 1000) {
-					this.resetBulkActionsPosition();
-					this.lastBulkActionsTappedDate = 0;
-					return;
-				}
-				this.lastBulkActionsTappedDate = Date.now();
-			}
-
-			this.bulkPopup.pos3 = e1IsTouch
-				? e1.changedTouches[0].clientX
-				: e1.clientX;
-			this.bulkPopup.pos4 = e1IsTouch
-				? e1.changedTouches[0].clientY
-				: e1.clientY;
-
-			document.onmousemove = document.ontouchmove = e => {
-				const e2 = e || window.event;
-				const e2IsTouch = e2.type === "touchmove";
-				if (!e2IsTouch) e2.preventDefault();
-
-				// Get the clientX and clientY
-				const e2ClientX = e2IsTouch
-					? e2.changedTouches[0].clientX
-					: e2.clientX;
-				const e2ClientY = e2IsTouch
-					? e2.changedTouches[0].clientY
-					: e2.clientY;
-
-				// calculate the new cursor position:
-				this.bulkPopup.pos1 = this.bulkPopup.pos3 - e2ClientX;
-				this.bulkPopup.pos2 = this.bulkPopup.pos4 - e2ClientY;
-				this.bulkPopup.pos3 = e2ClientX;
-				this.bulkPopup.pos4 = e2ClientY;
-				// set the element's new position:
-				this.bulkPopup.top -= this.bulkPopup.pos2;
-				this.bulkPopup.left -= this.bulkPopup.pos1;
-
-				if (this.bulkPopup.top < 0) this.bulkPopup.top = 0;
-				if (this.bulkPopup.top > document.body.clientHeight - 50)
-					this.bulkPopup.top = document.body.clientHeight - 50;
-				if (this.bulkPopup.left < 0) this.bulkPopup.left = 0;
-				if (this.bulkPopup.left > document.body.clientWidth - 400)
-					this.bulkPopup.left = document.body.clientWidth - 400;
-			};
-
-			document.onmouseup = document.ontouchend = () => {
-				document.onmouseup = null;
-				document.ontouchend = null;
-				document.onmousemove = null;
-				document.ontouchmove = null;
-			};
-		},
-		resetBulkActionsPosition() {
-			this.bulkPopup.top = document.body.clientHeight - 56;
-			this.bulkPopup.left = document.body.clientWidth / 2 - 200;
-			this.bulkPopup.initial.top = this.bulkPopup.top;
-			this.bulkPopup.initial.left = this.bulkPopup.left;
-		},
 		applyFilterAndGetData() {
 		applyFilterAndGetData() {
 			this.appliedFilters = JSON.parse(
 			this.appliedFilters = JSON.parse(
 				JSON.stringify(this.editingFilters)
 				JSON.stringify(this.editingFilters)
@@ -1930,25 +1864,31 @@ export default {
 			);
 			);
 		},
 		},
 		onWindowResize() {
 		onWindowResize() {
-			if (this.bulkPopup.debounceTimeout)
-				clearTimeout(this.bulkPopup.debounceTimeout);
+			if (this.windowResizeDebounceTimeout)
+				clearTimeout(this.windowResizeDebounceTimeout);
 
 
-			this.bulkPopup.debounceTimeout = setTimeout(() => {
+			this.windowResizeDebounceTimeout = setTimeout(() => {
 				// Only change the position if the popup is actually visible
 				// Only change the position if the popup is actually visible
 				if (this.selectedRows.length === 0) return;
 				if (this.selectedRows.length === 0) return;
-				if (
-					this.bulkPopup.top === this.bulkPopup.initial.top &&
-					this.bulkPopup.left === this.bulkPopup.initial.left
-				)
-					this.resetBulkActionsPosition();
-				else {
-					if (this.bulkPopup.top < 0) this.bulkPopup.top = 0;
-					if (this.bulkPopup.top > document.body.clientHeight - 50)
-						this.bulkPopup.top = document.body.clientHeight - 50;
-					if (this.bulkPopup.left < 0) this.bulkPopup.left = 0;
-					if (this.bulkPopup.left > document.body.clientWidth - 400)
-						this.bulkPopup.left = document.body.clientWidth - 400;
-				}
+
+				const bulkActions = {
+					height: 46,
+					width: 400,
+					...this.bulkActions
+				};
+
+				this.setInitialBox(
+					{
+						top:
+							document.body.clientHeight -
+							bulkActions.height -
+							10,
+						left:
+							(document.body.clientWidth - bulkActions.width) / 2,
+						...bulkActions
+					},
+					true
+				);
 			}, 50);
 			}, 50);
 		},
 		},
 		updateData(index, data) {
 		updateData(index, data) {
@@ -2436,6 +2376,10 @@ export default {
 		.delete-icon {
 		.delete-icon {
 			color: var(--dark-red);
 			color: var(--dark-red);
 		}
 		}
+
+		.import-album-icon {
+			color: var(--purple);
+		}
 	}
 	}
 }
 }
 </style>
 </style>

+ 127 - 75
frontend/src/components/FloatingBox.vue

@@ -6,17 +6,26 @@
 			column
 			column
 		}"
 		}"
 		:id="id"
 		:id="id"
-		v-if="shown"
+		v-if="persist || shown"
 		:style="{
 		:style="{
-			width: width + 'px',
-			height: height + 'px',
-			top: top + 'px',
-			left: left + 'px'
+			width: dragBox.width + 'px',
+			height: dragBox.height + 'px',
+			top: dragBox.top + 'px',
+			left: dragBox.left + 'px'
 		}"
 		}"
 		@mousedown.left="onResizeBox"
 		@mousedown.left="onResizeBox"
 	>
 	>
 		<div class="box-header item-draggable" @mousedown.left="onDragBox">
 		<div class="box-header item-draggable" @mousedown.left="onDragBox">
-			<span class="delete material-icons" @click="toggleBox()"
+			<span class="drag material-icons" @dblclick="resetBoxPosition()"
+				>drag_indicator</span
+			>
+			<span v-if="title" class="box-title" :title="title">{{
+				title
+			}}</span>
+			<span
+				v-if="!persist"
+				class="delete material-icons"
+				@click="toggleBox()"
 				>highlight_off</span
 				>highlight_off</span
 			>
 			>
 		</div>
 		</div>
@@ -27,61 +36,71 @@
 </template>
 </template>
 
 
 <script>
 <script>
+import DragBox from "@/mixins/DragBox.vue";
+
 export default {
 export default {
+	mixins: [DragBox],
 	props: {
 	props: {
 		id: { type: String, default: null },
 		id: { type: String, default: null },
-		column: { type: Boolean, default: true }
+		column: { type: Boolean, default: true },
+		title: { type: String, default: null },
+		persist: { type: Boolean, default: false },
+		initial: { type: String, default: "align-top" },
+		minWidth: { type: Number, default: 100 },
+		maxWidth: { type: Number, default: 1000 },
+		minHeight: { type: Number, default: 100 },
+		maxHeight: { type: Number, default: 1000 }
 	},
 	},
 	data() {
 	data() {
 		return {
 		return {
-			width: 200,
-			height: 200,
-			top: 0,
-			left: 0,
 			shown: false,
 			shown: false,
-			pos1: 0,
-			pos2: 0,
-			pos3: 0,
-			pos4: 0
+			debounceTimeout: null
 		};
 		};
 	},
 	},
 	mounted() {
 	mounted() {
+		let initial = {
+			top: 10,
+			left: 10,
+			width: 200,
+			height: 400
+		};
 		if (this.id !== null && localStorage[`box:${this.id}`]) {
 		if (this.id !== null && localStorage[`box:${this.id}`]) {
 			const json = JSON.parse(localStorage.getItem(`box:${this.id}`));
 			const json = JSON.parse(localStorage.getItem(`box:${this.id}`));
-			this.height = json.height;
-			this.width = json.width;
-			this.top = json.top;
-			this.left = json.left;
+			initial = { ...initial, ...json };
 			this.shown = json.shown;
 			this.shown = json.shown;
+		} else {
+			initial.top =
+				this.initial === "align-bottom"
+					? Math.max(
+							document.body.clientHeight - 10 - initial.height,
+							0
+					  )
+					: 10;
 		}
 		}
+		this.setInitialBox(initial, true);
+
+		this.$nextTick(() => {
+			this.onWindowResize();
+			window.addEventListener("resize", this.onWindowResize);
+		});
+	},
+	unmounted() {
+		window.removeEventListener("resize", this.onWindowResize);
+		if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
 	},
 	},
 	methods: {
 	methods: {
-		onDragBox(e) {
-			const e1 = e || window.event;
-			e1.preventDefault();
-
-			this.pos3 = e1.clientX;
-			this.pos4 = e1.clientY;
-
-			document.onmousemove = e => {
-				const e2 = e || window.event;
-				e2.preventDefault();
-				// calculate the new cursor position:
-				this.pos1 = this.pos3 - e.clientX;
-				this.pos2 = this.pos4 - e.clientY;
-				this.pos3 = e.clientX;
-				this.pos4 = e.clientY;
-				// set the element's new position:
-				this.top -= this.pos2;
-				this.left -= this.pos1;
-			};
-
-			document.onmouseup = () => {
-				document.onmouseup = null;
-				document.onmousemove = null;
+		setBoxDimensions(width, height) {
+			this.dragBox.height = Math.min(
+				Math.max(height, this.minHeight),
+				this.maxHeight,
+				document.body.clientHeight
+			);
 
 
-				this.saveBox();
-			};
+			this.dragBox.width = Math.min(
+				Math.max(width, this.minWidth),
+				this.maxWidth,
+				document.body.clientWidth
+			);
 		},
 		},
 		onResizeBox(e) {
 		onResizeBox(e) {
 			if (e.target !== this.$refs.box) return;
 			if (e.target !== this.$refs.box) return;
@@ -89,18 +108,15 @@ export default {
 			document.onmouseup = () => {
 			document.onmouseup = () => {
 				document.onmouseup = null;
 				document.onmouseup = null;
 
 
-				const { height, width } = e.target.style;
-
-				this.height = Number(
-					height
-						.split("")
-						.splice(0, height.length - 2)
-						.join("")
-				);
-				this.width = Number(
+				const { width, height } = e.target.style;
+				this.setBoxDimensions(
 					width
 					width
 						.split("")
 						.split("")
 						.splice(0, width.length - 2)
 						.splice(0, width.length - 2)
+						.join(""),
+					height
+						.split("")
+						.splice(0, height.length - 2)
 						.join("")
 						.join("")
 				);
 				);
 
 
@@ -111,11 +127,8 @@ export default {
 			this.shown = !this.shown;
 			this.shown = !this.shown;
 			this.saveBox();
 			this.saveBox();
 		},
 		},
-		resetBox() {
-			this.top = 0;
-			this.left = 0;
-			this.width = 200;
-			this.height = 200;
+		resetBoxDimensions() {
+			this.setBoxDimensions(200, 200);
 			this.saveBox();
 			this.saveBox();
 		},
 		},
 		saveBox() {
 		saveBox() {
@@ -123,13 +136,37 @@ export default {
 			localStorage.setItem(
 			localStorage.setItem(
 				`box:${this.id}`,
 				`box:${this.id}`,
 				JSON.stringify({
 				JSON.stringify({
-					height: this.height,
-					width: this.width,
-					top: this.top,
-					left: this.left,
+					height: this.dragBox.height,
+					width: this.dragBox.width,
+					top: this.dragBox.top,
+					left: this.dragBox.left,
 					shown: this.shown
 					shown: this.shown
 				})
 				})
 			);
 			);
+			this.setInitialBox({
+				top:
+					this.initial === "align-bottom"
+						? Math.max(
+								document.body.clientHeight -
+									10 -
+									this.dragBox.height,
+								0
+						  )
+						: 10,
+				left: 10
+			});
+		},
+		onDragBoxUpdate() {
+			this.onWindowResize();
+		},
+		onWindowResize() {
+			if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
+
+			this.debounceTimeout = setTimeout(() => {
+				const { width, height } = this.dragBox;
+				this.setBoxDimensions(width + 0, height + 0);
+				this.saveBox();
+			}, 50);
 		}
 		}
 	}
 	}
 };
 };
@@ -155,28 +192,43 @@ export default {
 	overflow: auto;
 	overflow: auto;
 	border: 1px solid var(--light-grey-2);
 	border: 1px solid var(--light-grey-2);
 	border-radius: @border-radius;
 	border-radius: @border-radius;
-	min-height: 50px !important;
-	min-width: 50px !important;
 	padding: 0;
 	padding: 0;
 
 
 	.box-header {
 	.box-header {
-		z-index: 100000001;
-		background-color: var(--primary-color);
-		display: block;
-		height: 24px;
+		display: flex;
+		height: 30px;
 		width: 100%;
 		width: 100%;
+		background-color: var(--primary-color);
+		color: var(--white);
+		z-index: 100000001;
+
+		.box-title {
+			font-size: 16px;
+			font-weight: 600;
+			line-height: 30px;
+			margin-right: 5px;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			overflow: hidden;
+		}
 
 
-		.delete.material-icons {
-			position: absolute;
-			top: 2px;
-			right: 2px;
+		.material-icons {
 			font-size: 20px;
 			font-size: 20px;
-			color: var(--white);
-			cursor: pointer;
+			line-height: 30px;
+
 			&:hover,
 			&:hover,
 			&:focus {
 			&:focus {
 				filter: brightness(90%);
 				filter: brightness(90%);
 			}
 			}
+
+			&.drag {
+				margin: 0 5px;
+			}
+
+			&.delete {
+				margin: 0 5px 0 auto;
+				cursor: pointer;
+			}
 		}
 		}
 	}
 	}
 
 
@@ -184,7 +236,7 @@ export default {
 		display: flex;
 		display: flex;
 		flex-wrap: wrap;
 		flex-wrap: wrap;
 		padding: 10px;
 		padding: 10px;
-		height: calc(100% - 24px); /* 24px is the height of the box-header */
+		height: calc(100% - 30px); /* 30px is the height of the box-header */
 		overflow: auto;
 		overflow: auto;
 
 
 		span {
 		span {

+ 100 - 0
frontend/src/components/LineChart.vue

@@ -0,0 +1,100 @@
+<template>
+	<Line
+		:ref="`chart-${chartId}`"
+		:chart-options="chartOptions"
+		:chart-data="data"
+		:chart-id="chartId"
+		:dataset-id-key="datasetIdKey"
+		:plugins="plugins"
+		:css-classes="cssClasses"
+		:styles="chartStyles"
+		:width="width"
+		:height="height"
+	/>
+</template>
+
+<script>
+import { Line } from "vue-chartjs";
+import {
+	Chart as ChartJS,
+	Title,
+	Tooltip,
+	Legend,
+	LineElement,
+	PointElement,
+	CategoryScale,
+	LinearScale,
+	LineController
+} from "chart.js";
+
+ChartJS.register(
+	Title,
+	Tooltip,
+	Legend,
+	LineElement,
+	PointElement,
+	CategoryScale,
+	LinearScale,
+	LineController
+);
+
+export default {
+	name: "LineChart",
+	components: { Line },
+	props: {
+		chartId: {
+			type: String,
+			default: "line-chart"
+		},
+		datasetIdKey: {
+			type: String,
+			default: "label"
+		},
+		width: {
+			type: Number,
+			default: 200
+		},
+		height: {
+			type: Number,
+			default: 200
+		},
+		cssClasses: {
+			default: "",
+			type: String
+		},
+		styles: {
+			type: Object,
+			default: () => {}
+		},
+		plugins: {
+			type: Object,
+			default: () => {}
+		},
+		data: {
+			type: Object,
+			default: () => {}
+		},
+		options: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	computed: {
+		chartStyles() {
+			return {
+				position: "relative",
+				height: this.height,
+				...this.styles
+			};
+		},
+		chartOptions() {
+			return {
+				responsive: true,
+				maintainAspectRatio: false,
+				resizeDelay: 10,
+				...this.options
+			};
+		}
+	}
+};
+</script>

+ 256 - 0
frontend/src/components/LongJobs.vue

@@ -0,0 +1,256 @@
+<template>
+	<floating-box
+		v-if="activeJobs.length > 0"
+		title="Jobs"
+		id="longJobs"
+		ref="longJobs"
+		:persist="true"
+		initial="align-bottom"
+		:min-width="200"
+		:max-width="400"
+		:min-height="200"
+	>
+		<template #body>
+			<div class="active-jobs">
+				<div
+					v-for="job in activeJobs"
+					:key="`activeJob-${job.id}`"
+					class="active-job"
+				>
+					<i
+						v-if="
+							job.status === 'started' || job.status === 'update'
+						"
+						class="material-icons"
+						content="In Progress"
+						v-tippy="{ theme: 'info', placement: 'right' }"
+					>
+						pending
+					</i>
+					<i
+						v-else-if="job.status === 'success'"
+						class="material-icons success"
+						content="Complete"
+						v-tippy="{ theme: 'info', placement: 'right' }"
+					>
+						check_circle
+					</i>
+					<i
+						v-else
+						class="material-icons error"
+						content="Failed"
+						v-tippy="{ theme: 'info', placement: 'right' }"
+					>
+						error
+					</i>
+					<div class="name" :title="job.name">{{ job.name }}</div>
+					<div class="actions">
+						<i
+							class="material-icons clear"
+							:class="{
+								disabled: !(
+									job.status === 'success' ||
+									job.status === 'error'
+								)
+							}"
+							content="Clear"
+							v-tippy="{ placement: 'left' }"
+							@click="remove(job)"
+						>
+							remove_circle
+						</i>
+						<tippy
+							:touch="true"
+							:interactive="true"
+							placement="left"
+							ref="longJobMessage"
+							:append-to="body"
+						>
+							<i class="material-icons">chat</i>
+
+							<template #content>
+								<div class="long-job-message">
+									<strong>Latest Update:</strong>
+									<span :title="job.message">{{
+										job.message
+									}}</span>
+								</div>
+							</template>
+						</tippy>
+					</div>
+				</div>
+			</div>
+		</template>
+	</floating-box>
+</template>
+
+<script>
+import { mapState, mapActions, mapGetters } from "vuex";
+
+import FloatingBox from "@/components/FloatingBox.vue";
+
+export default {
+	components: {
+		FloatingBox
+	},
+	data() {
+		return {
+			minimise: true,
+			body: document.body
+		};
+	},
+	computed: {
+		...mapState("longJobs", {
+			activeJobs: state => state.activeJobs
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.socket.dispatch("users.getLongJobs", {
+			cb: res => {
+				if (res.status === "success") {
+					this.setJobs(res.data.longJobs);
+				} else console.log(res.message);
+			},
+			onProgress: res => {
+				this.setJob(res);
+			}
+		});
+
+		this.socket.on("keep.event:longJob.removed", ({ data }) => {
+			this.removeJob(data.jobId);
+		});
+
+		this.socket.on("keep.event:longJob.added", ({ data }) => {
+			if (!this.activeJobs.find(activeJob => activeJob.id === data.jobId))
+				this.socket.dispatch("users.getLongJob", data.jobId, {
+					cb: res => {
+						if (res.status === "success") {
+							this.setJob(res.data.longJob);
+						} else console.log(res.message);
+					},
+					onProgress: res => {
+						this.setJob(res);
+					}
+				});
+		});
+	},
+	methods: {
+		remove(job) {
+			if (job.status === "success" || job.status === "error") {
+				this.socket.dispatch("users.removeLongJob", job.id, res => {
+					if (res.status === "success") {
+						this.removeJob(job.id);
+					} else console.log(res.message);
+				});
+			}
+		},
+		...mapActions("longJobs", ["setJob", "setJobs", "removeJob"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.night-mode {
+	#longJobs {
+		.active-jobs {
+			.active-job {
+				background-color: var(--dark-grey);
+				border: 0;
+			}
+		}
+	}
+
+	.long-job-message {
+		color: var(--black);
+	}
+}
+
+#longJobs {
+	z-index: 5000 !important;
+
+	.active-jobs {
+		.active-job {
+			display: flex;
+			padding: 5px;
+			margin: 5px 0;
+			border: 1px solid var(--light-grey-3);
+			border-radius: @border-radius;
+
+			&:first-child {
+				margin-top: 0;
+			}
+
+			&:last-child {
+				margin-bottom: 0;
+			}
+
+			.name {
+				line-height: 24px;
+				font-weight: 600;
+				text-transform: capitalize;
+				text-overflow: ellipsis;
+				white-space: nowrap;
+				overflow: hidden;
+				margin-right: auto;
+			}
+
+			.material-icons {
+				font-size: 20px;
+				color: var(--primary-color);
+				margin: auto 5px auto 0;
+				cursor: pointer;
+
+				&.success {
+					color: var(--green);
+				}
+
+				&.error,
+				&.clear {
+					color: var(--red);
+				}
+
+				&.disabled {
+					color: var(--light-grey-3);
+					cursor: not-allowed;
+				}
+			}
+
+			.actions {
+				display: flex;
+
+				.material-icons {
+					margin: auto 0 auto 5px;
+				}
+
+				& > span {
+					display: flex;
+					padding: 0;
+				}
+			}
+		}
+	}
+}
+
+.long-job-message {
+	display: flex;
+	flex-direction: column;
+
+	strong {
+		font-size: 12px;
+	}
+
+	span {
+		display: -webkit-inline-box;
+		text-overflow: ellipsis;
+		white-space: normal;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 3;
+		overflow: hidden;
+		max-width: 200px;
+		font-size: 12px;
+	}
+}
+</style>

+ 3 - 2
frontend/src/components/ModalManager.vue

@@ -24,18 +24,19 @@ export default {
 			createStation: "CreateStation.vue",
 			createStation: "CreateStation.vue",
 			editNews: "EditNews.vue",
 			editNews: "EditNews.vue",
 			manageStation: "ManageStation/index.vue",
 			manageStation: "ManageStation/index.vue",
-			importPlaylist: "ImportPlaylist.vue",
 			editPlaylist: "EditPlaylist/index.vue",
 			editPlaylist: "EditPlaylist/index.vue",
 			createPlaylist: "CreatePlaylist.vue",
 			createPlaylist: "CreatePlaylist.vue",
 			report: "Report.vue",
 			report: "Report.vue",
 			viewReport: "ViewReport.vue",
 			viewReport: "ViewReport.vue",
 			bulkActions: "BulkActions.vue",
 			bulkActions: "BulkActions.vue",
+			viewApiRequest: "ViewApiRequest.vue",
 			viewPunishment: "ViewPunishment.vue",
 			viewPunishment: "ViewPunishment.vue",
 			removeAccount: "RemoveAccount.vue",
 			removeAccount: "RemoveAccount.vue",
 			importAlbum: "ImportAlbum.vue",
 			importAlbum: "ImportAlbum.vue",
 			confirm: "Confirm.vue",
 			confirm: "Confirm.vue",
 			editSongs: "EditSongs.vue",
 			editSongs: "EditSongs.vue",
-			editSong: "EditSong/index.vue"
+			editSong: "EditSong/index.vue",
+			viewYoutubeVideo: "ViewYoutubeVideo.vue"
 		}),
 		}),
 		...mapState("modalVisibility", {
 		...mapState("modalVisibility", {
 			activeModals: state => state.activeModals,
 			activeModals: state => state.activeModals,

+ 1 - 5
frontend/src/components/PlaylistItem.vue

@@ -20,11 +20,7 @@
 						v-if="playlist.createdBy === 'Musare'"
 						v-if="playlist.createdBy === 'Musare'"
 						:title="sitename"
 						:title="sitename"
 						>{{ sitename }}</a
 						>{{ sitename }}</a
-					><user-id-to-username
-						v-else
-						:user-id="playlist.createdBy"
-						:link="true"
-					/>
+					><user-link v-else :user-id="playlist.createdBy" />
 				</span>
 				</span>
 				<span :title="playlistLength">
 				<span :title="playlistLength">

+ 2 - 3
frontend/src/components/PunishmentItem.vue

@@ -15,10 +15,9 @@
 		<div class="item-title-description">
 		<div class="item-title-description">
 			<h2 v-if="punishment.type === 'banUserId'" class="item-title">
 			<h2 v-if="punishment.type === 'banUserId'" class="item-title">
 				<strong>Punishment</strong> for user
 				<strong>Punishment</strong> for user
-				<user-id-to-username
+				<user-link
 					:user-id="punishment.value"
 					:user-id="punishment.value"
 					:alt="punishment.value"
 					:alt="punishment.value"
-					:link="true"
 				/>
 				/>
 			</h2>
 			</h2>
 			<h2 class="item-title" v-else>
 			<h2 class="item-title" v-else>
@@ -45,7 +44,7 @@
 				</li>
 				</li>
 				<li class="item-description">
 				<li class="item-description">
 					Punished by
 					Punished by
-					<user-id-to-username
+					<user-link
 						:user-id="punishment.punishedBy"
 						:user-id="punishment.punishedBy"
 						:alt="punishment.punishedBy"
 						:alt="punishment.punishedBy"
 					/>
 					/>

+ 19 - 11
frontend/src/components/RunJobDropdown.vue

@@ -3,7 +3,7 @@
 		class="runJobDropdown"
 		class="runJobDropdown"
 		:touch="true"
 		:touch="true"
 		:interactive="true"
 		:interactive="true"
-		placement="bottom-start"
+		placement="bottom-end"
 		theme="dropdown"
 		theme="dropdown"
 		ref="dropdown"
 		ref="dropdown"
 		trigger="click"
 		trigger="click"
@@ -55,8 +55,6 @@
 <script>
 <script>
 import { mapGetters } from "vuex";
 import { mapGetters } from "vuex";
 
 
-import Toast from "toasters";
-
 export default {
 export default {
 	props: {
 	props: {
 		jobs: {
 		jobs: {
@@ -76,14 +74,24 @@ export default {
 	},
 	},
 	methods: {
 	methods: {
 		runJob(job) {
 		runJob(job) {
-			new Toast(`Running job: ${job.name}`);
-			this.socket.dispatch(job.socket, data => {
-				if (data.status !== "success")
-					new Toast({
-						content: `Error: ${data.message}`,
-						timeout: 8000
-					});
-				else new Toast({ content: data.message, timeout: 4000 });
+			let id;
+			let title;
+
+			this.socket.dispatch(job.socket, {
+				cb: () => {},
+				onProgress: res => {
+					if (res.status === "started") {
+						id = res.id;
+						title = res.title;
+					}
+
+					if (id)
+						this.setJob({
+							id,
+							name: title,
+							...res
+						});
+				}
 			});
 			});
 		}
 		}
 	}
 	}

+ 19 - 11
frontend/src/components/SongItem.vue

@@ -40,11 +40,10 @@
 				<p class="song-request-time" v-if="requestedBy">
 				<p class="song-request-time" v-if="requestedBy">
 					Requested by
 					Requested by
 					<strong>
 					<strong>
-						<user-id-to-username
+						<user-link
 							v-if="song.requestedBy"
 							v-if="song.requestedBy"
 							:key="song._id"
 							:key="song._id"
 							:user-id="song.requestedBy"
 							:user-id="song.requestedBy"
-							:link="true"
 						/>
 						/>
 						<span v-else>station</span>
 						<span v-else>station</span>
 						{{ formatedRequestedAt }}
 						{{ formatedRequestedAt }}
@@ -80,17 +79,26 @@
 
 
 					<template #content>
 					<template #content>
 						<div class="icons-group">
 						<div class="icons-group">
-							<a
+							<i
 								v-if="disabledActions.indexOf('youtube') === -1"
 								v-if="disabledActions.indexOf('youtube') === -1"
-								target="_blank"
-								:href="`https://www.youtube.com/watch?v=${song.youtubeId}`"
-								content="View on Youtube"
+								@click="
+									openModal({
+										modal: 'viewYoutubeVideo',
+										data: {
+											youtubeId: song.youtubeId
+										}
+									})
+								"
+								content="View YouTube Video"
 								v-tippy
 								v-tippy
 							>
 							>
 								<div class="youtube-icon"></div>
 								<div class="youtube-icon"></div>
-							</a>
+							</i>
 							<i
 							<i
-								v-if="disabledActions.indexOf('report') === -1"
+								v-if="
+									song._id &&
+									disabledActions.indexOf('report') === -1
+								"
 								class="material-icons report-icon"
 								class="material-icons report-icon"
 								@click="report(song)"
 								@click="report(song)"
 								content="Report Song"
 								content="Report Song"
@@ -118,6 +126,7 @@
 							<i
 							<i
 								v-if="
 								v-if="
 									loggedIn &&
 									loggedIn &&
+									song._id &&
 									userRole === 'admin' &&
 									userRole === 'admin' &&
 									disabledActions.indexOf('edit') === -1
 									disabledActions.indexOf('edit') === -1
 								"
 								"
@@ -162,11 +171,10 @@ import { mapActions, mapState } from "vuex";
 import { formatDistance, parseISO } from "date-fns";
 import { formatDistance, parseISO } from "date-fns";
 
 
 import AddToPlaylistDropdown from "./AddToPlaylistDropdown.vue";
 import AddToPlaylistDropdown from "./AddToPlaylistDropdown.vue";
-import SongThumbnail from "./SongThumbnail.vue";
 import utils from "../../js/utils";
 import utils from "../../js/utils";
 
 
 export default {
 export default {
-	components: { AddToPlaylistDropdown, SongThumbnail },
+	components: { AddToPlaylistDropdown },
 	props: {
 	props: {
 		song: {
 		song: {
 			type: Object,
 			type: Object,
@@ -262,7 +270,7 @@ export default {
 			this.hideTippyElements();
 			this.hideTippyElements();
 			this.openModal({
 			this.openModal({
 				modal: "editSong",
 				modal: "editSong",
-				data: { song: { songId: song._id } }
+				data: { song }
 			});
 			});
 		},
 		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("modalVisibility", ["openModal"]),

+ 0 - 110
frontend/src/components/SongThumbnail.vue

@@ -1,110 +0,0 @@
-<template>
-	<div
-		:class="{
-			thumbnail: true,
-			'youtube-thumbnail':
-				song.youtubeId &&
-				(!song.thumbnail ||
-					(song.thumbnail &&
-						(song.thumbnail.lastIndexOf('notes-transparent') !==
-							-1 ||
-							song.thumbnail.lastIndexOf('/assets/notes.png') !==
-								-1 ||
-							song.thumbnail.lastIndexOf('i.ytimg.com') !==
-								-1)) ||
-					song.thumbnail === 'empty' ||
-					song.thumbnail == null)
-		}"
-	>
-		<slot name="icon" />
-		<div
-			class="yt-thumbnail-bg"
-			:style="{
-				'background-image':
-					'url(' +
-					`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg` +
-					')'
-			}"
-		></div>
-		<img
-			v-if="
-				song.youtubeId &&
-				(!song.thumbnail ||
-					(song.thumbnail &&
-						(song.thumbnail.lastIndexOf('notes-transparent') !==
-							-1 ||
-							song.thumbnail.lastIndexOf('/assets/notes.png') !==
-								-1 ||
-							song.thumbnail.lastIndexOf('i.ytimg.com') !==
-								-1)) ||
-					song.thumbnail === 'empty' ||
-					song.thumbnail == null)
-			"
-			loading="lazy"
-			:src="`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg`"
-			onerror="this.src='/assets/notes-transparent.png'"
-		/>
-		<img
-			v-else
-			loading="lazy"
-			:src="song.thumbnail"
-			onerror="this.src='/assets/notes-transparent.png'"
-		/>
-	</div>
-</template>
-
-<script>
-export default {
-	props: {
-		song: {
-			type: Object,
-			default: () => {}
-		}
-	}
-};
-</script>
-
-<style lang="less">
-.thumbnail {
-	min-width: 130px;
-	height: 130px;
-	position: relative;
-	margin-top: -15px;
-	margin-bottom: -15px;
-	margin-left: -10px;
-
-	.yt-thumbnail-bg {
-		display: none;
-	}
-
-	img {
-		height: 100%;
-		width: 100%;
-		margin-top: auto;
-		margin-bottom: auto;
-		z-index: 1;
-		position: absolute;
-		top: 0;
-		bottom: 0;
-		left: 0;
-		right: 0;
-	}
-
-	&.youtube-thumbnail {
-		.yt-thumbnail-bg {
-			height: 100%;
-			width: 100%;
-			display: block;
-			position: absolute;
-			top: 0;
-			filter: blur(1px);
-			background: url("/assets/notes-transparent.png") no-repeat center
-				center;
-		}
-
-		img {
-			height: auto;
-		}
-	}
-}
-</style>

+ 141 - 0
frontend/src/components/global/SongThumbnail.vue

@@ -0,0 +1,141 @@
+<template>
+	<div
+		:class="{
+			thumbnail: true,
+			'youtube-thumbnail': isYoutubeThumbnail
+		}"
+	>
+		<slot name="icon" />
+		<div
+			v-if="-1 < loadError < 2 && isYoutubeThumbnail"
+			class="yt-thumbnail-bg"
+			:style="{
+				'background-image':
+					'url(' +
+					`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg` +
+					')'
+			}"
+		></div>
+		<img
+			v-if="-1 < loadError < 2 && isYoutubeThumbnail"
+			loading="lazy"
+			:src="`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg`"
+			@error="onLoadError"
+		/>
+		<img
+			v-else-if="loadError <= 0"
+			loading="lazy"
+			:src="song.thumbnail"
+			@error="onLoadError"
+		/>
+		<img v-else loading="lazy" src="/assets/notes-transparent.png" />
+	</div>
+</template>
+
+<script>
+export default {
+	props: {
+		song: {
+			type: Object,
+			default: () => {}
+		},
+		fallback: {
+			type: Boolean,
+			default: true
+		}
+	},
+	emits: ["loadError"],
+	data() {
+		return {
+			loadError: 0
+		};
+	},
+	computed: {
+		isYoutubeThumbnail() {
+			return (
+				this.song.youtubeId &&
+				((this.song.thumbnail &&
+					(this.song.thumbnail.lastIndexOf("i.ytimg.com") !== -1 ||
+						this.song.thumbnail.lastIndexOf("img.youtube.com") !==
+							-1)) ||
+					(this.fallback &&
+						(!this.song.thumbnail ||
+							(this.song.thumbnail &&
+								(this.song.thumbnail.lastIndexOf(
+									"notes-transparent"
+								) !== -1 ||
+									this.song.thumbnail.lastIndexOf(
+										"/assets/notes.png"
+									) !== -1 ||
+									this.song.thumbnail === "empty")) ||
+							this.loadError === 1)))
+			);
+		}
+	},
+	watch: {
+		song() {
+			this.loadError = 0;
+			this.$emit("loadError", this.loadError);
+		}
+	},
+	methods: {
+		onLoadError() {
+			// Error codes
+			// -1 - Error occured, fallback disabled
+			// 0 - No errors
+			// 1 - Error occured with thumbnail, fallback enabled
+			// 2 - Error occured with youtube thumbnail, fallback enabled
+			if (!this.fallback) this.loadError = -1;
+			else if (this.loadError === 0 && !this.isYoutubeThumbnail)
+				this.loadError = 1;
+			else this.loadError = 2;
+			this.$emit("loadError", this.loadError);
+		}
+	}
+};
+</script>
+
+<style lang="less">
+.thumbnail {
+	min-width: 130px;
+	height: 130px;
+	position: relative;
+	margin-top: -15px;
+	margin-bottom: -15px;
+	margin-left: -10px;
+
+	.yt-thumbnail-bg {
+		display: none;
+	}
+
+	img {
+		height: 100%;
+		width: 100%;
+		margin-top: auto;
+		margin-bottom: auto;
+		z-index: 1;
+		position: absolute;
+		top: 0;
+		bottom: 0;
+		left: 0;
+		right: 0;
+	}
+
+	&.youtube-thumbnail {
+		.yt-thumbnail-bg {
+			height: 100%;
+			width: 100%;
+			display: block;
+			position: absolute;
+			top: 0;
+			filter: blur(1px);
+			background: url("/assets/notes-transparent.png") no-repeat center
+				center;
+		}
+
+		img {
+			height: auto;
+		}
+	}
+}
+</style>

+ 0 - 46
frontend/src/components/global/UserIdToUsername.vue

@@ -1,46 +0,0 @@
-<template>
-	<router-link
-		v-if="$props.link && username !== 'unknown'"
-		:to="{ path: `/u/${username}` }"
-		:title="userId"
-	>
-		{{ username }}
-	</router-link>
-	<span :title="userId" v-else>
-		{{ username }}
-	</span>
-</template>
-
-<script>
-import { mapActions } from "vuex";
-
-export default {
-	props: {
-		userId: { type: String, default: "" },
-		link: Boolean
-	},
-	data() {
-		return {
-			username: "unknown"
-		};
-	},
-	mounted() {
-		this.getUsernameFromId(this.$props.userId).then(username => {
-			if (username) this.username = username;
-		});
-	},
-	methods: {
-		...mapActions("user/auth", ["getUsernameFromId"])
-	}
-};
-</script>
-
-<style lang="less" scoped>
-a {
-	color: var(--primary-color);
-	&:hover,
-	&:focus {
-		filter: brightness(90%);
-	}
-}
-</style>

+ 53 - 0
frontend/src/components/global/UserLink.vue

@@ -0,0 +1,53 @@
+<template>
+	<router-link
+		v-if="$props.link && user.username"
+		:to="{ path: `/u/${user.username}` }"
+		:title="userId"
+	>
+		{{ user.name }}
+	</router-link>
+	<span v-else :title="userId">
+		{{ user.name }}
+	</span>
+</template>
+
+<script>
+import { mapActions } from "vuex";
+
+export default {
+	props: {
+		userId: { type: String, default: "" },
+		link: { type: Boolean, default: true }
+	},
+	data() {
+		return {
+			user: {
+				name: "Unknown",
+				username: null
+			}
+		};
+	},
+	mounted() {
+		this.getBasicUser(this.$props.userId).then(user => {
+			if (user)
+				this.user = {
+					name: user.name,
+					username: user.username
+				};
+		});
+	},
+	methods: {
+		...mapActions("user/auth", ["getBasicUser"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+a {
+	color: var(--primary-color);
+	&:hover,
+	&:focus {
+		filter: brightness(90%);
+	}
+}
+</style>

+ 22 - 6
frontend/src/components/modals/BulkActions.vue

@@ -1,6 +1,6 @@
 <template>
 <template>
 	<div>
 	<div>
-		<modal title="Bulk Actions" class="bulk-actions-modal">
+		<modal title="Bulk Actions" class="bulk-actions-modal" size="slim">
 			<template #body>
 			<template #body>
 				<label class="label">Method</label>
 				<label class="label">Method</label>
 				<div class="control is-expanded select">
 				<div class="control is-expanded select">
@@ -131,19 +131,35 @@ export default {
 			this.items.splice(index, 1);
 			this.items.splice(index, 1);
 		},
 		},
 		applyChanges() {
 		applyChanges() {
+			let id;
+			let title;
+
 			this.socket.dispatch(
 			this.socket.dispatch(
 				this.type.action,
 				this.type.action,
 				this.method,
 				this.method,
 				this.items,
 				this.items,
 				this.type.items,
 				this.type.items,
-				res => {
-					new Toast(res.message);
-					if (res.status === "success")
-						this.closeModal("bulkActions");
+				{
+					cb: () => {},
+					onProgress: res => {
+						if (res.status === "started") {
+							id = res.id;
+							title = res.title;
+							this.closeCurrentModal();
+						}
+
+						if (id)
+							this.setJob({
+								id,
+								name: title,
+								...res
+							});
+					}
 				}
 				}
 			);
 			);
 		},
 		},
-		...mapActions("modalVisibility", ["closeModal"])
+		...mapActions("modalVisibility", ["closeCurrentModal"]),
+		...mapActions("longJobs", ["setJob"])
 	}
 	}
 };
 };
 </script>
 </script>

+ 1 - 2
frontend/src/components/modals/EditNews.vue

@@ -57,10 +57,9 @@
 				<div class="right" v-if="createdAt > 0">
 				<div class="right" v-if="createdAt > 0">
 					<span>
 					<span>
 						By
 						By
-						<user-id-to-username
+						<user-link
 							:user-id="createdBy"
 							:user-id="createdBy"
 							:alt="createdBy"
 							:alt="createdBy"
-							:link="true"
 						/> </span
 						/> </span
 					>&nbsp;<span :title="new Date(createdAt)">
 					>&nbsp;<span :title="new Date(createdAt)">
 						{{
 						{{

+ 14 - 41
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -56,7 +56,8 @@ export default {
 	},
 	},
 	methods: {
 	methods: {
 		importPlaylist() {
 		importPlaylist() {
-			let isImportingPlaylist = true;
+			let id;
+			let title;
 
 
 			// import query is blank
 			// import query is blank
 			if (!this.youtubeSearch.playlist.query)
 			if (!this.youtubeSearch.playlist.query)
@@ -72,53 +73,25 @@ export default {
 				});
 				});
 			}
 			}
 
 
-			// don't give starting import message instantly in case of instant error
-			setTimeout(() => {
-				if (isImportingPlaylist) {
-					new Toast(
-						"Starting to import your playlist. This can take some time to do."
-					);
-				}
-			}, 750);
-
 			return this.socket.dispatch(
 			return this.socket.dispatch(
 				"playlists.addSetToPlaylist",
 				"playlists.addSetToPlaylist",
 				this.youtubeSearch.playlist.query,
 				this.youtubeSearch.playlist.query,
 				this.playlist._id,
 				this.playlist._id,
 				this.youtubeSearch.playlist.isImportingOnlyMusic,
 				this.youtubeSearch.playlist.isImportingOnlyMusic,
-				res => {
-					new Toast({ content: res.message, timeout: 20000 });
-					if (res.status === "success") {
-						isImportingPlaylist = false;
-
-						const {
-							songsInPlaylistTotal,
-							videosInPlaylistTotal,
-							alreadyInLikedPlaylist,
-							alreadyInDislikedPlaylist
-						} = res.data.stats;
-
-						if (this.youtubeSearch.playlist.isImportingOnlyMusic) {
-							new Toast({
-								content: `${songsInPlaylistTotal} of the ${videosInPlaylistTotal} videos in the playlist were songs.`,
-								timeout: 20000
-							});
+				{
+					cb: () => {},
+					onProgress: res => {
+						if (res.status === "started") {
+							id = res.id;
+							title = res.title;
 						}
 						}
-						if (
-							alreadyInLikedPlaylist > 0 ||
-							alreadyInDislikedPlaylist > 0
-						) {
-							let message = "";
-							if (alreadyInLikedPlaylist > 0) {
-								message = `${alreadyInLikedPlaylist} songs were already in your Liked Songs playlist. A song cannot be in both the Liked Songs playlist and the Disliked Songs playlist at the same time.`;
-							} else {
-								message = `${alreadyInDislikedPlaylist} songs were already in your Disliked Songs playlist. A song cannot be in both the Liked Songs playlist and the Disliked Songs playlist at the same time.`;
-							}
-							new Toast({
-								content: message,
-								timeout: 20000
+
+						if (id)
+							this.setJob({
+								id,
+								name: title,
+								...res
 							});
 							});
-						}
 					}
 					}
 				}
 				}
 			);
 			);

+ 375 - 215
frontend/src/components/modals/EditSong/index.vue

@@ -15,7 +15,7 @@
 				<slot name="sidebar" />
 				<slot name="sidebar" />
 			</template>
 			</template>
 			<template #body>
 			<template #body>
-				<div v-if="!songId && !newSong" class="notice-container">
+				<div v-if="!youtubeId && !newSong" class="notice-container">
 					<h4>No song has been selected</h4>
 					<h4>No song has been selected</h4>
 				</div>
 				</div>
 				<div v-if="songDeleted" class="notice-container">
 				<div v-if="songDeleted" class="notice-container">
@@ -23,14 +23,17 @@
 				</div>
 				</div>
 				<div
 				<div
 					v-if="
 					v-if="
-						songId && !songDataLoaded && !songNotFound && !newSong
+						youtubeId &&
+						!songDataLoaded &&
+						!songNotFound &&
+						!newSong
 					"
 					"
 					class="notice-container"
 					class="notice-container"
 				>
 				>
 					<h4>Song hasn't loaded yet</h4>
 					<h4>Song hasn't loaded yet</h4>
 				</div>
 				</div>
 				<div
 				<div
-					v-if="songId && songNotFound && !newSong"
+					v-if="youtubeId && songNotFound && !newSong"
 					class="notice-container"
 					class="notice-container"
 				>
 				>
 					<h4>Song was not found</h4>
 					<h4>Song was not found</h4>
@@ -41,15 +44,15 @@
 				>
 				>
 					<div class="top-section">
 					<div class="top-section">
 						<div class="player-section">
 						<div class="player-section">
-							<div id="editSongPlayer" />
+							<div :id="`editSongPlayer-${modalUuid}`" />
 
 
 							<div v-show="youtubeError" class="player-error">
 							<div v-show="youtubeError" class="player-error">
 								<h2>{{ youtubeErrorMessage }}</h2>
 								<h2>{{ youtubeErrorMessage }}</h2>
 							</div>
 							</div>
 
 
 							<canvas
 							<canvas
-								ref="durationCanvas"
-								id="durationCanvas"
+								:ref="`durationCanvas-${modalUuid}`"
+								class="duration-canvas"
 								v-show="!youtubeError"
 								v-show="!youtubeError"
 								height="20"
 								height="20"
 								width="530"
 								width="530"
@@ -220,13 +223,19 @@
 								</div>
 								</div>
 							</div>
 							</div>
 						</div>
 						</div>
-						<img
+						<song-thumbnail
+							v-if="songDataLoaded && !songDeleted"
+							:song="song"
+							:fallback="false"
 							class="thumbnail-preview"
 							class="thumbnail-preview"
+							@loadError="onThumbnailLoadError"
+						/>
+						<img
+							v-if="!isYoutubeThumbnail && !songDeleted"
+							class="thumbnail-dummy"
 							:src="song.thumbnail"
 							:src="song.thumbnail"
-							onerror="this.src='/assets/notes-transparent.png'"
 							ref="thumbnailElement"
 							ref="thumbnailElement"
 							@load="onThumbnailLoad"
 							@load="onThumbnailLoad"
-							v-if="songDataLoaded && !songDeleted"
 						/>
 						/>
 					</div>
 					</div>
 
 
@@ -248,6 +257,16 @@
 											getAlbumData('title')
 											getAlbumData('title')
 										"
 										"
 									/>
 									/>
+									<button
+										class="button youtube-get-button"
+										@click="getYouTubeData('title')"
+									>
+										<div
+											class="youtube-icon"
+											v-tippy
+											content="Fill from YouTube"
+										></div>
+									</button>
 									<button
 									<button
 										class="button album-get-button"
 										class="button album-get-button"
 										@click="getAlbumData('title')"
 										@click="getAlbumData('title')"
@@ -302,11 +321,11 @@
 						<div class="control is-grouped">
 						<div class="control is-grouped">
 							<div class="album-art-container">
 							<div class="album-art-container">
 								<label class="label">
 								<label class="label">
-									Album art
+									Thumbnail
 									<i
 									<i
 										v-if="
 										v-if="
 											thumbnailNotSquare &&
 											thumbnailNotSquare &&
-											!thumbnailIsYouTubeThumbnail
+											!isYoutubeThumbnail
 										"
 										"
 										class="material-icons thumbnail-warning"
 										class="material-icons thumbnail-warning"
 										content="Thumbnail not square, it will be stretched"
 										content="Thumbnail not square, it will be stretched"
@@ -314,6 +333,17 @@
 									>
 									>
 										warning
 										warning
 									</i>
 									</i>
+									<i
+										v-if="
+											thumbnailLoadError &&
+											!isYoutubeThumbnail
+										"
+										class="material-icons thumbnail-warning"
+										content="Error loading thumbnail"
+										v-tippy="{ theme: 'info' }"
+									>
+										warning
+									</i>
 								</label>
 								</label>
 
 
 								<p class="control has-addons">
 								<p class="control has-addons">
@@ -321,16 +351,20 @@
 										class="input"
 										class="input"
 										type="text"
 										type="text"
 										v-model="song.thumbnail"
 										v-model="song.thumbnail"
-										placeholder="Enter link to album art..."
+										placeholder="Enter link to thumbnail..."
 										@keyup.shift.enter="
 										@keyup.shift.enter="
 											getAlbumData('albumArt')
 											getAlbumData('albumArt')
 										"
 										"
 									/>
 									/>
 									<button
 									<button
 										class="button youtube-get-button"
 										class="button youtube-get-button"
-										@click="getYouTubeData('albumArt')"
+										@click="getYouTubeData('thumbnail')"
 									>
 									>
-										<div class="youtube-icon"></div>
+										<div
+											class="youtube-icon"
+											v-tippy
+											content="Fill from YouTube"
+										></div>
 									</button>
 									</button>
 									<button
 									<button
 										class="button album-get-button"
 										class="button album-get-button"
@@ -387,6 +421,16 @@
 											getAlbumData('artists')
 											getAlbumData('artists')
 										"
 										"
 									/>
 									/>
+									<button
+										class="button youtube-get-button"
+										@click="getYouTubeData('author')"
+									>
+										<div
+											class="youtube-icon"
+											v-tippy
+											content="Fill from YouTube"
+										></div>
+									</button>
 									<button
 									<button
 										class="button album-get-button"
 										class="button album-get-button"
 										@click="getAlbumData('artists')"
 										@click="getAlbumData('artists')"
@@ -593,7 +637,7 @@
 					<button
 					<button
 						class="button is-primary"
 						class="button is-primary"
 						@click="toggleFlag()"
 						@click="toggleFlag()"
-						v-if="songId && !songDeleted"
+						v-if="youtubeId && !songDeleted"
 					>
 					>
 						{{ flagged ? "Unflag" : "Flag" }}
 						{{ flagged ? "Unflag" : "Flag" }}
 					</button>
 					</button>
@@ -635,10 +679,24 @@
 						default-message="Create Song"
 						default-message="Create Song"
 						@clicked="save(song, false, 'createButton', true)"
 						@clicked="save(song, false, 'createButton', true)"
 					/>
 					/>
+					<save-button
+						ref="createAndCloseButton"
+						:default-message="
+							bulk ? `Create and next` : `Create and close`
+						"
+						@clicked="
+							save(song, true, 'createAndCloseButton', true)
+						"
+					/>
 				</div>
 				</div>
 			</template>
 			</template>
 		</modal>
 		</modal>
-		<floating-box id="genreHelper" ref="genreHelper" :column="false">
+		<floating-box
+			id="genreHelper"
+			ref="genreHelper"
+			:column="false"
+			title="Song Genres List"
+		>
 			<template #body>
 			<template #body>
 				<span
 				<span
 					v-for="item in autosuggest.allItems.genres"
 					v-for="item in autosuggest.allItems.genres"
@@ -762,22 +820,25 @@ export default {
 			showRateDropdown: false,
 			showRateDropdown: false,
 			thumbnailNotSquare: false,
 			thumbnailNotSquare: false,
 			thumbnailWidth: null,
 			thumbnailWidth: null,
-			thumbnailHeight: null
+			thumbnailHeight: null,
+			thumbnailLoadError: false
 		};
 		};
 	},
 	},
 	computed: {
 	computed: {
-		thumbnailIsYouTubeThumbnail() {
+		isYoutubeThumbnail() {
 			return (
 			return (
 				this.songDataLoaded &&
 				this.songDataLoaded &&
+				this.song.youtubeId &&
 				this.song.thumbnail &&
 				this.song.thumbnail &&
-				this.song.thumbnail.startsWith("https://i.ytimg.com/")
+				(this.song.thumbnail.lastIndexOf("i.ytimg.com") !== -1 ||
+					this.song.thumbnail.lastIndexOf("img.youtube.com") !== -1)
 			);
 			);
 		},
 		},
 		...mapModalState("MODAL_MODULE_PATH", {
 		...mapModalState("MODAL_MODULE_PATH", {
 			tab: state => state.tab,
 			tab: state => state.tab,
 			video: state => state.video,
 			video: state => state.video,
 			song: state => state.song,
 			song: state => state.song,
-			songId: state => state.songId,
+			youtubeId: state => state.youtubeId,
 			prefillData: state => state.prefillData,
 			prefillData: state => state.prefillData,
 			originalSong: state => state.originalSong,
 			originalSong: state => state.originalSong,
 			reports: state => state.reports,
 			reports: state => state.reports,
@@ -799,10 +860,10 @@ export default {
 			this.drawCanvas();
 			this.drawCanvas();
 		},
 		},
 		/* eslint-enable */
 		/* eslint-enable */
-		songId(songId, oldSongId) {
-			console.log("NEW SONG ID", songId);
-			this.unloadSong(oldSongId);
-			this.loadSong(songId);
+		youtubeId(youtubeId, oldYoutubeId) {
+			console.log("NEW YOUTUBE ID", youtubeId);
+			this.unloadSong(oldYoutubeId);
+			this.loadSong(youtubeId);
 		}
 		}
 	},
 	},
 	async mounted() {
 	async mounted() {
@@ -821,17 +882,15 @@ export default {
 		localStorage.setItem("volume", volume);
 		localStorage.setItem("volume", volume);
 		this.volumeSliderValue = volume;
 		this.volumeSliderValue = volume;
 
 
-		if (!this.newSong) {
-			this.socket.on(
-				"event:admin.song.removed",
-				res => {
-					if (res.data.songId === this.song._id) {
-						this.songDeleted = true;
-					}
-				},
-				{ modalUuid: this.modalUuid }
-			);
-		}
+		this.socket.on(
+			"event:admin.song.removed",
+			res => {
+				if (res.data.songId === this.song._id) {
+					this.songDeleted = true;
+				}
+			},
+			{ modalUuid: this.modalUuid }
+		);
 
 
 		keyboardShortcuts.registerShortcut("editSong.pauseResumeVideo", {
 		keyboardShortcuts.registerShortcut("editSong.pauseResumeVideo", {
 			keyCode: 101,
 			keyCode: 101,
@@ -1003,7 +1062,7 @@ export default {
 	},
 	},
 	beforeUnmount() {
 	beforeUnmount() {
 		console.log("UNMOUNT");
 		console.log("UNMOUNT");
-		if (!this.newSong) this.unloadSong(this.songId);
+		this.unloadSong(this.youtubeId, this.song._id);
 
 
 		this.playerReady = false;
 		this.playerReady = false;
 		clearInterval(this.interval);
 		clearInterval(this.interval);
@@ -1055,8 +1114,11 @@ export default {
 				this.thumbnailWidth = null;
 				this.thumbnailWidth = null;
 			}
 			}
 		},
 		},
+		onThumbnailLoadError(error) {
+			this.thumbnailLoadError = error !== 0;
+		},
 		init() {
 		init() {
-			if (this.newSong) {
+			if (this.newSong && !this.youtubeId) {
 				this.setSong({
 				this.setSong({
 					youtubeId: "",
 					youtubeId: "",
 					title: "",
 					title: "",
@@ -1070,7 +1132,7 @@ export default {
 				});
 				});
 				this.songDataLoaded = true;
 				this.songDataLoaded = true;
 				this.showTab("youtube");
 				this.showTab("youtube");
-			} else if (this.songId) this.loadSong(this.songId);
+			} else if (this.youtubeId) this.loadSong(this.youtubeId);
 			else if (!this.bulk) {
 			else if (!this.bulk) {
 				new Toast("You can't open EditSong without editing a song");
 				new Toast("You can't open EditSong without editing a song");
 				return this.closeModal("editSong");
 				return this.closeModal("editSong");
@@ -1130,133 +1192,142 @@ export default {
 			}, 200);
 			}, 200);
 
 
 			if (window.YT && window.YT.Player) {
 			if (window.YT && window.YT.Player) {
-				this.video.player = new window.YT.Player("editSongPlayer", {
-					height: 298,
-					width: 530,
-					videoId: null,
-					host: "https://www.youtube-nocookie.com",
-					playerVars: {
-						controls: 0,
-						iv_load_policy: 3,
-						rel: 0,
-						showinfo: 0,
-						autoplay: 0
-					},
-					startSeconds: this.song.skipDuration,
-					events: {
-						onReady: () => {
-							let volume = parseFloat(
-								localStorage.getItem("volume")
-							);
-							volume = typeof volume === "number" ? volume : 20;
-							this.video.player.setVolume(volume);
-							if (volume > 0) this.video.player.unMute();
+				this.video.player = new window.YT.Player(
+					`editSongPlayer-${this.modalUuid}`,
+					{
+						height: 298,
+						width: 530,
+						videoId: null,
+						host: "https://www.youtube-nocookie.com",
+						playerVars: {
+							controls: 0,
+							iv_load_policy: 3,
+							rel: 0,
+							showinfo: 0,
+							autoplay: 0
+						},
+						startSeconds: this.song.skipDuration,
+						events: {
+							onReady: () => {
+								let volume = parseFloat(
+									localStorage.getItem("volume")
+								);
+								volume =
+									typeof volume === "number" ? volume : 20;
+								this.video.player.setVolume(volume);
+								if (volume > 0) this.video.player.unMute();
 
 
-							this.playerReady = true;
+								this.playerReady = true;
 
 
-							if (this.song && this.song._id)
-								this.video.player.cueVideoById(
-									this.song.youtubeId,
-									this.song.skipDuration
-								);
+								if (this.song && this.song.youtubeId)
+									this.video.player.cueVideoById(
+										this.song.youtubeId,
+										this.song.skipDuration
+									);
 
 
-							this.setPlaybackRate(null);
+								this.setPlaybackRate(null);
 
 
-							this.drawCanvas();
-						},
-						onStateChange: event => {
-							this.drawCanvas();
+								this.drawCanvas();
+							},
+							onStateChange: event => {
+								this.drawCanvas();
 
 
-							if (event.data === 1) {
-								this.video.paused = false;
-								let youtubeDuration =
-									this.video.player.getDuration();
-								const newYoutubeVideoDuration =
-									youtubeDuration.toFixed(3);
-
-								if (
-									this.youtubeVideoDuration.indexOf(
-										".000"
-									) !== -1 &&
-									`${this.youtubeVideoDuration}` !==
-										`${newYoutubeVideoDuration}`
-								) {
-									const songDurationNumber = Number(
-										this.song.duration
-									);
-									const songDurationNumber2 =
-										Number(this.song.duration) + 1;
-									const songDurationNumber3 =
-										Number(this.song.duration) - 1;
-									const fixedSongDuration =
-										songDurationNumber.toFixed(3);
-									const fixedSongDuration2 =
-										songDurationNumber2.toFixed(3);
-									const fixedSongDuration3 =
-										songDurationNumber3.toFixed(3);
+								if (event.data === 1) {
+									this.video.paused = false;
+									let youtubeDuration =
+										this.video.player.getDuration();
+									const newYoutubeVideoDuration =
+										youtubeDuration.toFixed(3);
 
 
 									if (
 									if (
-										`${this.youtubeVideoDuration}` ===
-											`${Number(
-												this.song.duration
-											).toFixed(3)}` &&
-										(fixedSongDuration ===
-											this.youtubeVideoDuration ||
-											fixedSongDuration2 ===
+										this.youtubeVideoDuration.indexOf(
+											".000"
+										) !== -1 &&
+										`${this.youtubeVideoDuration}` !==
+											`${newYoutubeVideoDuration}`
+									) {
+										const songDurationNumber = Number(
+											this.song.duration
+										);
+										const songDurationNumber2 =
+											Number(this.song.duration) + 1;
+										const songDurationNumber3 =
+											Number(this.song.duration) - 1;
+										const fixedSongDuration =
+											songDurationNumber.toFixed(3);
+										const fixedSongDuration2 =
+											songDurationNumber2.toFixed(3);
+										const fixedSongDuration3 =
+											songDurationNumber3.toFixed(3);
+
+										if (
+											`${this.youtubeVideoDuration}` ===
+												`${Number(
+													this.song.duration
+												).toFixed(3)}` &&
+											(fixedSongDuration ===
 												this.youtubeVideoDuration ||
 												this.youtubeVideoDuration ||
-											fixedSongDuration3 ===
-												this.youtubeVideoDuration)
-									)
-										this.song.duration =
+												fixedSongDuration2 ===
+													this.youtubeVideoDuration ||
+												fixedSongDuration3 ===
+													this.youtubeVideoDuration)
+										)
+											this.song.duration =
+												newYoutubeVideoDuration;
+
+										this.youtubeVideoDuration =
 											newYoutubeVideoDuration;
 											newYoutubeVideoDuration;
+										if (
+											this.youtubeVideoDuration.indexOf(
+												".000"
+											) !== -1
+										)
+											this.youtubeVideoNote = "(~)";
+										else this.youtubeVideoNote = "";
+									}
+
+									if (this.song.duration === -1)
+										this.song.duration =
+											this.youtubeVideoDuration;
 
 
-									this.youtubeVideoDuration =
-										newYoutubeVideoDuration;
+									youtubeDuration -= this.song.skipDuration;
 									if (
 									if (
-										this.youtubeVideoDuration.indexOf(
-											".000"
-										) !== -1
-									)
-										this.youtubeVideoNote = "(~)";
-									else this.youtubeVideoNote = "";
-								}
-
-								if (this.song.duration === -1)
-									this.song.duration =
-										this.youtubeVideoDuration;
+										this.song.duration >
+										youtubeDuration + 1
+									) {
+										this.stopVideo();
+										this.pauseVideo(true);
+										return new Toast(
+											"Video can't play. Specified duration is bigger than the YouTube song duration."
+										);
+									}
+									if (this.song.duration <= 0) {
+										this.stopVideo();
+										this.pauseVideo(true);
+										return new Toast(
+											"Video can't play. Specified duration has to be more than 0 seconds."
+										);
+									}
 
 
-								youtubeDuration -= this.song.skipDuration;
-								if (this.song.duration > youtubeDuration + 1) {
-									this.stopVideo();
-									this.pauseVideo(true);
-									return new Toast(
-										"Video can't play. Specified duration is bigger than the YouTube song duration."
-									);
-								}
-								if (this.song.duration <= 0) {
-									this.stopVideo();
-									this.pauseVideo(true);
-									return new Toast(
-										"Video can't play. Specified duration has to be more than 0 seconds."
-									);
-								}
-
-								if (
-									this.video.player.getCurrentTime() <
-									this.song.skipDuration
-								) {
-									return this.seekTo(this.song.skipDuration);
+									if (
+										this.video.player.getCurrentTime() <
+										this.song.skipDuration
+									) {
+										return this.seekTo(
+											this.song.skipDuration
+										);
+									}
+
+									this.setPlaybackRate(null);
+								} else if (event.data === 2) {
+									this.video.paused = true;
 								}
 								}
 
 
-								this.setPlaybackRate(null);
-							} else if (event.data === 2) {
-								this.video.paused = true;
+								return false;
 							}
 							}
-
-							return false;
 						}
 						}
 					}
 					}
-				});
+				);
 			} else {
 			} else {
 				this.youtubeError = true;
 				this.youtubeError = true;
 				this.youtubeErrorMessage = "Player could not be loaded.";
 				this.youtubeErrorMessage = "Player could not be loaded.";
@@ -1285,12 +1356,12 @@ export default {
 
 
 			return null;
 			return null;
 		},
 		},
-		unloadSong(songId) {
+		unloadSong(youtubeId, songId) {
 			this.songDataLoaded = false;
 			this.songDataLoaded = false;
 			this.songDeleted = false;
 			this.songDeleted = false;
 			this.stopVideo();
 			this.stopVideo();
 			this.pauseVideo(true);
 			this.pauseVideo(true);
-			this.resetSong(songId);
+			this.resetSong(youtubeId);
 			this.thumbnailNotSquare = false;
 			this.thumbnailNotSquare = false;
 			this.thumbnailWidth = null;
 			this.thumbnailWidth = null;
 			this.thumbnailHeight = null;
 			this.thumbnailHeight = null;
@@ -1300,40 +1371,52 @@ export default {
 			this.socket.dispatch("apis.leaveRoom", `edit-song.${songId}`);
 			this.socket.dispatch("apis.leaveRoom", `edit-song.${songId}`);
 			if (this.$refs.saveButton) this.$refs.saveButton.status = "default";
 			if (this.$refs.saveButton) this.$refs.saveButton.status = "default";
 		},
 		},
-		loadSong(songId) {
-			console.log(`LOAD SONG ${songId}`);
+		loadSong(youtubeId) {
+			console.log(`LOAD SONG ${youtubeId}`);
 			this.songNotFound = false;
 			this.songNotFound = false;
-			this.socket.dispatch(`songs.getSongFromSongId`, songId, res => {
-				if (res.status === "success") {
-					let { song } = res.data;
-
-					song = Object.assign(song, this.prefillData);
-
-					this.setSong(song);
+			this.socket.dispatch(
+				`songs.getSongsFromYoutubeIds`,
+				[youtubeId],
+				res => {
+					const { songs } = res.data;
+					if (res.status === "success" && songs.length > 0) {
+						let song = songs[0];
+						song = Object.assign(song, this.prefillData);
 
 
-					this.songDataLoaded = true;
+						this.setSong(song);
 
 
-					this.socket.dispatch(
-						"apis.joinRoom",
-						`edit-song.${this.song._id}`
-					);
+						this.songDataLoaded = true;
 
 
-					if (this.video.player && this.video.player.cueVideoById) {
-						this.video.player.cueVideoById(
-							this.song.youtubeId,
-							this.song.skipDuration
+						this.socket.dispatch(
+							"apis.joinRoom",
+							`edit-song.${this.song._id}`
 						);
 						);
+
+						if (
+							this.video.player &&
+							this.video.player.cueVideoById
+						) {
+							this.video.player.cueVideoById(
+								this.youtubeId,
+								song.skipDuration
+							);
+						}
+					} else {
+						new Toast("Song with that ID not found");
+						if (this.bulk) this.songNotFound = true;
+						if (!this.bulk) this.closeModal("editSong");
 					}
 					}
-				} else {
-					new Toast("Song with that ID not found");
-					if (this.bulk) this.songNotFound = true;
-					if (!this.bulk) this.closeModal("editSong");
 				}
 				}
-			});
+			);
 
 
-			this.socket.dispatch("reports.getReportsForSong", songId, res => {
-				this.updateReports(res.data.reports);
-			});
+			if (!this.newSong)
+				this.socket.dispatch(
+					"reports.getReportsForSong",
+					this.song._id,
+					res => {
+						this.updateReports(res.data.reports);
+					}
+				);
 		},
 		},
 		importAlbum(result) {
 		importAlbum(result) {
 			this.selectDiscogsAlbum(result);
 			this.selectDiscogsAlbum(result);
@@ -1343,25 +1426,27 @@ export default {
 		save(songToCopy, closeOrNext, saveButtonRefName, newSong = false) {
 		save(songToCopy, closeOrNext, saveButtonRefName, newSong = false) {
 			const song = JSON.parse(JSON.stringify(songToCopy));
 			const song = JSON.parse(JSON.stringify(songToCopy));
 
 
-			if (!newSong) this.$emit("saving", song._id);
+			if (!newSong || this.bulk) this.$emit("saving", song.youtubeId);
 
 
 			const saveButtonRef = this.$refs[saveButtonRefName];
 			const saveButtonRef = this.$refs[saveButtonRefName];
 
 
 			if (!this.youtubeError && this.youtubeVideoDuration === "0.000") {
 			if (!this.youtubeError && this.youtubeVideoDuration === "0.000") {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong) this.$emit("savedError", song.youtubeId);
 				return new Toast("The video appears to not be working.");
 				return new Toast("The video appears to not be working.");
 			}
 			}
 
 
 			if (!song.title) {
 			if (!song.title) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast("Please fill in all fields");
 				return new Toast("Please fill in all fields");
 			}
 			}
 
 
 			if (!song.thumbnail) {
 			if (!song.thumbnail) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast("Please fill in all fields");
 				return new Toast("Please fill in all fields");
 			}
 			}
 
 
@@ -1394,7 +1479,8 @@ export default {
 				this.originalSong.youtubeId !== song.youtubeId
 				this.originalSong.youtubeId !== song.youtubeId
 			) {
 			) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(
 				return new Toast(
 					"You're not allowed to change the YouTube id while the player is not working"
 					"You're not allowed to change the YouTube id while the player is not working"
 				);
 				);
@@ -1404,11 +1490,12 @@ export default {
 			if (
 			if (
 				Number(song.skipDuration) + Number(song.duration) >
 				Number(song.skipDuration) + Number(song.duration) >
 					this.youtubeVideoDuration &&
 					this.youtubeVideoDuration &&
-				((!newSong && !this.youtubeError) ||
+				(((!newSong || this.bulk) && !this.youtubeError) ||
 					this.originalSong.duration !== song.duration)
 					this.originalSong.duration !== song.duration)
 			) {
 			) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(
 				return new Toast(
 					"Duration can't be higher than the length of the video"
 					"Duration can't be higher than the length of the video"
 				);
 				);
@@ -1417,7 +1504,8 @@ export default {
 			// Title
 			// Title
 			if (!validation.isLength(song.title, 1, 100)) {
 			if (!validation.isLength(song.title, 1, 100)) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(
 				return new Toast(
 					"Title must have between 1 and 100 characters."
 					"Title must have between 1 and 100 characters."
 				);
 				);
@@ -1429,7 +1517,8 @@ export default {
 				song.artists.length > 10
 				song.artists.length > 10
 			) {
 			) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(
 				return new Toast(
 					"Invalid artists. You must have at least 1 artist and a maximum of 10 artists."
 					"Invalid artists. You must have at least 1 artist and a maximum of 10 artists."
 				);
 				);
@@ -1452,25 +1541,27 @@ export default {
 
 
 			if (error) {
 			if (error) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(error);
 				return new Toast(error);
 			}
 			}
 
 
 			// Genres
 			// Genres
 			error = undefined;
 			error = undefined;
-			song.genres.forEach(genre => {
-				if (!validation.isLength(genre, 1, 32)) {
-					error = "Genre must have between 1 and 32 characters.";
-					return error;
-				}
-				if (!validation.regex.ascii.test(genre)) {
-					error =
-						"Invalid genre format. Only ascii characters are allowed.";
-					return error;
-				}
+			if (song.verified && song.genres.length < 1)
+				song.genres.forEach(genre => {
+					if (!validation.isLength(genre, 1, 32)) {
+						error = "Genre must have between 1 and 32 characters.";
+						return error;
+					}
+					if (!validation.regex.ascii.test(genre)) {
+						error =
+							"Invalid genre format. Only ascii characters are allowed.";
+						return error;
+					}
 
 
-				return false;
-			});
+					return false;
+				});
 
 
 			if (
 			if (
 				(song.verified && song.genres.length < 1) ||
 				(song.verified && song.genres.length < 1) ||
@@ -1480,7 +1571,8 @@ export default {
 
 
 			if (error) {
 			if (error) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(error);
 				return new Toast(error);
 			}
 			}
 
 
@@ -1500,21 +1592,24 @@ export default {
 
 
 			if (error) {
 			if (error) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(error);
 				return new Toast(error);
 			}
 			}
 
 
 			// Thumbnail
 			// Thumbnail
 			if (!validation.isLength(song.thumbnail, 1, 256)) {
 			if (!validation.isLength(song.thumbnail, 1, 256)) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast(
 				return new Toast(
 					"Thumbnail must have between 8 and 256 characters."
 					"Thumbnail must have between 8 and 256 characters."
 				);
 				);
 			}
 			}
 			if (this.useHTTPS && song.thumbnail.indexOf("https://") !== 0) {
 			if (this.useHTTPS && song.thumbnail.indexOf("https://") !== 0) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast('Thumbnail must start with "https://".');
 				return new Toast('Thumbnail must start with "https://".');
 			}
 			}
 
 
@@ -1524,7 +1619,8 @@ export default {
 				song.thumbnail.indexOf("https://") !== 0
 				song.thumbnail.indexOf("https://") !== 0
 			) {
 			) {
 				saveButtonRef.handleFailedSave();
 				saveButtonRef.handleFailedSave();
-				if (!newSong) this.$emit("savedError", song._id);
+				if (!newSong || this.bulk)
+					this.$emit("savedError", song.youtubeId);
 				return new Toast('Thumbnail must start with "http://".');
 				return new Toast('Thumbnail must start with "http://".');
 			}
 			}
 
 
@@ -1536,26 +1632,34 @@ export default {
 
 
 					if (res.status === "error") {
 					if (res.status === "error") {
 						saveButtonRef.handleFailedSave();
 						saveButtonRef.handleFailedSave();
+						this.$emit("savedError", song.youtubeId);
 						return;
 						return;
 					}
 					}
 
 
 					saveButtonRef.handleSuccessfulSave();
 					saveButtonRef.handleSuccessfulSave();
+					this.$emit("savedSuccess", song.youtubeId);
+
+					if (!closeOrNext) {
+						this.loadSong(song.youtubeId);
+						return;
+					}
 
 
-					this.closeModal("editSong");
+					if (this.bulk) this.$emit("nextSong");
+					else this.closeModal("editSong");
 				});
 				});
 			return this.socket.dispatch(`songs.update`, song._id, song, res => {
 			return this.socket.dispatch(`songs.update`, song._id, song, res => {
 				new Toast(res.message);
 				new Toast(res.message);
 
 
 				if (res.status === "error") {
 				if (res.status === "error") {
 					saveButtonRef.handleFailedSave();
 					saveButtonRef.handleFailedSave();
-					this.$emit("savedError", song._id);
+					this.$emit("savedError", song.youtubeId);
 					return;
 					return;
 				}
 				}
 
 
 				this.updateOriginalSong(song);
 				this.updateOriginalSong(song);
 
 
 				saveButtonRef.handleSuccessfulSave();
 				saveButtonRef.handleSuccessfulSave();
-				this.$emit("savedSuccess", song._id);
+				this.$emit("savedSuccess", song.youtubeId);
 
 
 				if (!closeOrNext) return;
 				if (!closeOrNext) return;
 
 
@@ -1597,11 +1701,39 @@ export default {
 				});
 				});
 		},
 		},
 		getYouTubeData(type) {
 		getYouTubeData(type) {
-			if (type === "albumArt")
+			if (type === "title") {
+				try {
+					const { title } = this.video.player.getVideoData();
+
+					if (title)
+						this.updateSongField({
+							field: "title",
+							value: title
+						});
+					else throw new Error("No title found");
+				} catch (e) {
+					new Toast(
+						"Unable to fetch YouTube video title. Try starting the video."
+					);
+				}
+			}
+			if (type === "thumbnail")
 				this.updateSongField({
 				this.updateSongField({
 					field: "thumbnail",
 					field: "thumbnail",
 					value: `https://img.youtube.com/vi/${this.song.youtubeId}/mqdefault.jpg`
 					value: `https://img.youtube.com/vi/${this.song.youtubeId}/mqdefault.jpg`
 				});
 				});
+			if (type === "author") {
+				try {
+					const { author } = this.video.player.getVideoData();
+
+					if (author) this.artistInputValue = author;
+					else throw new Error("No video author found");
+				} catch (e) {
+					new Toast(
+						"Unable to fetch YouTube video author. Try starting the video."
+					);
+				}
+			}
 		},
 		},
 		fillDuration() {
 		fillDuration() {
 			this.song.duration =
 			this.song.duration =
@@ -1726,7 +1858,8 @@ export default {
 		},
 		},
 		drawCanvas() {
 		drawCanvas() {
 			if (!this.songDataLoaded) return;
 			if (!this.songDataLoaded) return;
-			const canvasElement = this.$refs.durationCanvas;
+			const canvasElement =
+				this.$refs[`durationCanvas-${this.modalUuid}`];
 			const ctx = canvasElement.getContext("2d");
 			const ctx = canvasElement.getContext("2d");
 
 
 			const videoDuration = Number(this.youtubeVideoDuration);
 			const videoDuration = Number(this.youtubeVideoDuration);
@@ -1941,7 +2074,7 @@ export default {
 		}
 		}
 	}
 	}
 
 
-	#durationCanvas {
+	.duration-canvas {
 		background-color: var(--dark-grey-2) !important;
 		background-color: var(--dark-grey-2) !important;
 	}
 	}
 }
 }
@@ -1961,7 +2094,6 @@ export default {
 }
 }
 
 
 .left-section {
 .left-section {
-	flex-basis: unset !important;
 	height: 100%;
 	height: 100%;
 	display: flex;
 	display: flex;
 	flex-direction: column;
 	flex-direction: column;
@@ -1978,7 +2110,7 @@ export default {
 			border-radius: @border-radius;
 			border-radius: @border-radius;
 			overflow: hidden;
 			overflow: hidden;
 
 
-			#durationCanvas {
+			.duration-canvas {
 				background-color: var(--light-grey-2);
 				background-color: var(--light-grey-2);
 			}
 			}
 
 
@@ -2170,17 +2302,24 @@ export default {
 			}
 			}
 		}
 		}
 
 
-		.thumbnail-preview {
+		:deep(.thumbnail-preview) {
 			width: 189px;
 			width: 189px;
 			height: 189px;
 			height: 189px;
 			margin-left: 16px;
 			margin-left: 16px;
 		}
 		}
+
+		.thumbnail-dummy {
+			opacity: 0;
+			height: 10px;
+			width: 10px;
+		}
 	}
 	}
 
 
 	.edit-section {
 	.edit-section {
-		width: 735px;
+		display: flex;
+		flex-wrap: wrap;
+		flex-grow: 1;
 		border: 1px solid var(--light-grey-3);
 		border: 1px solid var(--light-grey-3);
-		flex: 1;
 		margin-top: 16px;
 		margin-top: 16px;
 		border-radius: @border-radius;
 		border-radius: @border-radius;
 
 
@@ -2232,6 +2371,7 @@ export default {
 
 
 		> div {
 		> div {
 			margin: 16px !important;
 			margin: 16px !important;
+			width: 100%;
 		}
 		}
 
 
 		input {
 		input {
@@ -2254,7 +2394,7 @@ export default {
 
 
 		.album-art-container {
 		.album-art-container {
 			margin-right: 16px;
 			margin-right: 16px;
-			width: calc((100% - 16px) / 8 * 4);
+			width: 100%;
 		}
 		}
 
 
 		.youtube-id-container {
 		.youtube-id-container {
@@ -2399,6 +2539,26 @@ export default {
 	}
 	}
 }
 }
 
 
+@media screen and (max-width: 1100px) {
+	.left-section,
+	.right-section {
+		height: unset;
+		max-height: unset;
+	}
+
+	.left-section {
+		margin-right: 0;
+	}
+
+	.right-section {
+		flex-basis: 100% !important;
+
+		#tabs-container {
+			width: 100%;
+		}
+	}
+}
+
 .modal-card-foot .is-primary {
 .modal-card-foot .is-primary {
 	width: 200px;
 	width: 200px;
 }
 }

+ 70 - 34
frontend/src/components/modals/EditSongs.vue

@@ -44,8 +44,8 @@
 							v-for="(
 							v-for="(
 								{ status, flagged, song }, index
 								{ status, flagged, song }, index
 							) in filteredItems"
 							) in filteredItems"
-							:key="song._id"
-							:ref="`edit-songs-item-${song._id}`"
+							:key="song.youtubeId"
+							:ref="`edit-songs-item-${song.youtubeId}`"
 						>
 						>
 							<song-item
 							<song-item
 								:song="song"
 								:song="song"
@@ -61,7 +61,10 @@
 							>
 							>
 								<template #leftIcon>
 								<template #leftIcon>
 									<i
 									<i
-										v-if="currentSong._id === song._id"
+										v-if="
+											currentSong.youtubeId ===
+												song.youtubeId && !song.removed
+										"
 										class="material-icons item-icon editing-icon"
 										class="material-icons item-icon editing-icon"
 										content="Currently editing song"
 										content="Currently editing song"
 										v-tippy="{ theme: 'info' }"
 										v-tippy="{ theme: 'info' }"
@@ -201,12 +204,12 @@ export default {
 	computed: {
 	computed: {
 		editingItemIndex() {
 		editingItemIndex() {
 			return this.items.findIndex(
 			return this.items.findIndex(
-				item => item.song._id === this.currentSong._id
+				item => item.song.youtubeId === this.currentSong.youtubeId
 			);
 			);
 		},
 		},
 		filteredEditingItemIndex() {
 		filteredEditingItemIndex() {
 			return this.filteredItems.findIndex(
 			return this.filteredItems.findIndex(
-				item => item.song._id === this.currentSong._id
+				item => item.song.youtubeId === this.currentSong.youtubeId
 			);
 			);
 		},
 		},
 		filteredItems: {
 		filteredItems: {
@@ -217,18 +220,18 @@ export default {
 			},
 			},
 			set(newItem) {
 			set(newItem) {
 				const index = this.items.findIndex(
 				const index = this.items.findIndex(
-					item => item.song._id === newItem._id
+					item => item.song.youtubeId === newItem.youtubeId
 				);
 				);
 				this.item[index] = newItem;
 				this.item[index] = newItem;
 			}
 			}
 		},
 		},
 		currentSongFlagged() {
 		currentSongFlagged() {
 			return this.items.find(
 			return this.items.find(
-				item => item.song._id === this.currentSong._id
+				item => item.song.youtubeId === this.currentSong.youtubeId
 			)?.flagged;
 			)?.flagged;
 		},
 		},
 		...mapModalState("modals/editSongs/MODAL_UUID", {
 		...mapModalState("modals/editSongs/MODAL_UUID", {
-			songIds: state => state.songIds,
+			youtubeIds: state => state.youtubeIds,
 			songPrefillData: state => state.songPrefillData
 			songPrefillData: state => state.songPrefillData
 		}),
 		}),
 		...mapGetters({
 		...mapGetters({
@@ -243,27 +246,46 @@ export default {
 			editSong
 			editSong
 		);
 		);
 
 
-		this.socket.dispatch("songs.getSongsFromSongIds", this.songIds, res => {
-			res.data.songs.forEach(song => {
-				this.items.push({
-					status: "todo",
-					flagged: false,
-					song
+		this.socket.dispatch(
+			"songs.getSongsFromYoutubeIds",
+			this.youtubeIds,
+			res => {
+				res.data.songs.forEach(song => {
+					this.items.push({
+						status: "todo",
+						flagged: false,
+						song
+					});
 				});
 				});
-			});
 
 
-			if (this.items.length === 0) {
-				this.closeThisModal();
-				new Toast("You can't edit 0 songs.");
-			} else this.editNextSong();
-		});
+				if (this.items.length === 0) {
+					this.closeThisModal();
+					new Toast("You can't edit 0 songs.");
+				} else this.editNextSong();
+			}
+		);
+
+		this.socket.on(
+			`event:admin.song.created`,
+			res => {
+				const index = this.items
+					.map(item => item.song.youtubeId)
+					.indexOf(res.data.song.youtubeId);
+				this.items[index].song = {
+					...this.items[index].song,
+					...res.data.song,
+					created: true
+				};
+			},
+			{ modalUuid: this.modalUuid }
+		);
 
 
 		this.socket.on(
 		this.socket.on(
 			`event:admin.song.updated`,
 			`event:admin.song.updated`,
 			res => {
 			res => {
 				const index = this.items
 				const index = this.items
-					.map(item => item.song._id)
-					.indexOf(res.data.song._id);
+					.map(item => item.song.youtubeId)
+					.indexOf(res.data.song.youtubeId);
 				this.items[index].song = {
 				this.items[index].song = {
 					...this.items[index].song,
 					...this.items[index].song,
 					...res.data.song,
 					...res.data.song,
@@ -283,6 +305,17 @@ export default {
 			},
 			},
 			{ modalUuid: this.modalUuid }
 			{ modalUuid: this.modalUuid }
 		);
 		);
+
+		this.socket.on(
+			`event:admin.youtubeVideo.removed`,
+			res => {
+				const index = this.items
+					.map(item => item.song.youtubeVideoId)
+					.indexOf(res.videoId);
+				if (index !== -1) this.items[index].song.removed = true;
+			},
+			{ modalUuid: this.modalUuid }
+		);
 	},
 	},
 	beforeUnmount() {
 	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRoom", "edit-songs");
 		this.socket.dispatch("apis.leaveRoom", "edit-songs");
@@ -294,15 +327,17 @@ export default {
 	methods: {
 	methods: {
 		pickSong(song) {
 		pickSong(song) {
 			this.editSong({
 			this.editSong({
-				songId: song._id,
-				prefill: this.songPrefillData[song._id]
+				youtubeId: song.youtubeId,
+				prefill: this.songPrefillData[song.youtubeId]
 			});
 			});
 			this.currentSong = song;
 			this.currentSong = song;
 			if (
 			if (
-				this.$refs[`edit-songs-item-${song._id}`] &&
-				this.$refs[`edit-songs-item-${song._id}`][0]
+				this.$refs[`edit-songs-item-${song.youtubeId}`] &&
+				this.$refs[`edit-songs-item-${song.youtubeId}`][0]
 			)
 			)
-				this.$refs[`edit-songs-item-${song._id}`][0].scrollIntoView();
+				this.$refs[
+					`edit-songs-item-${song.youtubeId}`
+				][0].scrollIntoView();
 		},
 		},
 		editNextSong() {
 		editNextSong() {
 			const currentlyEditingSongIndex = this.filteredEditingItemIndex;
 			const currentlyEditingSongIndex = this.filteredEditingItemIndex;
@@ -320,7 +355,8 @@ export default {
 
 
 			if (newEditingSongIndex > -1) {
 			if (newEditingSongIndex > -1) {
 				const nextSong = this.filteredItems[newEditingSongIndex].song;
 				const nextSong = this.filteredItems[newEditingSongIndex].song;
-				this.pickSong(nextSong);
+				if (nextSong.removed) this.editNextSong();
+				else this.pickSong(nextSong);
 			}
 			}
 		},
 		},
 		toggleFlag(songIndex = null) {
 		toggleFlag(songIndex = null) {
@@ -346,24 +382,24 @@ export default {
 				);
 				);
 			}
 			}
 		},
 		},
-		onSavedSuccess(songId) {
+		onSavedSuccess(youtubeId) {
 			const itemIndex = this.items.findIndex(
 			const itemIndex = this.items.findIndex(
-				item => item.song._id === songId
+				item => item.song.youtubeId === youtubeId
 			);
 			);
 			if (itemIndex > -1) {
 			if (itemIndex > -1) {
 				this.items[itemIndex].status = "done";
 				this.items[itemIndex].status = "done";
 				this.items[itemIndex].flagged = false;
 				this.items[itemIndex].flagged = false;
 			}
 			}
 		},
 		},
-		onSavedError(songId) {
+		onSavedError(youtubeId) {
 			const itemIndex = this.items.findIndex(
 			const itemIndex = this.items.findIndex(
-				item => item.song._id === songId
+				item => item.song.youtubeId === youtubeId
 			);
 			);
 			if (itemIndex > -1) this.items[itemIndex].status = "error";
 			if (itemIndex > -1) this.items[itemIndex].status = "error";
 		},
 		},
-		onSaving(songId) {
+		onSaving(youtubeId) {
 			const itemIndex = this.items.findIndex(
 			const itemIndex = this.items.findIndex(
-				item => item.song._id === songId
+				item => item.song.youtubeId === youtubeId
 			);
 			);
 			if (itemIndex > -1) this.items[itemIndex].status = "saving";
 			if (itemIndex > -1) this.items[itemIndex].status = "saving";
 		},
 		},

+ 29 - 16
frontend/src/components/modals/ImportAlbum.vue

@@ -425,7 +425,7 @@ export default {
 					delete discogsAlbum.gotMoreInfo;
 					delete discogsAlbum.gotMoreInfo;
 
 
 					const songToEdit = {
 					const songToEdit = {
-						songId: song._id,
+						youtubeId: song.youtubeId,
 						prefill: {
 						prefill: {
 							discogs: discogsAlbum
 							discogs: discogsAlbum
 						}
 						}
@@ -488,26 +488,39 @@ export default {
 			}, 750);
 			}, 750);
 
 
 			return this.socket.dispatch(
 			return this.socket.dispatch(
-				"songs.requestSet",
+				"youtube.requestSet",
 				this.search.playlist.query,
 				this.search.playlist.query,
 				false,
 				false,
 				true,
 				true,
 				res => {
 				res => {
 					this.isImportingPlaylist = false;
 					this.isImportingPlaylist = false;
-					const songs = res.songs.filter(song => !song.verified);
-					const songsAlreadyVerified =
-						res.songs.length - songs.length;
-					this.setPlaylistSongs(songs);
-					if (this.discogsAlbum.tracks) {
-						this.trackSongs = this.discogsAlbum.tracks.map(
-							() => []
-						);
-						this.tryToAutoMove();
-					}
-					if (songsAlreadyVerified > 0)
-						new Toast(
-							`${songsAlreadyVerified} songs were already verified, skipping those.`
-						);
+					const youtubeIds = res.videos.map(video => video.youtubeId);
+
+					this.socket.dispatch(
+						"songs.getSongsFromYoutubeIds",
+						youtubeIds,
+						res => {
+							if (res.status === "success") {
+								const songs = res.data.songs.filter(
+									song => !song.verified
+								);
+								const songsAlreadyVerified =
+									res.data.songs.length - songs.length;
+								this.setPlaylistSongs(songs);
+								if (this.discogsAlbum.tracks) {
+									this.trackSongs =
+										this.discogsAlbum.tracks.map(() => []);
+									this.tryToAutoMove();
+								}
+								if (songsAlreadyVerified > 0)
+									new Toast(
+										`${songsAlreadyVerified} songs were already verified, skipping those.`
+									);
+							}
+							new Toast("Could not get songs.");
+						}
+					);
+
 					return new Toast({ content: res.message, timeout: 20000 });
 					return new Toast({ content: res.message, timeout: 20000 });
 				}
 				}
 			);
 			);

+ 0 - 164
frontend/src/components/modals/ImportPlaylist.vue

@@ -1,164 +0,0 @@
-<template>
-	<modal title="Import Playlist">
-		<template #body>
-			<div class="vertical-padding">
-				<p class="section-description">
-					Import a playlist by using a link from YouTube
-				</p>
-
-				<div class="control is-grouped">
-					<p class="control is-expanded">
-						<input
-							class="input"
-							type="text"
-							placeholder="YouTube Playlist URL"
-							v-model="youtubeSearch.playlist.query"
-							@keyup.enter="importPlaylist()"
-						/>
-					</p>
-					<p id="playlist-import-type" class="control select">
-						<select
-							v-model="
-								youtubeSearch.playlist.isImportingOnlyMusic
-							"
-						>
-							<option :value="false">Import all</option>
-							<option :value="true">Import only music</option>
-						</select>
-					</p>
-					<p class="control">
-						<button
-							class="button is-info"
-							@click.prevent="importPlaylist()"
-						>
-							<i class="material-icons icon-with-button"
-								>publish</i
-							>Import
-						</button>
-					</p>
-				</div>
-			</div>
-		</template>
-		<template #footer>
-			<p class="is-expanded checkbox-control">
-				<label class="switch">
-					<input
-						type="checkbox"
-						id="edit-imported-songs"
-						v-model="localEditSongs"
-					/>
-					<span class="slider round"></span>
-				</label>
-
-				<label for="edit-imported-songs">
-					<p>Edit Songs</p>
-				</label>
-			</p>
-		</template>
-	</modal>
-</template>
-
-<script>
-import { mapActions, mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-
-export default {
-	mixins: [SearchYoutube],
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	computed: {
-		localEditSongs: {
-			get() {
-				return this.$store.state.modals.importPlaylist[this.modalUuid]
-					.editImportedSongs;
-			},
-			set(editImportedSongs) {
-				this.$store.commit(
-					`modals/importPlaylist/${this.modalUuid}/updateEditImportedSongs`,
-					editImportedSongs
-				);
-			}
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	beforeUnmount() {
-		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
-		this.$store.unregisterModule([
-			"modals",
-			"importPlaylist",
-			this.modalUuid
-		]);
-	},
-	methods: {
-		importPlaylist() {
-			let isImportingPlaylist = true;
-
-			// import query is blank
-			if (!this.youtubeSearch.playlist.query)
-				return new Toast("Please enter a YouTube playlist URL.");
-
-			const regex = /[\\?&]list=([^&#]*)/;
-			const splitQuery = regex.exec(this.youtubeSearch.playlist.query);
-
-			if (!splitQuery) {
-				return new Toast({
-					content: "Please enter a valid YouTube playlist URL.",
-					timeout: 4000
-				});
-			}
-
-			// don't give starting import message instantly in case of instant error
-			setTimeout(() => {
-				if (isImportingPlaylist) {
-					new Toast(
-						"Starting to import your playlist. This can take some time to do."
-					);
-				}
-			}, 750);
-
-			return this.socket.dispatch(
-				"songs.requestSet",
-				this.youtubeSearch.playlist.query,
-				this.youtubeSearch.playlist.isImportingOnlyMusic,
-				true,
-				res => {
-					isImportingPlaylist = false;
-
-					if (
-						this.localEditSongs &&
-						res.status === "success" &&
-						res.songs &&
-						res.songs.length > 0
-					) {
-						this.openModal({
-							modal: "editSongs",
-							data: {
-								songs: res.songs.map(song => ({
-									...song,
-									songId: song._id
-								}))
-							}
-						});
-					}
-
-					this.closeCurrentModal();
-					return new Toast({
-						content: res.message,
-						timeout: 20000
-					});
-				}
-			);
-		},
-		...mapActions("modalVisibility", ["openModal", "closeCurrentModal"])
-	}
-};
-</script>

+ 149 - 0
frontend/src/components/modals/ViewApiRequest.vue

@@ -0,0 +1,149 @@
+<template>
+	<modal title="View API Request">
+		<template #body>
+			<div v-if="!loaded" class="vertical-padding">
+				<p>Request hasn't loaded yet</p>
+			</div>
+			<div v-else class="vertical-padding">
+				<p><b>ID:</b> {{ request._id }}</p>
+				<p><b>URL:</b> {{ request.url }}</p>
+				<div>
+					<b>Params:</b>
+					<ul v-if="request.params">
+						<li
+							v-for="[paramKey, paramValue] in Object.entries(
+								request.params
+							)"
+							:key="paramKey"
+						>
+							<b>{{ paramKey }}</b
+							>: {{ paramValue }}
+						</li>
+					</ul>
+					<span v-else>None/Not found</span>
+				</div>
+				<div>
+					<b>Results:</b>
+					<vue-json-pretty
+						:data="request.results"
+						:show-length="true"
+					></vue-json-pretty>
+				</div>
+				<p><b>Date:</b> {{ request.date }}</p>
+				<p><b>Quota cost:</b> {{ request.quotaCost }}</p>
+			</div>
+		</template>
+		<template #footer>
+			<quick-confirm v-if="removeAction" @confirm="remove()">
+				<a class="button is-danger"> Remove API request</a>
+			</quick-confirm>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapActions, mapGetters } from "vuex";
+
+import VueJsonPretty from "vue-json-pretty";
+import "vue-json-pretty/lib/styles.css";
+import Toast from "toasters";
+
+import ws from "@/ws";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
+
+export default {
+	components: {
+		VueJsonPretty
+	},
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
+	data() {
+		return {
+			loaded: false
+		};
+	},
+	computed: {
+		...mapModalState("modals/viewApiRequest/MODAL_UUID", {
+			requestId: state => state.requestId,
+			request: state => state.request,
+			removeAction: state => state.removeAction
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		ws.onConnect(this.init);
+	},
+	beforeUnmount() {
+		this.socket.dispatch(
+			"apis.leaveRoom",
+			`view-api-request.${this.requestId}`,
+			() => {}
+		);
+
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"viewApiRequest",
+			this.modalUuid
+		]);
+	},
+	methods: {
+		init() {
+			this.loaded = false;
+			this.socket.dispatch(
+				"youtube.getApiRequest",
+				this.requestId,
+				res => {
+					if (res.status === "success") {
+						const { apiRequest } = res.data;
+						this.viewApiRequest(apiRequest);
+						this.loaded = true;
+
+						this.socket.dispatch(
+							"apis.joinRoom",
+							`view-api-request.${this.requestId}`
+						);
+
+						this.socket.on(
+							"event:youtubeApiRequest.removed",
+							() => {
+								new Toast("This API request was removed.");
+								this.closeCurrentModal();
+							},
+							{ modalUuid: this.modalUuid }
+						);
+					} else {
+						new Toast("API request with that ID not found");
+						this.closeCurrentModal();
+					}
+				}
+			);
+		},
+		remove() {
+			if (this.removeAction)
+				this.socket.dispatch(this.removeAction, this.requestId, res => {
+					if (res.status === "success") {
+						new Toast("API request successfully removed.");
+						this.closeCurrentModal();
+					} else {
+						new Toast("API request with that ID not found.");
+					}
+				});
+		},
+		...mapModalActions("modals/viewApiRequest/MODAL_UUID", [
+			"viewApiRequest"
+		]),
+		...mapActions("modalVisibility", ["closeCurrentModal"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+ul {
+	list-style-type: disc;
+	padding-left: 20px;
+}
+</style>

+ 1 - 1
frontend/src/components/modals/ViewReport.vue

@@ -246,7 +246,7 @@ export default {
 		openSong() {
 		openSong() {
 			this.openModal({
 			this.openModal({
 				modal: "editSong",
 				modal: "editSong",
-				data: { song: { songId: this.report.song._id } }
+				data: { song: this.report.song }
 			});
 			});
 		},
 		},
 		...mapActions("admin/reports", [
 		...mapActions("admin/reports", [

+ 1019 - 0
frontend/src/components/modals/ViewYoutubeVideo.vue

@@ -0,0 +1,1019 @@
+<template>
+	<modal title="View YouTube Video">
+		<template #body>
+			<div v-if="loaded" class="top-section">
+				<div class="left-section">
+					<p>
+						<strong>ID:</strong>
+						<span :title="video._id">{{ video._id }}</span>
+					</p>
+					<p>
+						<strong>YouTube ID:</strong>
+						<a
+							:href="
+								'https://www.youtube.com/watch?v=' +
+								`${video.youtubeId}`
+							"
+							target="_blank"
+						>
+							{{ video.youtubeId }}
+						</a>
+					</p>
+					<p>
+						<strong>Title:</strong>
+						<span :title="video.title">{{ video.title }}</span>
+					</p>
+					<p>
+						<strong>Author:</strong>
+						<span :title="video.author">{{ video.author }}</span>
+					</p>
+					<p>
+						<strong>Duration:</strong>
+						<span :title="video.duration">{{
+							video.duration
+						}}</span>
+					</p>
+				</div>
+				<div class="right-section">
+					<song-thumbnail :song="video" class="thumbnail-preview" />
+				</div>
+			</div>
+
+			<div v-show="loaded" class="player-section">
+				<div class="player-container">
+					<div :id="`viewYoutubeVideoPlayer-${modalUuid}`" />
+				</div>
+
+				<div v-show="player.error" class="player-error">
+					<h2>{{ player.errorMessage }}</h2>
+				</div>
+
+				<canvas
+					:ref="`durationCanvas-${modalUuid}`"
+					class="duration-canvas"
+					v-show="!player.error"
+					height="20"
+					:width="canvasWidth"
+					@click="setTrackPosition($event)"
+				/>
+				<div class="player-footer">
+					<div class="player-footer-left">
+						<button
+							class="button is-primary"
+							@click="play()"
+							@keyup.enter="play()"
+							v-if="player.paused"
+							content="Resume Playback"
+							v-tippy
+						>
+							<i class="material-icons">play_arrow</i>
+						</button>
+						<button
+							class="button is-primary"
+							@click="settings('pause')"
+							@keyup.enter="settings('pause')"
+							v-else
+							content="Pause Playback"
+							v-tippy
+						>
+							<i class="material-icons">pause</i>
+						</button>
+						<button
+							class="button is-danger"
+							@click.exact="settings('stop')"
+							@click.shift="settings('hardStop')"
+							@keyup.enter.exact="settings('stop')"
+							@keyup.shift.enter="settings('hardStop')"
+							content="Stop Playback"
+							v-tippy
+						>
+							<i class="material-icons">stop</i>
+						</button>
+						<tippy
+							class="playerRateDropdown"
+							:touch="true"
+							:interactive="true"
+							placement="bottom"
+							theme="dropdown"
+							ref="dropdown"
+							trigger="click"
+							append-to="parent"
+							@show="
+								() => {
+									player.showRateDropdown = true;
+								}
+							"
+							@hide="
+								() => {
+									player.showRateDropdown = false;
+								}
+							"
+						>
+							<div
+								ref="trigger"
+								class="control has-addons"
+								content="Set Playback Rate"
+								v-tippy
+							>
+								<button class="button is-primary">
+									<i class="material-icons">fast_forward</i>
+								</button>
+								<button class="button dropdown-toggle">
+									<i class="material-icons">
+										{{
+											player.showRateDropdown
+												? "expand_more"
+												: "expand_less"
+										}}
+									</i>
+								</button>
+							</div>
+
+							<template #content>
+								<div class="nav-dropdown-items">
+									<button
+										class="nav-item button"
+										:class="{
+											active: player.playbackRate === 0.5
+										}"
+										title="0.5x"
+										@click="setPlaybackRate(0.5)"
+									>
+										<p>0.5x</p>
+									</button>
+									<button
+										class="nav-item button"
+										:class="{
+											active: player.playbackRate === 1
+										}"
+										title="1x"
+										@click="setPlaybackRate(1)"
+									>
+										<p>1x</p>
+									</button>
+									<button
+										class="nav-item button"
+										:class="{
+											active: player.playbackRate === 2
+										}"
+										title="2x"
+										@click="setPlaybackRate(2)"
+									>
+										<p>2x</p>
+									</button>
+								</div>
+							</template>
+						</tippy>
+					</div>
+					<div class="player-footer-center">
+						<span>
+							<span>
+								{{ player.currentTime }}
+							</span>
+							/
+							<span>
+								{{ player.duration }}
+								{{ player.videoNote }}
+							</span>
+						</span>
+					</div>
+					<div class="player-footer-right">
+						<p id="volume-control">
+							<i
+								class="material-icons"
+								@click="toggleMute()"
+								:content="`${player.muted ? 'Unmute' : 'Mute'}`"
+								v-tippy
+								>{{
+									player.muted
+										? "volume_mute"
+										: player.volume >= 50
+										? "volume_up"
+										: "volume_down"
+								}}</i
+							>
+							<input
+								v-model="player.volume"
+								type="range"
+								min="0"
+								max="100"
+								class="volume-slider active"
+								@change="changeVolume()"
+								@input="changeVolume()"
+							/>
+						</p>
+					</div>
+				</div>
+			</div>
+
+			<div v-if="!loaded" class="vertical-padding">
+				<p>Video hasn't loaded yet</p>
+			</div>
+		</template>
+		<template #footer>
+			<button
+				class="button is-primary icon-with-button material-icons"
+				@click.prevent="
+					openModal({ modal: 'editSong', data: { song: video } })
+				"
+				content="Create/edit song from video"
+				v-tippy
+			>
+				music_note
+			</button>
+			<div class="right">
+				<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: 'remove'
+						})
+					"
+					content="Delete Video"
+					v-tippy
+				>
+					delete_forever
+				</button>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapActions, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import aw from "@/aw";
+import ws from "@/ws";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
+
+export default {
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
+	data() {
+		return {
+			loaded: false,
+			canvasWidth: 760,
+			activityWatchVideoDataInterval: null,
+			activityWatchVideoLastStatus: "",
+			activityWatchVideoLastStartDuration: ""
+		};
+	},
+	computed: {
+		...mapModalState("modals/viewYoutubeVideo/MODAL_UUID", {
+			videoId: state => state.videoId,
+			youtubeId: state => state.youtubeId,
+			video: state => state.video,
+			player: state => state.player
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		ws.onConnect(this.init);
+	},
+	beforeUnmount() {
+		this.stopVideo();
+		this.pauseVideo(true);
+		this.player.duration = "0.000";
+		this.player.currentTime = 0;
+		this.player.playerReady = false;
+		this.player.videoNote = "";
+		clearInterval(this.interval);
+		clearInterval(this.activityWatchVideoDataInterval);
+		this.loaded = false;
+
+		this.socket.dispatch(
+			"apis.leaveRoom",
+			`view-youtube-video.${this.videoId}`,
+			() => {}
+		);
+
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"viewYoutubeVideo",
+			this.modalUuid
+		]);
+	},
+	methods: {
+		init() {
+			this.loaded = false;
+			this.socket.dispatch(
+				"youtube.getVideo",
+				this.videoId || this.youtubeId,
+				true,
+				res => {
+					if (res.status === "success") {
+						const youtubeVideo = res.data;
+						this.viewYoutubeVideo(youtubeVideo);
+						this.loaded = true;
+
+						this.interval = setInterval(() => {
+							if (
+								this.video.duration !== -1 &&
+								this.player.paused === false &&
+								this.player.playerReady &&
+								(this.player.player.getCurrentTime() >
+									this.video.duration ||
+									(this.player.player.getCurrentTime() > 0 &&
+										this.player.player.getCurrentTime() >=
+											this.player.player.getDuration()))
+							) {
+								this.stopVideo();
+								this.pauseVideo(true);
+								this.drawCanvas();
+							}
+							if (
+								this.player.playerReady &&
+								this.player.player.getVideoData &&
+								this.player.player.getVideoData() &&
+								this.player.player.getVideoData().video_id ===
+									this.video.youtubeId
+							) {
+								const currentTime =
+									this.player.player.getCurrentTime();
+
+								if (currentTime !== undefined)
+									this.player.currentTime =
+										currentTime.toFixed(3);
+
+								if (
+									this.player.duration.indexOf(".000") !== -1
+								) {
+									const duration =
+										this.player.player.getDuration();
+
+									if (duration !== undefined) {
+										if (
+											`${this.player.duration}` ===
+											`${Number(
+												this.video.duration
+											).toFixed(3)}`
+										)
+											this.video.duration =
+												duration.toFixed(3);
+
+										this.player.duration =
+											duration.toFixed(3);
+										if (
+											this.player.duration.indexOf(
+												".000"
+											) !== -1
+										)
+											this.player.videoNote = "(~)";
+										else this.player.videoNote = "";
+
+										this.drawCanvas();
+									}
+								}
+							}
+
+							if (this.player.paused === false) this.drawCanvas();
+						}, 200);
+
+						this.activityWatchVideoDataInterval = setInterval(
+							() => {
+								this.sendActivityWatchVideoData();
+							},
+							1000
+						);
+
+						if (window.YT && window.YT.Player) {
+							this.player.player = new window.YT.Player(
+								`viewYoutubeVideoPlayer-${this.modalUuid}`,
+								{
+									height: 298,
+									width: 530,
+									videoId: null,
+									host: "https://www.youtube-nocookie.com",
+									playerVars: {
+										controls: 0,
+										iv_load_policy: 3,
+										rel: 0,
+										showinfo: 0,
+										autoplay: 0
+									},
+									events: {
+										onReady: () => {
+											let volume = parseFloat(
+												localStorage.getItem("volume")
+											);
+											volume =
+												typeof volume === "number"
+													? volume
+													: 20;
+											this.player.player.setVolume(
+												volume
+											);
+											if (volume > 0)
+												this.player.player.unMute();
+
+											this.player.playerReady = true;
+
+											if (this.video && this.video._id)
+												this.player.player.cueVideoById(
+													this.video.youtubeId
+												);
+
+											this.setPlaybackRate(null);
+
+											this.drawCanvas();
+										},
+										onStateChange: event => {
+											this.drawCanvas();
+
+											if (event.data === 1) {
+												this.player.paused = false;
+												const youtubeDuration =
+													this.player.player.getDuration();
+												const newYoutubeVideoDuration =
+													youtubeDuration.toFixed(3);
+
+												if (
+													this.player.duration.indexOf(
+														".000"
+													) !== -1 &&
+													`${this.player.duration}` !==
+														`${newYoutubeVideoDuration}`
+												) {
+													const songDurationNumber =
+														Number(
+															this.video.duration
+														);
+													const songDurationNumber2 =
+														Number(
+															this.video.duration
+														) + 1;
+													const songDurationNumber3 =
+														Number(
+															this.video.duration
+														) - 1;
+													const fixedSongDuration =
+														songDurationNumber.toFixed(
+															3
+														);
+													const fixedSongDuration2 =
+														songDurationNumber2.toFixed(
+															3
+														);
+													const fixedSongDuration3 =
+														songDurationNumber3.toFixed(
+															3
+														);
+
+													if (
+														`${this.player.duration}` ===
+															`${Number(
+																this.video
+																	.duration
+															).toFixed(3)}` &&
+														(fixedSongDuration ===
+															this.player
+																.duration ||
+															fixedSongDuration2 ===
+																this.player
+																	.duration ||
+															fixedSongDuration3 ===
+																this.player
+																	.duration)
+													)
+														this.video.duration =
+															newYoutubeVideoDuration;
+
+													this.player.duration =
+														newYoutubeVideoDuration;
+													if (
+														this.player.duration.indexOf(
+															".000"
+														) !== -1
+													)
+														this.player.videoNote =
+															"(~)";
+													else
+														this.player.videoNote =
+															"";
+												}
+
+												if (this.video.duration === -1)
+													this.video.duration =
+														this.player.duration;
+
+												if (
+													this.video.duration >
+													youtubeDuration + 1
+												) {
+													this.stopVideo();
+													this.pauseVideo(true);
+													return new Toast(
+														"Video can't play. Specified duration is bigger than the YouTube song duration."
+													);
+												}
+												if (this.video.duration <= 0) {
+													this.stopVideo();
+													this.pauseVideo(true);
+													return new Toast(
+														"Video can't play. Specified duration has to be more than 0 seconds."
+													);
+												}
+
+												this.setPlaybackRate(null);
+											} else if (event.data === 2) {
+												this.player.paused = true;
+											}
+
+											return false;
+										}
+									}
+								}
+							);
+						} else {
+							this.updatePlayer({
+								error: true,
+								errorMessage: "Player could not be loaded."
+							});
+						}
+
+						let volume = parseFloat(localStorage.getItem("volume"));
+						volume =
+							typeof volume === "number" && !Number.isNaN(volume)
+								? volume
+								: 20;
+						localStorage.setItem("volume", volume);
+						this.updatePlayer({ volume });
+
+						this.socket.dispatch(
+							"apis.joinRoom",
+							`view-youtube-video.${this.videoId}`
+						);
+
+						this.socket.on(
+							"event:youtubeVideo.removed",
+							() => {
+								new Toast("This YouTube video was removed.");
+								this.closeCurrentModal();
+							},
+							{ modalUuid: this.modalUuid }
+						);
+					} else {
+						new Toast("YouTube video with that ID not found");
+						this.closeCurrentModal();
+					}
+				}
+			);
+		},
+		remove() {
+			this.socket.dispatch("youtube.removeVideos", this.videoId, res => {
+				if (res.status === "success") {
+					new Toast("YouTube video successfully removed.");
+					this.closeCurrentModal();
+				} else {
+					new Toast("Youtube video with that ID not found.");
+				}
+			});
+		},
+		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]();
+			}
+		},
+		settings(type) {
+			switch (type) {
+				case "stop":
+					this.stopVideo();
+					this.pauseVideo(true);
+					break;
+				case "pause":
+					this.pauseVideo(true);
+					break;
+				case "play":
+					this.pauseVideo(false);
+					break;
+				case "skipToLast10Secs":
+					this.seekTo(this.song.duration - 10);
+					break;
+				default:
+					break;
+			}
+		},
+		play() {
+			if (
+				this.player.player.getVideoData().video_id !==
+				this.video.youtubeId
+			) {
+				this.video.duration = -1;
+				this.loadVideoById(this.video.youtubeId);
+			}
+			this.settings("play");
+		},
+		seekTo(position) {
+			this.settings("play");
+			this.player.player.seekTo(position);
+		},
+		changeVolume() {
+			const { volume } = this.player;
+			localStorage.setItem("volume", volume);
+			this.player.player.setVolume(volume);
+			if (volume > 0) {
+				this.player.player.unMute();
+				this.player.muted = false;
+			}
+		},
+		toggleMute() {
+			const previousVolume = parseFloat(localStorage.getItem("volume"));
+			const volume =
+				this.player.player.getVolume() <= 0 ? previousVolume : 0;
+			this.player.muted = !this.player.muted;
+			this.volumeSliderValue = volume;
+			this.player.player.setVolume(volume);
+			if (!this.player.muted) localStorage.setItem("volume", volume);
+		},
+		increaseVolume() {
+			const previousVolume = parseFloat(localStorage.getItem("volume"));
+			let volume = previousVolume + 5;
+			this.player.muted = false;
+			if (volume > 100) volume = 100;
+			this.player.volume = volume;
+			this.player.player.setVolume(volume);
+			localStorage.setItem("volume", volume);
+		},
+		drawCanvas() {
+			if (!this.loaded) return;
+			const canvasElement =
+				this.$refs[`durationCanvas-${this.modalUuid}`];
+			if (!canvasElement) return;
+			const ctx = canvasElement.getContext("2d");
+
+			const videoDuration = Number(this.player.duration);
+
+			const duration = Number(this.video.duration);
+			const afterDuration = videoDuration - duration;
+
+			this.canvasWidth = Math.min(document.body.clientWidth - 40, 760);
+			const width = this.canvasWidth;
+
+			const currentTime =
+				this.player.player && this.player.player.getCurrentTime
+					? this.player.player.getCurrentTime()
+					: 0;
+
+			const widthDuration = (duration / videoDuration) * width;
+			const widthAfterDuration = (afterDuration / videoDuration) * width;
+
+			const widthCurrentTime = (currentTime / videoDuration) * width;
+
+			const durationColor = "#03A9F4";
+			const afterDurationColor = "#41E841";
+			const currentDurationColor = "#3b25e8";
+
+			ctx.fillStyle = durationColor;
+			ctx.fillRect(0, 0, widthDuration, 20);
+			ctx.fillStyle = afterDurationColor;
+			ctx.fillRect(widthDuration, 0, widthAfterDuration, 20);
+
+			ctx.fillStyle = currentDurationColor;
+			ctx.fillRect(widthCurrentTime, 0, 1, 20);
+		},
+		setTrackPosition(event) {
+			this.seekTo(
+				Number(
+					Number(this.player.player.getDuration()) *
+						((event.pageX -
+							event.target.getBoundingClientRect().left) /
+							this.canvasWidth)
+				)
+			);
+		},
+		sendActivityWatchVideoData() {
+			if (!this.player.paused) {
+				if (this.activityWatchVideoLastStatus !== "playing") {
+					this.activityWatchVideoLastStatus = "playing";
+					this.activityWatchVideoLastStartDuration = Math.floor(
+						parseFloat(this.player.currentTime)
+					);
+				}
+
+				const videoData = {
+					title: this.video.title,
+					artists: this.video.author,
+					youtubeId: this.video.youtubeId,
+					muted: this.player.muted,
+					volume: this.player.volume,
+					startedDuration:
+						this.activityWatchVideoLastStartDuration <= 0
+							? 0
+							: this.activityWatchVideoLastStartDuration,
+					source: `viewYoutubeVideo#${this.video.youtubeId}`,
+					hostname: window.location.hostname
+				};
+
+				aw.sendVideoData(videoData);
+			} else {
+				this.activityWatchVideoLastStatus = "not_playing";
+			}
+		},
+		...mapModalActions("modals/viewYoutubeVideo/MODAL_UUID", [
+			"updatePlayer",
+			"stopVideo",
+			"loadVideoById",
+			"pauseVideo",
+			"setPlaybackRate",
+			"viewYoutubeVideo"
+		]),
+		...mapActions("modalVisibility", ["openModal", "closeCurrentModal"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.night-mode {
+	.player-section,
+	.top-section {
+		background-color: var(--dark-grey-3) !important;
+		border: 0 !important;
+
+		.duration-canvas {
+			background-color: var(--dark-grey-2) !important;
+		}
+	}
+}
+
+.top-section {
+	display: flex;
+	margin: 0 auto;
+	padding: 10px;
+	border: 1px solid var(--light-grey-3);
+	border-radius: @border-radius;
+
+	.left-section {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+
+		p {
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			overflow: hidden;
+
+			&:first-child {
+				margin-top: auto;
+			}
+
+			&:last-child {
+				margin-bottom: auto;
+			}
+
+			& > span,
+			& > a {
+				margin-left: 5px;
+			}
+		}
+	}
+
+	:deep(.right-section .thumbnail-preview) {
+		width: 120px;
+		height: 120px;
+		margin: 0;
+	}
+
+	@media (max-width: 600px) {
+		flex-direction: column-reverse;
+
+		.left-section {
+			margin-top: 10px;
+		}
+	}
+}
+
+.player-section {
+	display: flex;
+	flex-direction: column;
+	margin: 10px auto 0 auto;
+	border: 1px solid var(--light-grey-3);
+	border-radius: @border-radius;
+	overflow: hidden;
+
+	.player-container {
+		position: relative;
+		padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
+		height: 0;
+		overflow: hidden;
+
+		:deep([id^="viewYoutubeVideoPlayer"]) {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			min-height: 200px;
+		}
+	}
+
+	.duration-canvas {
+		background-color: var(--light-grey-2);
+	}
+
+	.player-error {
+		display: flex;
+		height: 428px;
+		align-items: center;
+
+		* {
+			margin: 0;
+			flex: 1;
+			font-size: 30px;
+			text-align: center;
+		}
+	}
+
+	.player-footer {
+		display: flex;
+		justify-content: space-between;
+		height: 54px;
+		padding-left: 10px;
+		padding-right: 10px;
+
+		> * {
+			width: 33.3%;
+			display: flex;
+			align-items: center;
+		}
+
+		.player-footer-left {
+			flex: 1;
+
+			& > .button:not(:first-child) {
+				margin-left: 5px;
+			}
+
+			:deep(& > .playerRateDropdown) {
+				margin-left: 5px;
+				margin-bottom: unset !important;
+
+				.control.has-addons {
+					margin-bottom: unset !important;
+
+					& > .button {
+						font-size: 24px;
+					}
+				}
+			}
+
+			:deep(.tippy-box[data-theme~="dropdown"]) {
+				max-width: 100px !important;
+
+				.nav-dropdown-items .nav-item {
+					justify-content: center !important;
+					border-radius: @border-radius !important;
+
+					&.active {
+						background-color: var(--primary-color);
+						color: var(--white);
+					}
+				}
+			}
+		}
+
+		.player-footer-center {
+			justify-content: center;
+			align-items: center;
+			flex: 2;
+			font-size: 18px;
+			font-weight: 400;
+			width: 200px;
+			margin: 0 5px;
+
+			img {
+				height: 21px;
+				margin-right: 12px;
+				filter: invert(26%) sepia(54%) saturate(6317%) hue-rotate(2deg)
+					brightness(92%) contrast(115%);
+			}
+		}
+
+		.player-footer-right {
+			justify-content: right;
+			flex: 1;
+
+			#volume-control {
+				margin: 3px;
+				margin-top: 0;
+				display: flex;
+				align-items: center;
+				cursor: pointer;
+
+				.volume-slider {
+					width: 100%;
+					padding: 0 15px;
+					background: transparent;
+					min-width: 100px;
+				}
+
+				input[type="range"] {
+					-webkit-appearance: none;
+					margin: 7.3px 0;
+				}
+
+				input[type="range"]:focus {
+					outline: none;
+				}
+
+				input[type="range"]::-webkit-slider-runnable-track {
+					width: 100%;
+					height: 5.2px;
+					cursor: pointer;
+					box-shadow: 0;
+					background: var(--light-grey-3);
+					border-radius: @border-radius;
+					border: 0;
+				}
+
+				input[type="range"]::-webkit-slider-thumb {
+					box-shadow: 0;
+					border: 0;
+					height: 19px;
+					width: 19px;
+					border-radius: 100%;
+					background: var(--primary-color);
+					cursor: pointer;
+					-webkit-appearance: none;
+					margin-top: -6.5px;
+				}
+
+				input[type="range"]::-moz-range-track {
+					width: 100%;
+					height: 5.2px;
+					cursor: pointer;
+					box-shadow: 0;
+					background: var(--light-grey-3);
+					border-radius: @border-radius;
+					border: 0;
+				}
+
+				input[type="range"]::-moz-range-thumb {
+					box-shadow: 0;
+					border: 0;
+					height: 19px;
+					width: 19px;
+					border-radius: 100%;
+					background: var(--primary-color);
+					cursor: pointer;
+					-webkit-appearance: none;
+					margin-top: -6.5px;
+				}
+				input[type="range"]::-ms-track {
+					width: 100%;
+					height: 5.2px;
+					cursor: pointer;
+					box-shadow: 0;
+					background: var(--light-grey-3);
+					border-radius: @border-radius;
+				}
+
+				input[type="range"]::-ms-fill-lower {
+					background: var(--light-grey-3);
+					border: 0;
+					border-radius: 0;
+					box-shadow: 0;
+				}
+
+				input[type="range"]::-ms-fill-upper {
+					background: var(--light-grey-3);
+					border: 0;
+					border-radius: 0;
+					box-shadow: 0;
+				}
+
+				input[type="range"]::-ms-thumb {
+					box-shadow: 0;
+					border: 0;
+					height: 15px;
+					width: 15px;
+					border-radius: 100%;
+					background: var(--primary-color);
+					cursor: pointer;
+					-webkit-appearance: none;
+					margin-top: 1.5px;
+				}
+			}
+		}
+	}
+}
+</style>

+ 2 - 3
frontend/src/components/modals/WhatIsNew.vue

@@ -9,10 +9,9 @@
 		<template #footer>
 		<template #footer>
 			<span v-if="news.createdBy">
 			<span v-if="news.createdBy">
 				By
 				By
-				<user-id-to-username
+				<user-link
 					:user-id="news.createdBy"
 					:user-id="news.createdBy"
-					:alt="news.createdBy"
-					:link="true" /></span
+					:alt="news.createdBy" /></span
 			>&nbsp;<span :title="new Date(news.createdAt)">
 			>&nbsp;<span :title="new Date(news.createdAt)">
 				{{
 				{{
 					formatDistance(news.createdAt, new Date(), {
 					formatDistance(news.createdAt, new Date(), {

+ 16 - 3
frontend/src/main.js

@@ -167,7 +167,11 @@ const router = createRouter({
 			children: [
 			children: [
 				{
 				{
 					path: "songs",
 					path: "songs",
-					component: () => import("@/pages/Admin/Songs.vue")
+					component: () => import("@/pages/Admin/Songs/index.vue")
+				},
+				{
+					path: "songs/import",
+					component: () => import("@/pages/Admin/Songs/Import.vue")
 				},
 				},
 				{
 				{
 					path: "reports",
 					path: "reports",
@@ -191,8 +195,9 @@ const router = createRouter({
 						import("@/pages/Admin/Users/DataRequests.vue")
 						import("@/pages/Admin/Users/DataRequests.vue")
 				},
 				},
 				{
 				{
-					path: "punishments",
-					component: () => import("@/pages/Admin/Punishments.vue")
+					path: "users/punishments",
+					component: () =>
+						import("@/pages/Admin/Users/Punishments.vue")
 				},
 				},
 				{
 				{
 					path: "news",
 					path: "news",
@@ -201,6 +206,14 @@ const router = createRouter({
 				{
 				{
 					path: "statistics",
 					path: "statistics",
 					component: () => import("@/pages/Admin/Statistics.vue")
 					component: () => import("@/pages/Admin/Statistics.vue")
+				},
+				{
+					path: "youtube",
+					component: () => import("@/pages/Admin/YouTube/index.vue")
+				},
+				{
+					path: "youtube/videos",
+					component: () => import("@/pages/Admin/YouTube/Videos.vue")
 				}
 				}
 			],
 			],
 			meta: {
 			meta: {

+ 156 - 0
frontend/src/mixins/DragBox.vue

@@ -0,0 +1,156 @@
+<script>
+export default {
+	data() {
+		return {
+			dragBox: {
+				top: 0,
+				left: 0,
+				pos1: 0,
+				pos2: 0,
+				pos3: 0,
+				pos4: 0,
+				width: 400,
+				height: 50,
+				initial: {
+					top: 0,
+					left: 0
+				},
+				latest: {
+					top: null,
+					left: null
+				},
+				debounceTimeout: null,
+				lastTappedDate: 0
+			}
+		};
+	},
+	mounted() {
+		this.resetBoxPosition(true);
+
+		this.$nextTick(() => {
+			this.onWindowResizeDragBox();
+			window.addEventListener("resize", this.onWindowResizeDragBox);
+		});
+	},
+	unmounted() {
+		window.removeEventListener("resize", this.onWindowResizeDragBox);
+		if (this.dragBox.debounceTimeout)
+			clearTimeout(this.dragBox.debounceTimeout);
+	},
+	methods: {
+		setInitialBox(initial, reset) {
+			this.dragBox.initial = initial || this.dragBox.initial;
+			if (reset)
+				this.dragBox = { ...this.dragBox, ...this.dragBox.initial };
+		},
+		onDragBox(e) {
+			const e1 = e || window.event;
+			const e1IsTouch = e1.type === "touchstart";
+			e1.preventDefault();
+
+			if (e1IsTouch) {
+				// Handle double click from touch (if this method is twice in a row within one second)
+				if (Date.now() - this.dragBox.lastTappedDate <= 1000) {
+					this.resetBoxPosition();
+					this.dragBox.lastTappedDate = 0;
+					return;
+				}
+				this.dragBox.lastTappedDate = Date.now();
+			}
+
+			this.dragBox.pos3 = e1IsTouch
+				? e1.changedTouches[0].clientX
+				: e1.clientX;
+			this.dragBox.pos4 = e1IsTouch
+				? e1.changedTouches[0].clientY
+				: e1.clientY;
+
+			document.onmousemove = document.ontouchmove = e => {
+				const e2 = e || window.event;
+				const e2IsTouch = e2.type === "touchmove";
+				if (!e2IsTouch) e2.preventDefault();
+
+				// Get the clientX and clientY
+				const e2ClientX = e2IsTouch
+					? e2.changedTouches[0].clientX
+					: e2.clientX;
+				const e2ClientY = e2IsTouch
+					? e2.changedTouches[0].clientY
+					: e2.clientY;
+
+				// calculate the new cursor position:
+				this.dragBox.pos1 = this.dragBox.pos3 - e2ClientX;
+				this.dragBox.pos2 = this.dragBox.pos4 - e2ClientY;
+				this.dragBox.pos3 = e2ClientX;
+				this.dragBox.pos4 = e2ClientY;
+				// set the element's new position:
+				this.dragBox.top -= this.dragBox.pos2;
+				this.dragBox.left -= this.dragBox.pos1;
+
+				if (
+					this.dragBox.top >
+					document.body.clientHeight - this.dragBox.height
+				)
+					this.dragBox.top =
+						document.body.clientHeight - this.dragBox.height;
+				if (this.dragBox.top < 0) this.dragBox.top = 0;
+				if (
+					this.dragBox.left >
+					document.body.clientWidth - this.dragBox.width
+				)
+					this.dragBox.left =
+						document.body.clientWidth - this.dragBox.width;
+				if (this.dragBox.left < 0) this.dragBox.left = 0;
+			};
+
+			document.onmouseup = document.ontouchend = () => {
+				document.onmouseup = null;
+				document.ontouchend = null;
+				document.onmousemove = null;
+				document.ontouchmove = null;
+
+				if (typeof this.onDragBoxUpdate === "function")
+					this.onDragBoxUpdate();
+			};
+		},
+		resetBoxPosition(preventUpdate) {
+			this.setInitialBox(null, true);
+			this.dragBox.latest.top = this.dragBox.top;
+			this.dragBox.latest.left = this.dragBox.left;
+			if (!preventUpdate && typeof this.onDragBoxUpdate === "function")
+				this.onDragBoxUpdate();
+		},
+		onWindowResizeDragBox() {
+			if (this.dragBox.debounceTimeout)
+				clearTimeout(this.dragBox.debounceTimeout);
+
+			this.dragBox.debounceTimeout = setTimeout(() => {
+				if (
+					this.dragBox.top === this.dragBox.latest.top &&
+					this.dragBox.left === this.dragBox.latest.left
+				)
+					this.resetBoxPosition();
+				else {
+					if (
+						this.dragBox.top >
+						document.body.clientHeight - this.dragBox.height
+					)
+						this.dragBox.top =
+							document.body.clientHeight - this.dragBox.height;
+					if (this.dragBox.top < 0) this.dragBox.top = 0;
+					if (
+						this.dragBox.left >
+						document.body.clientWidth - this.dragBox.width
+					)
+						this.dragBox.left =
+							document.body.clientWidth - this.dragBox.width;
+					if (this.dragBox.left < 0) this.dragBox.left = 0;
+
+					if (typeof this.onDragBoxUpdate === "function")
+						this.onDragBoxUpdate();
+				}
+			}, 50);
+		}
+	}
+};
+</script>

+ 68 - 65
frontend/src/pages/Admin/News.vue

@@ -1,7 +1,11 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab container">
 		<page-metadata title="Admin | News" />
 		<page-metadata title="Admin | News" />
-		<div class="container">
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>News</h1>
+				<p>Create and update news items</p>
+			</div>
 			<div class="button-row">
 			<div class="button-row">
 				<button
 				<button
 					class="is-primary button"
 					class="is-primary button"
@@ -15,73 +19,72 @@
 					Create News Item
 					Create News Item
 				</button>
 				</button>
 			</div>
 			</div>
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="news.getData"
-				name="admin-news"
-				:max-width="1200"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="news.getData"
+			name="admin-news"
+			:max-width="1200"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'editNews',
+								data: { newsId: slotProps.item._id }
+							})
+						"
+						content="Edit News"
+						v-tippy
+					>
+						edit
+					</button>
+					<quick-confirm
+						@confirm="remove(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+					>
 						<button
 						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="
-								openModal({
-									modal: 'editNews',
-									data: { newsId: slotProps.item._id }
-								})
-							"
-							content="Edit News"
+							class="button is-danger icon-with-button material-icons"
+							content="Remove News"
 							v-tippy
 							v-tippy
 						>
 						>
-							edit
+							delete_forever
 						</button>
 						</button>
-						<quick-confirm
-							@confirm="remove(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-						>
-							<button
-								class="button is-danger icon-with-button material-icons"
-								content="Remove News"
-								v-tippy
-							>
-								delete_forever
-							</button>
-						</quick-confirm>
-					</div>
-				</template>
-				<template #column-status="slotProps">
-					<span :title="slotProps.item.status">{{
-						slotProps.item.status
-					}}</span>
-				</template>
-				<template #column-showToNewUsers="slotProps">
-					<span :title="slotProps.item.showToNewUsers">{{
-						slotProps.item.showToNewUsers
-					}}</span>
-				</template>
-				<template #column-title="slotProps">
-					<span :title="slotProps.item.title">{{
-						slotProps.item.title
-					}}</span>
-				</template>
-				<template #column-createdBy="slotProps">
-					<user-id-to-username
-						:user-id="slotProps.item.createdBy"
-						:alt="slotProps.item.createdBy"
-						:link="true"
-					/>
-				</template>
-				<template #column-markdown="slotProps">
-					<span :title="slotProps.item.markdown">{{
-						slotProps.item.markdown
-					}}</span>
-				</template>
-			</advanced-table>
-		</div>
+					</quick-confirm>
+				</div>
+			</template>
+			<template #column-status="slotProps">
+				<span :title="slotProps.item.status">{{
+					slotProps.item.status
+				}}</span>
+			</template>
+			<template #column-showToNewUsers="slotProps">
+				<span :title="slotProps.item.showToNewUsers">{{
+					slotProps.item.showToNewUsers
+				}}</span>
+			</template>
+			<template #column-title="slotProps">
+				<span :title="slotProps.item.title">{{
+					slotProps.item.title
+				}}</span>
+			</template>
+			<template #column-createdBy="slotProps">
+				<user-link
+					:user-id="slotProps.item.createdBy"
+					:alt="slotProps.item.createdBy"
+				/>
+			</template>
+			<template #column-markdown="slotProps">
+				<span :title="slotProps.item.markdown">{{
+					slotProps.item.markdown
+				}}</span>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 

+ 77 - 79
frontend/src/pages/Admin/Playlists.vue

@@ -1,88 +1,86 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab">
 		<page-metadata title="Admin | Playlists" />
 		<page-metadata title="Admin | Playlists" />
-		<div class="admin-tab">
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Playlist</h1>
+				<p>Manage playlists</p>
+			</div>
 			<div class="button-row">
 			<div class="button-row">
 				<run-job-dropdown :jobs="jobs" />
 				<run-job-dropdown :jobs="jobs" />
 			</div>
 			</div>
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="playlists.getData"
-				name="admin-playlists"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="
-								openModal({
-									modal: 'editPlaylist',
-									data: { playlistId: slotProps.item._id }
-								})
-							"
-							:disabled="slotProps.item.removed"
-							content="Edit Playlist"
-							v-tippy
-						>
-							edit
-						</button>
-					</div>
-				</template>
-				<template #column-displayName="slotProps">
-					<span :title="slotProps.item.displayName">{{
-						slotProps.item.displayName
-					}}</span>
-				</template>
-				<template #column-type="slotProps">
-					<span :title="slotProps.item.type">{{
-						slotProps.item.type
-					}}</span>
-				</template>
-				<template #column-privacy="slotProps">
-					<span :title="slotProps.item.privacy">{{
-						slotProps.item.privacy
-					}}</span>
-				</template>
-				<template #column-songsCount="slotProps">
-					<span :title="slotProps.item.songsCount">{{
-						slotProps.item.songsCount
-					}}</span>
-				</template>
-				<template #column-totalLength="slotProps">
-					<span :title="formatTimeLong(slotProps.item.totalLength)">{{
-						formatTimeLong(slotProps.item.totalLength)
-					}}</span>
-				</template>
-				<template #column-createdBy="slotProps">
-					<span v-if="slotProps.item.createdBy === 'Musare'"
-						>Musare</span
-					>
-					<user-id-to-username
-						v-else
-						:user-id="slotProps.item.createdBy"
-						:link="true"
-					/>
-				</template>
-				<template #column-createdAt="slotProps">
-					<span :title="new Date(slotProps.item.createdAt)">{{
-						getDateFormatted(slotProps.item.createdAt)
-					}}</span>
-				</template>
-				<template #column-createdFor="slotProps">
-					<span :title="slotProps.item.createdFor">{{
-						slotProps.item.createdFor
-					}}</span>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-			</advanced-table>
 		</div>
 		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="playlists.getData"
+			name="admin-playlists"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'editPlaylist',
+								data: { playlistId: slotProps.item._id }
+							})
+						"
+						:disabled="slotProps.item.removed"
+						content="Edit Playlist"
+						v-tippy
+					>
+						edit
+					</button>
+				</div>
+			</template>
+			<template #column-displayName="slotProps">
+				<span :title="slotProps.item.displayName">{{
+					slotProps.item.displayName
+				}}</span>
+			</template>
+			<template #column-type="slotProps">
+				<span :title="slotProps.item.type">{{
+					slotProps.item.type
+				}}</span>
+			</template>
+			<template #column-privacy="slotProps">
+				<span :title="slotProps.item.privacy">{{
+					slotProps.item.privacy
+				}}</span>
+			</template>
+			<template #column-songsCount="slotProps">
+				<span :title="slotProps.item.songsCount">{{
+					slotProps.item.songsCount
+				}}</span>
+			</template>
+			<template #column-totalLength="slotProps">
+				<span :title="formatTimeLong(slotProps.item.totalLength)">{{
+					formatTimeLong(slotProps.item.totalLength)
+				}}</span>
+			</template>
+			<template #column-createdBy="slotProps">
+				<span v-if="slotProps.item.createdBy === 'Musare'">Musare</span>
+				<user-link v-else :user-id="slotProps.item.createdBy" />
+			</template>
+			<template #column-createdAt="slotProps">
+				<span :title="new Date(slotProps.item.createdAt)">{{
+					getDateFormatted(slotProps.item.createdAt)
+				}}</span>
+			</template>
+			<template #column-createdFor="slotProps">
+				<span :title="slotProps.item.createdFor">{{
+					slotProps.item.createdFor
+				}}</span>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 

+ 101 - 103
frontend/src/pages/Admin/Reports.vue

@@ -1,111 +1,109 @@
 <template>
 <template>
-	<div>
-		<page-metadata title="Admin | Songs | Reports" />
-		<div class="container">
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="reports.getData"
-				name="admin-reports"
-				:max-width="1200"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="
-								openModal({
-									modal: 'viewReport',
-									data: { reportId: slotProps.item._id }
-								})
-							"
-							:disabled="slotProps.item.removed"
-							content="View Report"
-							v-tippy
-						>
-							open_in_full
-						</button>
-						<button
-							v-if="slotProps.item.resolved"
-							class="button is-danger material-icons icon-with-button"
-							@click="resolve(slotProps.item._id, false)"
-							:disabled="slotProps.item.removed"
-							content="Unresolve Report"
-							v-tippy
-						>
-							remove_done
-						</button>
-						<button
-							v-else
-							class="button is-success material-icons icon-with-button"
-							@click="resolve(slotProps.item._id, true)"
-							:disabled="slotProps.item.removed"
-							content="Resolve Report"
-							v-tippy
-						>
-							done_all
-						</button>
-					</div>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-				<template #column-songId="slotProps">
-					<span :title="slotProps.item.song._id">{{
-						slotProps.item.song._id
-					}}</span>
-				</template>
-				<template #column-songYoutubeId="slotProps">
-					<a
-						:href="
-							'https://www.youtube.com/watch?v=' +
-							`${slotProps.item.song.youtubeId}`
-						"
-						target="_blank"
-					>
-						{{ slotProps.item.song.youtubeId }}
-					</a>
-				</template>
-				<template #column-resolved="slotProps">
-					<span :title="slotProps.item.resolved">{{
-						slotProps.item.resolved
-					}}</span>
-				</template>
-				<template #column-categories="slotProps">
-					<span
-						:title="
-							slotProps.item.issues
-								.map(issue => issue.category)
-								.join(', ')
+	<div class="admin-tab container">
+		<page-metadata title="Admin | Reports" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Reports</h1>
+				<p>Manage song reports</p>
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="reports.getData"
+			name="admin-reports"
+			:max-width="1200"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'viewReport',
+								data: { reportId: slotProps.item._id }
+							})
 						"
 						"
-						>{{
-							slotProps.item.issues
-								.map(issue => issue.category)
-								.join(", ")
-						}}</span
+						:disabled="slotProps.item.removed"
+						content="View Report"
+						v-tippy
 					>
 					>
-				</template>
-				<template #column-createdBy="slotProps">
-					<span v-if="slotProps.item.createdBy === 'Musare'"
-						>Musare</span
+						open_in_full
+					</button>
+					<button
+						v-if="slotProps.item.resolved"
+						class="button is-danger material-icons icon-with-button"
+						@click="resolve(slotProps.item._id, false)"
+						:disabled="slotProps.item.removed"
+						content="Unresolve Report"
+						v-tippy
 					>
 					>
-					<user-id-to-username
+						remove_done
+					</button>
+					<button
 						v-else
 						v-else
-						:user-id="slotProps.item.createdBy"
-						:link="true"
-					/>
-				</template>
-				<template #column-createdAt="slotProps">
-					<span :title="new Date(slotProps.item.createdAt)">{{
-						getDateFormatted(slotProps.item.createdAt)
-					}}</span>
-				</template>
-			</advanced-table>
-		</div>
+						class="button is-success material-icons icon-with-button"
+						@click="resolve(slotProps.item._id, true)"
+						:disabled="slotProps.item.removed"
+						content="Resolve Report"
+						v-tippy
+					>
+						done_all
+					</button>
+				</div>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
+			<template #column-songId="slotProps">
+				<span :title="slotProps.item.song._id">{{
+					slotProps.item.song._id
+				}}</span>
+			</template>
+			<template #column-songYoutubeId="slotProps">
+				<a
+					:href="
+						'https://www.youtube.com/watch?v=' +
+						`${slotProps.item.song.youtubeId}`
+					"
+					target="_blank"
+				>
+					{{ slotProps.item.song.youtubeId }}
+				</a>
+			</template>
+			<template #column-resolved="slotProps">
+				<span :title="slotProps.item.resolved">{{
+					slotProps.item.resolved
+				}}</span>
+			</template>
+			<template #column-categories="slotProps">
+				<span
+					:title="
+						slotProps.item.issues
+							.map(issue => issue.category)
+							.join(', ')
+					"
+					>{{
+						slotProps.item.issues
+							.map(issue => issue.category)
+							.join(", ")
+					}}</span
+				>
+			</template>
+			<template #column-createdBy="slotProps">
+				<span v-if="slotProps.item.createdBy === 'Musare'">Musare</span>
+				<user-link v-else :user-id="slotProps.item.createdBy" />
+			</template>
+			<template #column-createdAt="slotProps">
+				<span :title="new Date(slotProps.item.createdAt)">{{
+					getDateFormatted(slotProps.item.createdAt)
+				}}</span>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 

+ 719 - 0
frontend/src/pages/Admin/Songs/Import.vue

@@ -0,0 +1,719 @@
+<template>
+	<div>
+		<page-metadata title="Admin | Songs | Import" />
+		<div class="admin-tab import-tab">
+			<div class="card">
+				<h1>Import Songs</h1>
+				<p>Import songs from YouTube playlists or channels</p>
+			</div>
+
+			<div class="section-row">
+				<div class="card left-section">
+					<h4>Start New Import</h4>
+					<hr class="section-horizontal-rule" />
+
+					<div v-if="false && createImport.stage === 1" class="stage">
+						<label class="label">Import Method</label>
+						<div class="control is-expanded select">
+							<select v-model="createImport.importMethod">
+								<option value="youtube">YouTube</option>
+							</select>
+						</div>
+
+						<div class="control is-expanded">
+							<button
+								class="button is-primary"
+								@click.prevent="submitCreateImport(1)"
+							>
+								<i class="material-icons">navigate_next</i>
+								Next
+							</button>
+						</div>
+					</div>
+
+					<div
+						v-else-if="
+							createImport.stage === 2 &&
+							createImport.importMethod === 'youtube'
+						"
+						class="stage"
+					>
+						<label class="label"
+							>YouTube URL
+							<info-icon
+								tooltip="YouTube playlist or channel URLs may be provided"
+							/>
+						</label>
+						<div class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="YouTube Playlist or Channel URL"
+								v-model="createImport.youtubeUrl"
+							/>
+						</div>
+
+						<div class="control is-expanded checkbox-control">
+							<label class="switch">
+								<input
+									type="checkbox"
+									id="import-music-only"
+									v-model="createImport.isImportingOnlyMusic"
+								/>
+								<span class="slider round"></span>
+							</label>
+
+							<label class="label" for="import-music-only">
+								Import Music Only
+								<info-icon
+									tooltip="Only import videos from YouTube identified as music"
+									@click.prevent
+								/>
+							</label>
+						</div>
+
+						<div class="control is-expanded">
+							<button
+								class="control is-expanded button is-primary"
+								@click.prevent="submitCreateImport(2)"
+							>
+								<i class="material-icons icon-with-button"
+									>publish</i
+								>
+								Import
+							</button>
+						</div>
+					</div>
+
+					<div v-if="createImport.stage === 3" class="stage">
+						<p class="has-text-centered import-started">
+							Import Started
+						</p>
+
+						<div class="control is-expanded">
+							<button
+								class="button is-info"
+								@click.prevent="submitCreateImport(3)"
+							>
+								<i class="material-icons icon-with-button"
+									>restart_alt</i
+								>
+								Start Again
+							</button>
+						</div>
+					</div>
+				</div>
+				<div class="card right-section">
+					<h4>Manage Imports</h4>
+					<hr class="section-horizontal-rule" />
+					<advanced-table
+						:column-default="columnDefault"
+						:columns="columns"
+						:filters="filters"
+						:events="events"
+						data-action="media.getImportJobs"
+						name="admin-songs-import"
+						:max-width="1060"
+					>
+						<template #column-options="slotProps">
+							<div class="row-options">
+								<button
+									class="button is-primary icon-with-button material-icons"
+									@click="openAdvancedTable(slotProps.item)"
+									:disabled="
+										slotProps.item.removed ||
+										slotProps.item.status !== 'success'
+									"
+									content="Manage imported videos"
+									v-tippy
+								>
+									table_view
+								</button>
+								<button
+									class="button is-primary icon-with-button material-icons"
+									@click="
+										editSongs(
+											slotProps.item.response
+												.successfulVideoIds
+										)
+									"
+									:disabled="
+										slotProps.item.removed ||
+										slotProps.item.status !== 'success'
+									"
+									content="Create/edit song from videos"
+									v-tippy
+								>
+									music_note
+								</button>
+								<button
+									class="button icon-with-button material-icons import-album-icon"
+									@click="
+										importAlbum(
+											slotProps.item.response
+												.successfulVideoIds
+										)
+									"
+									:disabled="
+										slotProps.item.removed ||
+										slotProps.item.status !== 'success'
+									"
+									content="Import album from videos"
+									v-tippy
+								>
+									album
+								</button>
+								<button
+									class="button is-danger icon-with-button material-icons"
+									@click.prevent="
+										confirmAction({
+											message:
+												'Note: Removing an import will not remove any videos or songs.',
+											action: 'removeImportJob',
+											params: slotProps.item._id
+										})
+									"
+									:disabled="
+										slotProps.item.removed ||
+										slotProps.item.status === 'in-progress'
+									"
+									content="Remove Import"
+									v-tippy
+								>
+									delete_forever
+								</button>
+							</div>
+						</template>
+						<template #column-type="slotProps">
+							<span :title="slotProps.item.type">{{
+								slotProps.item.type
+							}}</span>
+						</template>
+						<template #column-requestedBy="slotProps">
+							<user-link :user-id="slotProps.item.requestedBy" />
+						</template>
+						<template #column-requestedAt="slotProps">
+							<span
+								:title="new Date(slotProps.item.requestedAt)"
+								>{{
+									getDateFormatted(slotProps.item.requestedAt)
+								}}</span
+							>
+						</template>
+						<template #column-successful="slotProps">
+							<span :title="slotProps.item.response.successful">{{
+								slotProps.item.response.successful
+							}}</span>
+						</template>
+						<template #column-alreadyInDatabase="slotProps">
+							<span
+								:title="
+									slotProps.item.response.alreadyInDatabase
+								"
+								>{{
+									slotProps.item.response.alreadyInDatabase
+								}}</span
+							>
+						</template>
+						<template #column-failed="slotProps">
+							<span :title="slotProps.item.response.failed">{{
+								slotProps.item.response.failed
+							}}</span>
+						</template>
+						<template #column-status="slotProps">
+							<span :title="slotProps.item.status">{{
+								slotProps.item.status
+							}}</span>
+						</template>
+						<template #column-url="slotProps">
+							<a
+								:href="slotProps.item.query.url"
+								target="_blank"
+								>{{ slotProps.item.query.url }}</a
+							>
+						</template>
+						<template #column-musicOnly="slotProps">
+							<span :title="slotProps.item.query.musicOnly">{{
+								slotProps.item.query.musicOnly
+							}}</span>
+						</template>
+						<template #column-_id="slotProps">
+							<span :title="slotProps.item._id">{{
+								slotProps.item._id
+							}}</span>
+						</template>
+					</advanced-table>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+
+export default {
+	components: {
+		AdvancedTable
+	},
+	data() {
+		return {
+			createImport: {
+				stage: 2,
+				importMethod: "youtube",
+				youtubeUrl:
+					"https://www.youtube.com/playlist?list=PL3-sRm8xAzY9gpXTMGVHJWy_FMD67NBed",
+				isImportingOnlyMusic: false
+			},
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 200,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id", "status"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 160,
+					defaultWidth: 160
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					properties: ["type"],
+					sortProperty: "type",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "requestedBy",
+					displayName: "Requested By",
+					properties: ["requestedBy"],
+					sortProperty: "requestedBy"
+				},
+				{
+					name: "requestedAt",
+					displayName: "Requested At",
+					properties: ["requestedAt"],
+					sortProperty: "requestedAt"
+				},
+				{
+					name: "successful",
+					displayName: "Successful",
+					properties: ["response"],
+					sortProperty: "response.successful",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "alreadyInDatabase",
+					displayName: "Existing",
+					properties: ["response"],
+					sortProperty: "response.alreadyInDatabase",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "failed",
+					displayName: "Failed",
+					properties: ["response"],
+					sortProperty: "response.failed",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					properties: ["status"],
+					sortProperty: "status",
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "url",
+					displayName: "URL",
+					properties: ["query.url"],
+					sortProperty: "query.url"
+				},
+				{
+					name: "musicOnly",
+					displayName: "Music Only",
+					properties: ["query.musicOnly"],
+					sortProperty: "query.musicOnly",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "_id",
+					displayName: "Import ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 215,
+					defaultWidth: 215,
+					defaultVisibility: "hidden"
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Import ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					property: "type",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [["youtube", "YouTube"]]
+				},
+				{
+					name: "requestedBy",
+					displayName: "Requested By",
+					property: "requestedBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "requestedAt",
+					displayName: "Requested At",
+					property: "requestedAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "response.successful",
+					displayName: "Successful",
+					property: "response.successful",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "response.alreadyInDatabase",
+					displayName: "Existing",
+					property: "response.alreadyInDatabase",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "response.failed",
+					displayName: "Failed",
+					property: "response.failed",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "url",
+					displayName: "URL",
+					property: "query.url",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "musicOnly",
+					displayName: "Music Only",
+					property: "query.musicOnly",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						[true, "True"],
+						[false, "False"]
+					]
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["success", "Success"],
+						["in-progress", "In Progress"],
+						["failed", "Failed"]
+					]
+				}
+			],
+			events: {
+				adminRoom: "import",
+				updated: {
+					event: "admin.importJob.updated",
+					id: "importJob._id",
+					item: "importJob"
+				},
+				removed: {
+					event: "admin.importJob.removed",
+					id: "jobId"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		openAdvancedTable(importJob) {
+			const filter = {
+				appliedFilters: [
+					{
+						data: importJob._id,
+						filter: {
+							name: "importJob",
+							displayName: "Import Job",
+							property: "importJob",
+							filterTypes: ["special"],
+							defaultFilterType: "special"
+						},
+						filterType: { name: "special", displayName: "Special" }
+					}
+				],
+				appliedFilterOperator: "or"
+			};
+			this.$router.push({
+				path: `/admin/youtube/videos`,
+				query: { filter: JSON.stringify(filter) }
+			});
+		},
+		submitCreateImport(stage) {
+			if (stage === 2) {
+				const playlistRegex = /[\\?&]list=([^&#]*)/;
+				const channelRegex =
+					/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
+				if (
+					playlistRegex.exec(this.createImport.youtubeUrl) ||
+					channelRegex.exec(this.createImport.youtubeUrl)
+				)
+					this.importFromYoutube();
+				else
+					return new Toast({
+						content: "Please enter a valid YouTube URL.",
+						timeout: 4000
+					});
+			}
+
+			if (stage === 3) this.resetCreateImport();
+			else this.createImport.stage += 1;
+
+			return this.createImport.stage;
+		},
+		resetCreateImport() {
+			this.createImport = {
+				stage: 2,
+				importMethod: "youtube",
+				youtubeUrl:
+					"https://www.youtube.com/channel/UCio_FVgKVgqcHrRiXDpnqbw",
+				isImportingOnlyMusic: false
+			};
+		},
+		prevCreateImport(stage) {
+			if (stage === 2) this.createImport.stage = 1;
+		},
+		importFromYoutube() {
+			if (!this.createImport.youtubeUrl)
+				return new Toast("Please enter a YouTube URL.");
+
+			let id;
+			let title;
+
+			return this.socket.dispatch(
+				"youtube.requestSetAdmin",
+				this.createImport.youtubeUrl,
+				this.createImport.isImportingOnlyMusic,
+				true,
+				{
+					cb: () => {},
+					onProgress: res => {
+						if (res.status === "started") {
+							id = res.id;
+							title = res.title;
+						}
+
+						if (id)
+							this.setJob({
+								id,
+								name: title,
+								...res
+							});
+					}
+				}
+			);
+		},
+		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}`;
+		},
+		editSongs(videos) {
+			const songs = videos.map(youtubeId => ({ youtubeId }));
+			if (songs.length === 1)
+				this.openModal({ modal: "editSong", data: { song: songs[0] } });
+			else this.openModal({ modal: "editSongs", data: { songs } });
+		},
+		importAlbum(youtubeIds) {
+			this.socket.dispatch(
+				"songs.getSongsFromYoutubeIds",
+				youtubeIds,
+				res => {
+					if (res.status === "success") {
+						this.openModal({
+							modal: "importAlbum",
+							data: { songs: res.data.songs }
+						});
+					} else new Toast("Could not get songs.");
+				}
+			);
+		},
+		removeImportJob(jobId) {
+			this.socket.dispatch("media.removeImportJobs", jobId, res => {
+				new Toast(res.message);
+			});
+		},
+		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"]),
+		...mapActions("longJobs", ["setJob"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.admin-tab.import-tab {
+	.section-row {
+		display: flex;
+		flex-wrap: wrap;
+		height: 100%;
+
+		.card {
+			max-height: 100%;
+			overflow-y: auto;
+			flex-grow: 1;
+
+			.control.is-expanded {
+				.button {
+					width: 100%;
+				}
+
+				&:not(:last-of-type) {
+					margin-bottom: 10px !important;
+				}
+
+				&:last-of-type {
+					margin-bottom: 0 !important;
+				}
+			}
+
+			.control.is-grouped > .button {
+				&:not(:last-child) {
+					border-radius: @border-radius 0 0 @border-radius;
+				}
+
+				&:last-child {
+					border-radius: 0 @border-radius @border-radius 0;
+				}
+			}
+		}
+
+		.left-section {
+			height: 100%;
+			max-width: 400px;
+			margin-right: 20px !important;
+
+			.checkbox-control label.label {
+				margin-left: 10px;
+			}
+
+			.import-started {
+				font-size: 18px;
+				font-weight: 600;
+				margin-bottom: 10px;
+			}
+		}
+
+		.right-section {
+			max-width: calc(100% - 400px);
+
+			.row-options .material-icons.import-album-icon {
+				background-color: var(--purple);
+				color: var(--white);
+				border-color: var(--purple);
+				font-size: 20px;
+			}
+		}
+
+		@media screen and (max-width: 1200px) {
+			.card {
+				flex-basis: 100%;
+				max-height: unset;
+
+				&.left-section {
+					max-width: unset;
+					margin-right: 0 !important;
+					margin-bottom: 10px !important;
+				}
+
+				&.right-section {
+					max-width: unset;
+				}
+			}
+		}
+	}
+}
+</style>

+ 294 - 289
frontend/src/pages/Admin/Songs.vue → frontend/src/pages/Admin/Songs/index.vue

@@ -1,17 +1,15 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab">
 		<page-metadata title="Admin | Songs" />
 		<page-metadata title="Admin | Songs" />
-		<div class="admin-tab">
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Songs</h1>
+				<p>Create, edit and manage songs in the catalogue</p>
+			</div>
 			<div class="button-row">
 			<div class="button-row">
 				<button class="button is-primary" @click="create()">
 				<button class="button is-primary" @click="create()">
 					Create song
 					Create song
 				</button>
 				</button>
-				<button
-					class="button is-primary"
-					@click="openModal('importPlaylist')"
-				>
-					Import playlist
-				</button>
 				<button
 				<button
 					class="button is-primary"
 					class="button is-primary"
 					@click="openModal('importAlbum')"
 					@click="openModal('importAlbum')"
@@ -20,242 +18,230 @@
 				</button>
 				</button>
 				<run-job-dropdown :jobs="jobs" />
 				<run-job-dropdown :jobs="jobs" />
 			</div>
 			</div>
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="songs.getData"
-				name="admin-songs"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="editOne(slotProps.item)"
-							:disabled="slotProps.item.removed"
-							content="Edit Song"
-							v-tippy
-						>
-							edit
-						</button>
-						<quick-confirm
-							v-if="slotProps.item.verified"
-							@confirm="unverifyOne(slotProps.item._id)"
-						>
-							<button
-								class="button is-danger icon-with-button material-icons"
-								:disabled="slotProps.item.removed"
-								content="Unverify Song"
-								v-tippy
-							>
-								cancel
-							</button>
-						</quick-confirm>
-						<button
-							v-else
-							class="button is-success icon-with-button material-icons"
-							@click="verifyOne(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-							content="Verify Song"
-							v-tippy
-						>
-							check_circle
-						</button>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="songs.getData"
+			name="admin-songs"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="editOne(slotProps.item)"
+						:disabled="slotProps.item.removed"
+						content="Edit Song"
+						v-tippy
+					>
+						edit
+					</button>
+					<quick-confirm
+						v-if="slotProps.item.verified"
+						@confirm="unverifyOne(slotProps.item._id)"
+					>
 						<button
 						<button
 							class="button is-danger icon-with-button material-icons"
 							class="button is-danger icon-with-button material-icons"
-							@click.prevent="
-								confirmAction({
-									message:
-										'Removing this song will remove it from all playlists and cause a ratings recalculation.',
-									action: 'deleteOne',
-									params: slotProps.item._id
-								})
-							"
 							:disabled="slotProps.item.removed"
 							:disabled="slotProps.item.removed"
-							content="Delete Song"
+							content="Unverify Song"
 							v-tippy
 							v-tippy
 						>
 						>
-							delete_forever
+							cancel
 						</button>
 						</button>
-					</div>
-				</template>
-				<template #column-thumbnailImage="slotProps">
-					<img
-						class="song-thumbnail"
-						:src="slotProps.item.thumbnail"
-						onerror="this.src='/assets/notes-transparent.png'"
-						loading="lazy"
-					/>
-				</template>
-				<template #column-thumbnailUrl="slotProps">
-					<a :href="slotProps.item.thumbnail" target="_blank">
-						{{ slotProps.item.thumbnail }}
-					</a>
-				</template>
-				<template #column-title="slotProps">
-					<span :title="slotProps.item.title">{{
-						slotProps.item.title
-					}}</span>
-				</template>
-				<template #column-artists="slotProps">
-					<span :title="slotProps.item.artists.join(', ')">{{
-						slotProps.item.artists.join(", ")
-					}}</span>
-				</template>
-				<template #column-genres="slotProps">
-					<span :title="slotProps.item.genres.join(', ')">{{
-						slotProps.item.genres.join(", ")
-					}}</span>
-				</template>
-				<template #column-tags="slotProps">
-					<span :title="slotProps.item.tags.join(', ')">{{
-						slotProps.item.tags.join(", ")
-					}}</span>
-				</template>
-				<template #column-likes="slotProps">
-					<span :title="slotProps.item.likes">{{
-						slotProps.item.likes
-					}}</span>
-				</template>
-				<template #column-dislikes="slotProps">
-					<span :title="slotProps.item.dislikes">{{
-						slotProps.item.dislikes
-					}}</span>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-				<template #column-youtubeId="slotProps">
-					<a
-						:href="
-							'https://www.youtube.com/watch?v=' +
-							`${slotProps.item.youtubeId}`
+					</quick-confirm>
+					<button
+						v-else
+						class="button is-success icon-with-button material-icons"
+						@click="verifyOne(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+						content="Verify Song"
+						v-tippy
+					>
+						check_circle
+					</button>
+					<button
+						class="button is-danger icon-with-button material-icons"
+						@click.prevent="
+							confirmAction({
+								message:
+									'Removing this song will remove it from all playlists and cause a ratings recalculation.',
+								action: 'deleteOne',
+								params: slotProps.item._id
+							})
 						"
 						"
-						target="_blank"
+						:disabled="slotProps.item.removed"
+						content="Delete Song"
+						v-tippy
+					>
+						delete_forever
+					</button>
+				</div>
+			</template>
+			<template #column-thumbnailImage="slotProps">
+				<song-thumbnail class="song-thumbnail" :song="slotProps.item" />
+			</template>
+			<template #column-thumbnailUrl="slotProps">
+				<a :href="slotProps.item.thumbnail" target="_blank">
+					{{ slotProps.item.thumbnail }}
+				</a>
+			</template>
+			<template #column-title="slotProps">
+				<span :title="slotProps.item.title">{{
+					slotProps.item.title
+				}}</span>
+			</template>
+			<template #column-artists="slotProps">
+				<span :title="slotProps.item.artists.join(', ')">{{
+					slotProps.item.artists.join(", ")
+				}}</span>
+			</template>
+			<template #column-genres="slotProps">
+				<span :title="slotProps.item.genres.join(', ')">{{
+					slotProps.item.genres.join(", ")
+				}}</span>
+			</template>
+			<template #column-tags="slotProps">
+				<span :title="slotProps.item.tags.join(', ')">{{
+					slotProps.item.tags.join(", ")
+				}}</span>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</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-verified="slotProps">
+				<span :title="slotProps.item.verified">{{
+					slotProps.item.verified
+				}}</span>
+			</template>
+			<template #column-duration="slotProps">
+				<span :title="slotProps.item.duration">{{
+					slotProps.item.duration
+				}}</span>
+			</template>
+			<template #column-skipDuration="slotProps">
+				<span :title="slotProps.item.skipDuration">{{
+					slotProps.item.skipDuration
+				}}</span>
+			</template>
+			<template #column-requestedBy="slotProps">
+				<user-link :user-id="slotProps.item.requestedBy" />
+			</template>
+			<template #column-requestedAt="slotProps">
+				<span :title="new Date(slotProps.item.requestedAt)">{{
+					getDateFormatted(slotProps.item.requestedAt)
+				}}</span>
+			</template>
+			<template #column-verifiedBy="slotProps">
+				<user-link :user-id="slotProps.item.verifiedBy" />
+			</template>
+			<template #column-verifiedAt="slotProps">
+				<span :title="new Date(slotProps.item.verifiedAt)">{{
+					getDateFormatted(slotProps.item.verifiedAt)
+				}}</span>
+			</template>
+			<template #bulk-actions="slotProps">
+				<div class="bulk-actions">
+					<i
+						class="material-icons edit-songs-icon"
+						@click.prevent="editMany(slotProps.item)"
+						content="Edit Songs"
+						v-tippy
+						tabindex="0"
+					>
+						edit
+					</i>
+					<i
+						class="material-icons verify-songs-icon"
+						@click.prevent="verifyMany(slotProps.item)"
+						content="Verify Songs"
+						v-tippy
+						tabindex="0"
+					>
+						check_circle
+					</i>
+					<quick-confirm
+						placement="left"
+						@confirm="unverifyMany(slotProps.item)"
+						tabindex="0"
 					>
 					>
-						{{ slotProps.item.youtubeId }}
-					</a>
-				</template>
-				<template #column-verified="slotProps">
-					<span :title="slotProps.item.verified">{{
-						slotProps.item.verified
-					}}</span>
-				</template>
-				<template #column-duration="slotProps">
-					<span :title="slotProps.item.duration">{{
-						slotProps.item.duration
-					}}</span>
-				</template>
-				<template #column-skipDuration="slotProps">
-					<span :title="slotProps.item.skipDuration">{{
-						slotProps.item.skipDuration
-					}}</span>
-				</template>
-				<template #column-requestedBy="slotProps">
-					<user-id-to-username
-						:user-id="slotProps.item.requestedBy"
-						:link="true"
-					/>
-				</template>
-				<template #column-requestedAt="slotProps">
-					<span :title="new Date(slotProps.item.requestedAt)">{{
-						getDateFormatted(slotProps.item.requestedAt)
-					}}</span>
-				</template>
-				<template #column-verifiedBy="slotProps">
-					<user-id-to-username
-						:user-id="slotProps.item.verifiedBy"
-						:link="true"
-					/>
-				</template>
-				<template #column-verifiedAt="slotProps">
-					<span :title="new Date(slotProps.item.verifiedAt)">{{
-						getDateFormatted(slotProps.item.verifiedAt)
-					}}</span>
-				</template>
-				<template #bulk-actions="slotProps">
-					<div class="bulk-actions">
-						<i
-							class="material-icons edit-songs-icon"
-							@click.prevent="editMany(slotProps.item)"
-							content="Edit Songs"
-							v-tippy
-							tabindex="0"
-						>
-							edit
-						</i>
-						<i
-							class="material-icons verify-songs-icon"
-							@click.prevent="verifyMany(slotProps.item)"
-							content="Verify Songs"
-							v-tippy
-							tabindex="0"
-						>
-							check_circle
-						</i>
-						<quick-confirm
-							placement="left"
-							@confirm="unverifyMany(slotProps.item)"
-							tabindex="0"
-						>
-							<i
-								class="material-icons unverify-songs-icon"
-								content="Unverify Songs"
-								v-tippy
-							>
-								cancel
-							</i>
-						</quick-confirm>
-						<i
-							class="material-icons tag-songs-icon"
-							@click.prevent="setTags(slotProps.item)"
-							content="Set Tags"
-							v-tippy
-							tabindex="0"
-						>
-							local_offer
-						</i>
-						<i
-							class="material-icons artists-songs-icon"
-							@click.prevent="setArtists(slotProps.item)"
-							content="Set Artists"
-							v-tippy
-							tabindex="0"
-						>
-							group
-						</i>
-						<i
-							class="material-icons genres-songs-icon"
-							@click.prevent="setGenres(slotProps.item)"
-							content="Set Genres"
-							v-tippy
-							tabindex="0"
-						>
-							theater_comedy
-						</i>
 						<i
 						<i
-							class="material-icons delete-icon"
-							@click.prevent="
-								confirmAction({
-									message:
-										'Removing these songs will remove them from all playlists and cause a ratings recalculation.',
-									action: 'deleteMany',
-									params: slotProps.item
-								})
-							"
-							content="Delete Songs"
+							class="material-icons unverify-songs-icon"
+							content="Unverify Songs"
 							v-tippy
 							v-tippy
-							tabindex="0"
 						>
 						>
-							delete_forever
+							cancel
 						</i>
 						</i>
-					</div>
-				</template>
-			</advanced-table>
-		</div>
+					</quick-confirm>
+					<i
+						class="material-icons import-album-icon"
+						@click.prevent="importAlbum(slotProps.item)"
+						content="Import Album"
+						v-tippy
+						tabindex="0"
+					>
+						album
+					</i>
+					<i
+						class="material-icons tag-songs-icon"
+						@click.prevent="setTags(slotProps.item)"
+						content="Set Tags"
+						v-tippy
+						tabindex="0"
+					>
+						local_offer
+					</i>
+					<i
+						class="material-icons artists-songs-icon"
+						@click.prevent="setArtists(slotProps.item)"
+						content="Set Artists"
+						v-tippy
+						tabindex="0"
+					>
+						group
+					</i>
+					<i
+						class="material-icons genres-songs-icon"
+						@click.prevent="setGenres(slotProps.item)"
+						content="Set Genres"
+						v-tippy
+						tabindex="0"
+					>
+						theater_comedy
+					</i>
+					<i
+						class="material-icons delete-icon"
+						@click.prevent="
+							confirmAction({
+								message:
+									'Removing these songs will remove them from all playlists and cause a ratings recalculation.',
+								action: 'deleteMany',
+								params: slotProps.item
+							})
+						"
+						content="Delete Songs"
+						v-tippy
+						tabindex="0"
+					>
+						delete_forever
+					</i>
+				</div>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 
@@ -287,7 +273,7 @@ export default {
 				{
 				{
 					name: "options",
 					name: "options",
 					displayName: "Options",
 					displayName: "Options",
-					properties: ["_id", "verified"],
+					properties: ["_id", "verified", "youtubeId"],
 					sortable: false,
 					sortable: false,
 					hidable: false,
 					hidable: false,
 					resizable: false,
 					resizable: false,
@@ -328,24 +314,6 @@ export default {
 					properties: ["tags"],
 					properties: ["tags"],
 					sortable: false
 					sortable: false
 				},
 				},
-				{
-					name: "likes",
-					displayName: "Likes",
-					properties: ["likes"],
-					sortProperty: "likes",
-					minWidth: 100,
-					defaultWidth: 100,
-					defaultVisibility: "hidden"
-				},
-				{
-					name: "dislikes",
-					displayName: "Dislikes",
-					properties: ["dislikes"],
-					sortProperty: "dislikes",
-					minWidth: 100,
-					defaultWidth: 100,
-					defaultVisibility: "hidden"
-				},
 				{
 				{
 					name: "_id",
 					name: "_id",
 					displayName: "Song ID",
 					displayName: "Song ID",
@@ -517,32 +485,6 @@ export default {
 					filterTypes: ["boolean"],
 					filterTypes: ["boolean"],
 					defaultFilterType: "boolean"
 					defaultFilterType: "boolean"
 				},
 				},
-				{
-					name: "likes",
-					displayName: "Likes",
-					property: "likes",
-					filterTypes: [
-						"numberLesserEqual",
-						"numberLesser",
-						"numberGreater",
-						"numberGreaterEqual",
-						"numberEquals"
-					],
-					defaultFilterType: "numberLesser"
-				},
-				{
-					name: "dislikes",
-					displayName: "Dislikes",
-					property: "dislikes",
-					filterTypes: [
-						"numberLesserEqual",
-						"numberLesser",
-						"numberGreater",
-						"numberGreaterEqual",
-						"numberEquals"
-					],
-					defaultFilterType: "numberLesser"
-				},
 				{
 				{
 					name: "duration",
 					name: "duration",
 					displayName: "Duration",
 					displayName: "Duration",
@@ -588,8 +530,8 @@ export default {
 					socket: "songs.updateAll"
 					socket: "songs.updateAll"
 				},
 				},
 				{
 				{
-					name: "Recalculate all song ratings",
-					socket: "songs.recalculateAllRatings"
+					name: "Recalculate all ratings",
+					socket: "media.recalculateAllRatings"
 				}
 				}
 			]
 			]
 		};
 		};
@@ -625,14 +567,14 @@ export default {
 		editOne(song) {
 		editOne(song) {
 			this.openModal({
 			this.openModal({
 				modal: "editSong",
 				modal: "editSong",
-				data: { song: { songId: song._id } }
+				data: { song }
 			});
 			});
 		},
 		},
 		editMany(selectedRows) {
 		editMany(selectedRows) {
 			if (selectedRows.length === 1) this.editOne(selectedRows[0]);
 			if (selectedRows.length === 1) this.editOne(selectedRows[0]);
 			else {
 			else {
 				const songs = selectedRows.map(row => ({
 				const songs = selectedRows.map(row => ({
-					songId: row._id
+					youtubeId: row.youtubeId
 				}));
 				}));
 				this.openModal({ modal: "editSongs", data: { songs } });
 				this.openModal({ modal: "editSongs", data: { songs } });
 			}
 			}
@@ -643,11 +585,27 @@ export default {
 			});
 			});
 		},
 		},
 		verifyMany(selectedRows) {
 		verifyMany(selectedRows) {
+			let id;
+			let title;
+
 			this.socket.dispatch(
 			this.socket.dispatch(
 				"songs.verifyMany",
 				"songs.verifyMany",
 				selectedRows.map(row => row._id),
 				selectedRows.map(row => row._id),
-				res => {
-					new Toast(res.message);
+				{
+					cb: () => {},
+					onProgress: res => {
+						if (res.status === "started") {
+							id = res.id;
+							title = res.title;
+						}
+
+						if (id)
+							this.setJob({
+								id,
+								name: title,
+								...res
+							});
+					}
 				}
 				}
 			);
 			);
 		},
 		},
@@ -657,11 +615,42 @@ export default {
 			});
 			});
 		},
 		},
 		unverifyMany(selectedRows) {
 		unverifyMany(selectedRows) {
+			let id;
+			let title;
+
 			this.socket.dispatch(
 			this.socket.dispatch(
 				"songs.unverifyMany",
 				"songs.unverifyMany",
 				selectedRows.map(row => row._id),
 				selectedRows.map(row => row._id),
+				{
+					cb: () => {},
+					onProgress: res => {
+						if (res.status === "started") {
+							id = res.id;
+							title = res.title;
+						}
+
+						if (id)
+							this.setJob({
+								id,
+								name: title,
+								...res
+							});
+					}
+				}
+			);
+		},
+		importAlbum(selectedRows) {
+			const youtubeIds = selectedRows.map(({ youtubeId }) => youtubeId);
+			this.socket.dispatch(
+				"songs.getSongsFromYoutubeIds",
+				youtubeIds,
 				res => {
 				res => {
-					new Toast(res.message);
+					if (res.status === "success") {
+						this.openModal({
+							modal: "importAlbum",
+							data: { songs: res.data.songs }
+						});
+					}
 				}
 				}
 			);
 			);
 		},
 		},
@@ -716,11 +705,27 @@ export default {
 			});
 			});
 		},
 		},
 		deleteMany(selectedRows) {
 		deleteMany(selectedRows) {
+			let id;
+			let title;
+
 			this.socket.dispatch(
 			this.socket.dispatch(
 				"songs.removeMany",
 				"songs.removeMany",
 				selectedRows.map(row => row._id),
 				selectedRows.map(row => row._id),
-				res => {
-					new Toast(res.message);
+				{
+					cb: () => {},
+					onProgress: res => {
+						if (res.status === "started") {
+							id = res.id;
+							title = res.title;
+						}
+
+						if (id)
+							this.setJob({
+								id,
+								name: title,
+								...res
+							});
+					}
 				}
 				}
 			);
 			);
 		},
 		},
@@ -756,12 +761,12 @@ export default {
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
-.song-thumbnail {
-	display: block;
+:deep(.song-thumbnail) {
 	width: 50px;
 	width: 50px;
 	height: 50px;
 	height: 50px;
+	min-width: 50px;
+	min-height: 50px;
 	margin: 0 auto;
 	margin: 0 auto;
-	object-fit: contain;
 }
 }
 
 
 :deep(.bulk-popup .bulk-actions) {
 :deep(.bulk-popup .bulk-actions) {

+ 123 - 125
frontend/src/pages/Admin/Stations.vue

@@ -1,7 +1,11 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab">
 		<page-metadata title="Admin | Stations" />
 		<page-metadata title="Admin | Stations" />
-		<div class="admin-tab">
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Stations</h1>
+				<p>Manage stations or create an official station</p>
+			</div>
 			<div class="button-row">
 			<div class="button-row">
 				<button
 				<button
 					class="button is-primary"
 					class="button is-primary"
@@ -16,134 +20,128 @@
 				</button>
 				</button>
 				<run-job-dropdown :jobs="jobs" />
 				<run-job-dropdown :jobs="jobs" />
 			</div>
 			</div>
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="stations.getData"
-				name="admin-stations"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="stations.getData"
+			name="admin-stations"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'manageStation',
+								data: {
+									stationId: slotProps.item._id,
+									sector: 'admin'
+								}
+							})
+						"
+						:disabled="slotProps.item.removed"
+						content="Manage Station"
+						v-tippy
+					>
+						settings
+					</button>
+					<quick-confirm
+						@confirm="remove(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+					>
 						<button
 						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="
-								openModal({
-									modal: 'manageStation',
-									data: {
-										stationId: slotProps.item._id,
-										sector: 'admin'
-									}
-								})
-							"
-							:disabled="slotProps.item.removed"
-							content="Manage Station"
+							class="button is-danger icon-with-button material-icons"
+							content="Remove Station"
 							v-tippy
 							v-tippy
 						>
 						>
-							settings
+							delete_forever
 						</button>
 						</button>
-						<quick-confirm
-							@confirm="remove(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-						>
-							<button
-								class="button is-danger icon-with-button material-icons"
-								content="Remove Station"
-								v-tippy
-							>
-								delete_forever
-							</button>
-						</quick-confirm>
-						<router-link
-							:to="{ path: `/${slotProps.item.name}` }"
-							target="_blank"
-							class="button is-primary icon-with-button material-icons"
-							:disabled="slotProps.item.removed"
-							content="View Station"
-							v-tippy
-						>
-							radio
-						</router-link>
-					</div>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-				<template #column-name="slotProps">
-					<span :title="slotProps.item.name">{{
-						slotProps.item.name
-					}}</span>
-				</template>
-				<template #column-displayName="slotProps">
-					<span :title="slotProps.item.displayName">{{
-						slotProps.item.displayName
-					}}</span>
-				</template>
-				<template #column-type="slotProps">
-					<span :title="slotProps.item.type">{{
-						slotProps.item.type
-					}}</span>
-				</template>
-				<template #column-description="slotProps">
-					<span :title="slotProps.item.description">{{
-						slotProps.item.description
-					}}</span>
-				</template>
-				<template #column-privacy="slotProps">
-					<span :title="slotProps.item.privacy">{{
-						slotProps.item.privacy
-					}}</span>
-				</template>
-				<template #column-owner="slotProps">
-					<span v-if="slotProps.item.type === 'official'"
-						>Musare</span
+					</quick-confirm>
+					<router-link
+						:to="{ path: `/${slotProps.item.name}` }"
+						target="_blank"
+						class="button is-primary icon-with-button material-icons"
+						:disabled="slotProps.item.removed"
+						content="View Station"
+						v-tippy
 					>
 					>
-					<user-id-to-username
-						v-else
-						:user-id="slotProps.item.owner"
-						:link="true"
-					/>
-				</template>
-				<template #column-theme="slotProps">
-					<span :title="slotProps.item.theme">{{
-						slotProps.item.theme
-					}}</span>
-				</template>
-				<template #column-requestsEnabled="slotProps">
-					<span :title="slotProps.item.requests.enabled">{{
-						slotProps.item.requests.enabled
-					}}</span>
-				</template>
-				<template #column-requestsAccess="slotProps">
-					<span :title="slotProps.item.requests.access">{{
-						slotProps.item.requests.access
-					}}</span>
-				</template>
-				<template #column-requestsLimit="slotProps">
-					<span :title="slotProps.item.requests.limit">{{
-						slotProps.item.requests.limit
-					}}</span>
-				</template>
-				<template #column-autofillEnabled="slotProps">
-					<span :title="slotProps.item.autofill.enabled">{{
-						slotProps.item.autofill.enabled
-					}}</span>
-				</template>
-				<template #column-autofillLimit="slotProps">
-					<span :title="slotProps.item.autofill.limit">{{
-						slotProps.item.autofill.limit
-					}}</span>
-				</template>
-				<template #column-autofillMode="slotProps">
-					<span :title="slotProps.item.autofill.mode">{{
-						slotProps.item.autofill.mode
-					}}</span>
-				</template>
-			</advanced-table>
-		</div>
+						radio
+					</router-link>
+				</div>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
+			<template #column-name="slotProps">
+				<span :title="slotProps.item.name">{{
+					slotProps.item.name
+				}}</span>
+			</template>
+			<template #column-displayName="slotProps">
+				<span :title="slotProps.item.displayName">{{
+					slotProps.item.displayName
+				}}</span>
+			</template>
+			<template #column-type="slotProps">
+				<span :title="slotProps.item.type">{{
+					slotProps.item.type
+				}}</span>
+			</template>
+			<template #column-description="slotProps">
+				<span :title="slotProps.item.description">{{
+					slotProps.item.description
+				}}</span>
+			</template>
+			<template #column-privacy="slotProps">
+				<span :title="slotProps.item.privacy">{{
+					slotProps.item.privacy
+				}}</span>
+			</template>
+			<template #column-owner="slotProps">
+				<span v-if="slotProps.item.type === 'official'">Musare</span>
+				<user-link v-else :user-id="slotProps.item.owner" />
+			</template>
+			<template #column-theme="slotProps">
+				<span :title="slotProps.item.theme">{{
+					slotProps.item.theme
+				}}</span>
+			</template>
+			<template #column-requestsEnabled="slotProps">
+				<span :title="slotProps.item.requests.enabled">{{
+					slotProps.item.requests.enabled
+				}}</span>
+			</template>
+			<template #column-requestsAccess="slotProps">
+				<span :title="slotProps.item.requests.access">{{
+					slotProps.item.requests.access
+				}}</span>
+			</template>
+			<template #column-requestsLimit="slotProps">
+				<span :title="slotProps.item.requests.limit">{{
+					slotProps.item.requests.limit
+				}}</span>
+			</template>
+			<template #column-autofillEnabled="slotProps">
+				<span :title="slotProps.item.autofill.enabled">{{
+					slotProps.item.autofill.enabled
+				}}</span>
+			</template>
+			<template #column-autofillLimit="slotProps">
+				<span :title="slotProps.item.autofill.limit">{{
+					slotProps.item.autofill.limit
+				}}</span>
+			</template>
+			<template #column-autofillMode="slotProps">
+				<span :title="slotProps.item.autofill.mode">{{
+					slotProps.item.autofill.mode
+				}}</span>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 

+ 82 - 120
frontend/src/pages/Admin/Statistics.vue

@@ -1,10 +1,15 @@
 <template>
 <template>
-	<div class="container">
+	<div class="admin-tab container">
 		<page-metadata title="Admin | Statistics" />
 		<page-metadata title="Admin | Statistics" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Statistics</h1>
+				<p>Analyze backend server job statistics</p>
+			</div>
+		</div>
 		<div class="card">
 		<div class="card">
-			<header class="card-header">
-				<p>Average Logs</p>
-			</header>
+			<h4>Average Logs</h4>
+			<hr class="section-horizontal-rule" />
 			<div class="card-content">
 			<div class="card-content">
 				<table class="table">
 				<table class="table">
 					<thead>
 					<thead>
@@ -40,93 +45,85 @@
 				</table>
 				</table>
 			</div>
 			</div>
 		</div>
 		</div>
-		<br />
-		<div v-if="module">
-			<div class="card">
-				<header class="card-header">
-					<p>Running tasks</p>
-				</header>
-				<div class="card-content">
-					<table class="table">
-						<thead>
-							<tr>
-								<th>Name</th>
-								<th>Payload</th>
-							</tr>
-						</thead>
-						<tbody>
-							<tr
-								v-for="job in module.runningTasks"
-								:key="JSON.stringify(job)"
-							>
-								<td>{{ job.name }}</td>
-								<td>
-									{{ JSON.stringify(job.payload) }}
-								</td>
-							</tr>
-						</tbody>
-					</table>
-				</div>
+		<div v-if="module" class="card">
+			<h4>Running Tasks</h4>
+			<hr class="section-horizontal-rule" />
+			<div class="card-content">
+				<table class="table">
+					<thead>
+						<tr>
+							<th>Name</th>
+							<th>Payload</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr
+							v-for="job in module.runningTasks"
+							:key="JSON.stringify(job)"
+						>
+							<td>{{ job.name }}</td>
+							<td>
+								{{ JSON.stringify(job.payload) }}
+							</td>
+						</tr>
+					</tbody>
+				</table>
 			</div>
 			</div>
-			<div class="card">
-				<header class="card-header">
-					<p>Paused tasks</p>
-				</header>
-				<div class="card-content">
-					<table class="table">
-						<thead>
-							<tr>
-								<th>Name</th>
-								<th>Payload</th>
-							</tr>
-						</thead>
-						<tbody>
-							<tr
-								v-for="job in module.pausedTasks"
-								:key="JSON.stringify(job)"
-							>
-								<td>{{ job.name }}</td>
-								<td>
-									{{ JSON.stringify(job.payload) }}
-								</td>
-							</tr>
-						</tbody>
-					</table>
-				</div>
+		</div>
+		<div v-if="module" class="card">
+			<h4>Paused Tasks</h4>
+			<hr class="section-horizontal-rule" />
+			<div class="card-content">
+				<table class="table">
+					<thead>
+						<tr>
+							<th>Name</th>
+							<th>Payload</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr
+							v-for="job in module.pausedTasks"
+							:key="JSON.stringify(job)"
+						>
+							<td>{{ job.name }}</td>
+							<td>
+								{{ JSON.stringify(job.payload) }}
+							</td>
+						</tr>
+					</tbody>
+				</table>
 			</div>
 			</div>
-			<div class="card">
-				<header class="card-header">
-					<p>Queued tasks</p>
-				</header>
-				<div class="card-content">
-					<table class="table">
-						<thead>
-							<tr>
-								<th>Name</th>
-								<th>Payload</th>
-							</tr>
-						</thead>
-						<tbody>
-							<tr
-								v-for="job in module.queuedTasks"
-								:key="JSON.stringify(job)"
-							>
-								<td>{{ job.name }}</td>
-								<td>
-									{{ JSON.stringify(job.payload) }}
-								</td>
-							</tr>
-						</tbody>
-					</table>
-				</div>
+		</div>
+		<div v-if="module" class="card">
+			<h4>Queued Tasks</h4>
+			<hr class="section-horizontal-rule" />
+			<div class="card-content">
+				<table class="table">
+					<thead>
+						<tr>
+							<th>Name</th>
+							<th>Payload</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr
+							v-for="job in module.queuedTasks"
+							:key="JSON.stringify(job)"
+						>
+							<td>{{ job.name }}</td>
+							<td>
+								{{ JSON.stringify(job.payload) }}
+							</td>
+						</tr>
+					</tbody>
+				</table>
 			</div>
 			</div>
 		</div>
 		</div>
-		<br />
 		<div v-if="module">
 		<div v-if="module">
 			<div class="card">
 			<div class="card">
-				<header class="card-header">
-					<p>Average Logs</p>
-				</header>
+				<h4>Average Logs</h4>
+				<hr class="section-horizontal-rule" />
 				<div class="card-content">
 				<div class="card-content">
 					<table class="table">
 					<table class="table">
 						<thead>
 						<thead>
@@ -236,44 +233,9 @@ export default {
 			color: var(--light-grey-2);
 			color: var(--light-grey-2);
 		}
 		}
 	}
 	}
-
-	.card {
-		background-color: var(--dark-grey-3);
-
-		p {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-.user-avatar {
-	display: block;
-	max-width: 50px;
-	margin: 0 auto;
 }
 }
 
 
 td {
 td {
 	vertical-align: middle;
 	vertical-align: middle;
 }
 }
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
-
-.card {
-	display: flex;
-	flex-grow: 1;
-	flex-direction: column;
-	padding: 20px;
-	margin: 10px;
-	border-radius: @border-radius;
-	background-color: var(--white);
-	color: var(--dark-grey);
-	box-shadow: @box-shadow;
-
-	.card-header {
-		font-weight: 700;
-		padding-bottom: 10px;
-	}
-}
 </style>
 </style>

+ 57 - 53
frontend/src/pages/Admin/Users/DataRequests.vue

@@ -1,59 +1,63 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab container">
 		<page-metadata title="Admin | Users | Data Requests" />
 		<page-metadata title="Admin | Users | Data Requests" />
-		<div class="container">
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="dataRequests.getData"
-				name="admin-data-requests"
-				:max-width="1200"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<quick-confirm
-							placement="right"
-							@confirm="resolveDataRequest(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-						>
-							<button
-								class="button is-success icon-with-button material-icons"
-								content="Resolve Data Request"
-								v-tippy
-							>
-								done_all
-							</button>
-						</quick-confirm>
-					</div>
-				</template>
-				<template #column-type="slotProps">
-					<span
-						:title="
-							slotProps.item.type
-								? 'Remove all associated data'
-								: slotProps.item.type
-						"
-						>{{
-							slotProps.item.type
-								? "Remove all associated data"
-								: slotProps.item.type
-						}}</span
-					>
-				</template>
-				<template #column-userId="slotProps">
-					<span :title="slotProps.item.userId">{{
-						slotProps.item.userId
-					}}</span>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-			</advanced-table>
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Data Requests</h1>
+				<p>Manage data requests made by users</p>
+			</div>
 		</div>
 		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="dataRequests.getData"
+			name="admin-data-requests"
+			:max-width="1200"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<quick-confirm
+						placement="right"
+						@confirm="resolveDataRequest(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+					>
+						<button
+							class="button is-success icon-with-button material-icons"
+							content="Resolve Data Request"
+							v-tippy
+						>
+							done_all
+						</button>
+					</quick-confirm>
+				</div>
+			</template>
+			<template #column-type="slotProps">
+				<span
+					:title="
+						slotProps.item.type
+							? 'Remove all associated data'
+							: slotProps.item.type
+					"
+					>{{
+						slotProps.item.type
+							? "Remove all associated data"
+							: slotProps.item.type
+					}}</span
+				>
+			</template>
+			<template #column-userId="slotProps">
+				<span :title="slotProps.item.userId">{{
+					slotProps.item.userId
+				}}</span>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 

+ 115 - 144
frontend/src/pages/Admin/Punishments.vue → frontend/src/pages/Admin/Users/Punishments.vue

@@ -1,123 +1,122 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab container">
 		<page-metadata title="Admin | Users | Punishments" />
 		<page-metadata title="Admin | Users | Punishments" />
-		<div class="container">
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="punishments.getData"
-				name="admin-punishments"
-				:max-width="1200"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="
-								openModal({
-									modal: 'viewPunishment',
-									data: { punishmentId: slotProps.item._id }
-								})
-							"
-							:disabled="slotProps.item.removed"
-							content="View Punishment"
-							v-tippy
-						>
-							open_in_full
-						</button>
-					</div>
-				</template>
-				<template #column-status="slotProps">
-					<span>{{ slotProps.item.status }}</span>
-				</template>
-				<template #column-type="slotProps">
-					<span
-						:title="
-							slotProps.item.type === 'banUserId'
-								? 'User ID'
-								: 'IP Address'
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Punishments</h1>
+				<p>Manage punishments or ban an IP</p>
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="punishments.getData"
+			name="admin-punishments"
+			:max-width="1200"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="
+							openModal({
+								modal: 'viewPunishment',
+								data: { punishmentId: slotProps.item._id }
+							})
 						"
 						"
-						>{{
-							slotProps.item.type === "banUserId"
-								? "User ID"
-								: "IP Address"
-						}}</span
+						:disabled="slotProps.item.removed"
+						content="View Punishment"
+						v-tippy
 					>
 					>
-				</template>
-				<template #column-value="slotProps">
-					<user-id-to-username
-						v-if="slotProps.item.type === 'banUserId'"
-						:user-id="slotProps.item.value"
-						:alt="slotProps.item.value"
-						:link="true"
-					/>
-					<span v-else :title="slotProps.item.value">{{
-						slotProps.item.value
-					}}</span>
-				</template>
-				<template #column-reason="slotProps">
-					<span :title="slotProps.item.reason">{{
-						slotProps.item.reason
-					}}</span>
-				</template>
-				<template #column-punishedBy="slotProps">
-					<user-id-to-username
-						:user-id="slotProps.item.punishedBy"
-						:link="true"
-					/>
-				</template>
-				<template #column-punishedAt="slotProps">
-					<span :title="new Date(slotProps.item.punishedAt)">{{
-						getDateFormatted(slotProps.item.punishedAt)
-					}}</span>
-				</template>
-				<template #column-expiresAt="slotProps">
-					<span :title="new Date(slotProps.item.expiresAt)">{{
-						getDateFormatted(slotProps.item.expiresAt)
-					}}</span>
-				</template>
-			</advanced-table>
-			<div class="card">
-				<header class="card-header">
-					<p>Ban an IP</p>
-				</header>
-				<div class="card-content">
-					<label class="label">Expires In</label>
-					<p class="control is-expanded select">
-						<select v-model="ipBan.expiresAt">
-							<option value="1h">1 Hour</option>
-							<option value="12h">12 Hours</option>
-							<option value="1d">1 Day</option>
-							<option value="1w">1 Week</option>
-							<option value="1m">1 Month</option>
-							<option value="3m">3 Months</option>
-							<option value="6m">6 Months</option>
-							<option value="1y">1 Year</option>
-						</select>
-					</p>
-					<label class="label">IP</label>
-					<p class="control is-expanded">
-						<input
-							v-model="ipBan.ip"
-							class="input"
-							type="text"
-							placeholder="IP address (xxx.xxx.xxx.xxx)"
-						/>
-					</p>
-					<label class="label">Reason</label>
-					<p class="control is-expanded">
-						<input
-							v-model="ipBan.reason"
-							class="input"
-							type="text"
-							placeholder="Reason"
-						/>
-					</p>
-					<button class="button is-primary" @click="banIP()">
-						Ban IP
+						open_in_full
 					</button>
 					</button>
 				</div>
 				</div>
+			</template>
+			<template #column-status="slotProps">
+				<span>{{ slotProps.item.status }}</span>
+			</template>
+			<template #column-type="slotProps">
+				<span
+					:title="
+						slotProps.item.type === 'banUserId'
+							? 'User ID'
+							: 'IP Address'
+					"
+					>{{
+						slotProps.item.type === "banUserId"
+							? "User ID"
+							: "IP Address"
+					}}</span
+				>
+			</template>
+			<template #column-value="slotProps">
+				<user-link
+					v-if="slotProps.item.type === 'banUserId'"
+					:user-id="slotProps.item.value"
+					:alt="slotProps.item.value"
+				/>
+				<span v-else :title="slotProps.item.value">{{
+					slotProps.item.value
+				}}</span>
+			</template>
+			<template #column-reason="slotProps">
+				<span :title="slotProps.item.reason">{{
+					slotProps.item.reason
+				}}</span>
+			</template>
+			<template #column-punishedBy="slotProps">
+				<user-link :user-id="slotProps.item.punishedBy" />
+			</template>
+			<template #column-punishedAt="slotProps">
+				<span :title="new Date(slotProps.item.punishedAt)">{{
+					getDateFormatted(slotProps.item.punishedAt)
+				}}</span>
+			</template>
+			<template #column-expiresAt="slotProps">
+				<span :title="new Date(slotProps.item.expiresAt)">{{
+					getDateFormatted(slotProps.item.expiresAt)
+				}}</span>
+			</template>
+		</advanced-table>
+		<div class="card">
+			<h4>Ban an IP</h4>
+			<hr class="section-horizontal-rule" />
+			<div class="card-content">
+				<label class="label">Expires In</label>
+				<p class="control is-expanded select">
+					<select v-model="ipBan.expiresAt">
+						<option value="1h">1 Hour</option>
+						<option value="12h">12 Hours</option>
+						<option value="1d">1 Day</option>
+						<option value="1w">1 Week</option>
+						<option value="1m">1 Month</option>
+						<option value="3m">3 Months</option>
+						<option value="6m">6 Months</option>
+						<option value="1y">1 Year</option>
+					</select>
+				</p>
+				<label class="label">IP</label>
+				<p class="control is-expanded">
+					<input
+						v-model="ipBan.ip"
+						class="input"
+						type="text"
+						placeholder="IP address (xxx.xxx.xxx.xxx)"
+					/>
+				</p>
+				<label class="label">Reason</label>
+				<p class="control is-expanded">
+					<input
+						v-model="ipBan.reason"
+						class="input"
+						type="text"
+						placeholder="Reason"
+					/>
+				</p>
+				<button class="button is-primary" @click="banIP()">
+					Ban IP
+				</button>
 			</div>
 			</div>
 		</div>
 		</div>
 	</div>
 	</div>
@@ -302,35 +301,7 @@ export default {
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
-.night-mode {
-	.card {
-		background: var(--dark-grey-3);
-
-		p,
-		.label {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-.card {
-	display: flex;
-	flex-grow: 1;
-	flex-direction: column;
-	padding: 20px;
-	margin: 10px 0;
-	border-radius: @border-radius;
-	background-color: var(--white);
-	color: var(--dark-grey);
-	box-shadow: @box-shadow;
-
-	.card-header {
-		font-weight: 700;
-		padding-bottom: 10px;
-	}
-
-	.button.is-primary {
-		width: 100%;
-	}
+.card .button.is-primary {
+	width: 100%;
 }
 }
 </style>
 </style>

+ 96 - 92
frontend/src/pages/Admin/Users/index.vue

@@ -1,98 +1,102 @@
 <template>
 <template>
-	<div>
+	<div class="admin-tab container">
 		<page-metadata title="Admin | Users" />
 		<page-metadata title="Admin | Users" />
-		<div class="container">
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="users.getData"
-				name="admin-users"
-				:max-width="1200"
-				:events="events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="edit(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-							content="Edit User"
-							v-tippy
-						>
-							edit
-						</button>
-						<router-link
-							:to="{ path: `/u/${slotProps.item.username}` }"
-							target="_blank"
-							class="button is-primary icon-with-button material-icons"
-							:disabled="slotProps.item.removed"
-							content="View Profile"
-							v-tippy
-						>
-							person
-						</router-link>
-					</div>
-				</template>
-				<template #column-profilePicture="slotProps">
-					<profile-picture
-						:avatar="slotProps.item.avatar"
-						:name="
-							slotProps.item.name
-								? slotProps.item.name
-								: slotProps.item.username
-						"
-					/>
-				</template>
-				<template #column-name="slotProps">
-					<span :title="slotProps.item.name">{{
-						slotProps.item.name
-					}}</span>
-				</template>
-				<template #column-username="slotProps">
-					<span :title="slotProps.item.username">{{
-						slotProps.item.username
-					}}</span>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-				<template #column-githubId="slotProps">
-					<span
-						v-if="slotProps.item.services.github"
-						:title="slotProps.item.services.github.id"
-						>{{ slotProps.item.services.github.id }}</span
-					>
-				</template>
-				<template #column-hasPassword="slotProps">
-					<span :title="slotProps.item.hasPassword">{{
-						slotProps.item.hasPassword
-					}}</span>
-				</template>
-				<template #column-role="slotProps">
-					<span :title="slotProps.item.role">{{
-						slotProps.item.role
-					}}</span>
-				</template>
-				<template #column-emailAddress="slotProps">
-					<span :title="slotProps.item.email.address">{{
-						slotProps.item.email.address
-					}}</span>
-				</template>
-				<template #column-emailVerified="slotProps">
-					<span :title="slotProps.item.email.verified">{{
-						slotProps.item.email.verified
-					}}</span>
-				</template>
-				<template #column-songsRequested="slotProps">
-					<span :title="slotProps.item.statistics.songsRequested">{{
-						slotProps.item.statistics.songsRequested
-					}}</span>
-				</template>
-			</advanced-table>
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>Users</h1>
+				<p>Manage users</p>
+			</div>
 		</div>
 		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			data-action="users.getData"
+			name="admin-users"
+			:max-width="1200"
+			:events="events"
+		>
+			<template #column-options="slotProps">
+				<div class="row-options">
+					<button
+						class="button is-primary icon-with-button material-icons"
+						@click="edit(slotProps.item._id)"
+						:disabled="slotProps.item.removed"
+						content="Edit User"
+						v-tippy
+					>
+						edit
+					</button>
+					<router-link
+						:to="{ path: `/u/${slotProps.item.username}` }"
+						target="_blank"
+						class="button is-primary icon-with-button material-icons"
+						:disabled="slotProps.item.removed"
+						content="View Profile"
+						v-tippy
+					>
+						person
+					</router-link>
+				</div>
+			</template>
+			<template #column-profilePicture="slotProps">
+				<profile-picture
+					:avatar="slotProps.item.avatar"
+					:name="
+						slotProps.item.name
+							? slotProps.item.name
+							: slotProps.item.username
+					"
+				/>
+			</template>
+			<template #column-name="slotProps">
+				<span :title="slotProps.item.name">{{
+					slotProps.item.name
+				}}</span>
+			</template>
+			<template #column-username="slotProps">
+				<span :title="slotProps.item.username">{{
+					slotProps.item.username
+				}}</span>
+			</template>
+			<template #column-_id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
+			<template #column-githubId="slotProps">
+				<span
+					v-if="slotProps.item.services.github"
+					:title="slotProps.item.services.github.id"
+					>{{ slotProps.item.services.github.id }}</span
+				>
+			</template>
+			<template #column-hasPassword="slotProps">
+				<span :title="slotProps.item.hasPassword">{{
+					slotProps.item.hasPassword
+				}}</span>
+			</template>
+			<template #column-role="slotProps">
+				<span :title="slotProps.item.role">{{
+					slotProps.item.role
+				}}</span>
+			</template>
+			<template #column-emailAddress="slotProps">
+				<span :title="slotProps.item.email.address">{{
+					slotProps.item.email.address
+				}}</span>
+			</template>
+			<template #column-emailVerified="slotProps">
+				<span :title="slotProps.item.email.verified">{{
+					slotProps.item.email.verified
+				}}</span>
+			</template>
+			<template #column-songsRequested="slotProps">
+				<span :title="slotProps.item.statistics.songsRequested">{{
+					slotProps.item.statistics.songsRequested
+				}}</span>
+			</template>
+		</advanced-table>
 	</div>
 	</div>
 </template>
 </template>
 
 

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

@@ -0,0 +1,411 @@
+<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 class="button-row">
+				<run-job-dropdown :jobs="jobs" />
+			</div>
+		</div>
+		<advanced-table
+			:column-default="columnDefault"
+			:columns="columns"
+			:filters="filters"
+			:events="events"
+			data-action="youtube.getVideos"
+			name="admin-youtube-videos"
+			:max-width="1140"
+			:bulk-actions="{ width: 150 }"
+		>
+			<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-primary icon-with-button material-icons"
+						@click="editOne(slotProps.item)"
+						:disabled="slotProps.item.removed"
+						content="Create/edit song from video"
+						v-tippy
+					>
+						music_note
+					</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/edit songs from videos"
+						v-tippy
+						tabindex="0"
+					>
+						music_note
+					</i>
+					<i
+						class="material-icons import-album-icon"
+						@click.prevent="importAlbum(slotProps.item)"
+						content="Import album from videos"
+						v-tippy
+						tabindex="0"
+					>
+						album
+					</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";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
+
+export default {
+	components: {
+		AdvancedTable,
+		RunJobDropdown
+	},
+	data() {
+		return {
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 200,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id", "youtubeId"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 129,
+					defaultWidth: 129
+				},
+				{
+					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"
+				},
+				{
+					name: "importJob",
+					displayName: "Import Job",
+					property: "importJob",
+					filterTypes: ["special"],
+					defaultFilterType: "special"
+				}
+			],
+			events: {
+				adminRoom: "youtubeVideos",
+				updated: {
+					event: "admin.youtubeVideo.updated",
+					id: "youtubeVideo._id",
+					item: "youtubeVideo"
+				},
+				removed: {
+					event: "admin.youtubeVideo.removed",
+					id: "videoId"
+				}
+			},
+			jobs: [
+				{
+					name: "Recalculate all ratings",
+					socket: "media.recalculateAllRatings"
+				}
+			]
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		editOne(song) {
+			this.openModal({
+				modal: "editSong",
+				data: { song }
+			});
+		},
+		editMany(selectedRows) {
+			if (selectedRows.length === 1) this.editOne(selectedRows[0]);
+			else {
+				const songs = selectedRows.map(row => ({
+					youtubeId: row.youtubeId
+				}));
+				this.openModal({ modal: "editSongs", data: { songs } });
+			}
+		},
+		importAlbum(selectedRows) {
+			const youtubeIds = selectedRows.map(({ youtubeId }) => youtubeId);
+			this.socket.dispatch(
+				"songs.getSongsFromYoutubeIds",
+				youtubeIds,
+				res => {
+					if (res.status === "success") {
+						this.openModal({
+							modal: "importAlbum",
+							data: { songs: res.data.songs }
+						});
+					} else new Toast("Could not get songs.");
+				}
+			);
+		},
+		removeVideos(videoIds) {
+			let id;
+			let title;
+
+			this.socket.dispatch("youtube.removeVideos", videoIds, {
+				cb: () => {},
+				onProgress: res => {
+					if (res.status === "started") {
+						id = res.id;
+						title = res.title;
+					}
+
+					if (id)
+						this.setJob({
+							id,
+							name: title,
+							...res
+						});
+				}
+			});
+		},
+		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>

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

@@ -0,0 +1,411 @@
+<template>
+	<div class="admin-tab container">
+		<page-metadata title="Admin | YouTube" />
+		<div class="card tab-info">
+			<div class="info-row">
+				<h1>YouTube API</h1>
+				<p>
+					Analyze YouTube quota usage and API requests made on this
+					instance
+				</p>
+			</div>
+			<div class="button-row">
+				<run-job-dropdown :jobs="jobs" />
+			</div>
+		</div>
+		<div v-if="charts" class="card charts">
+			<div class="chart">
+				<h4 class="has-text-centered">Quota Usage</h4>
+				<line-chart
+					v-if="charts.quotaUsage"
+					chart-id="youtube-quota-usage"
+					:data="charts.quotaUsage"
+				/>
+			</div>
+			<div class="chart">
+				<h4 class="has-text-centered">API Requests</h4>
+				<line-chart
+					v-if="charts.apiRequests"
+					chart-id="youtube-api-requests"
+					:data="charts.apiRequests"
+				/>
+			</div>
+		</div>
+		<div class="card">
+			<h4>Quota Stats</h4>
+			<hr class="section-horizontal-rule" />
+			<div class="quotas">
+				<div
+					v-for="[quotaName, quotaObject] in Object.entries(
+						quotaStatus
+					)"
+					:key="quotaName"
+					class="card quota"
+				>
+					<h5>{{ quotaObject.title }}</h5>
+					<p>
+						<strong>Quota used:</strong> {{ quotaObject.quotaUsed }}
+					</p>
+					<p><strong>Limit:</strong> {{ quotaObject.limit }}</p>
+					<p>
+						<strong>Quota exceeded:</strong>
+						{{ quotaObject.quotaExceeded }}
+					</p>
+				</div>
+			</div>
+		</div>
+		<div class="card">
+			<h4>API Requests</h4>
+			<hr class="section-horizontal-rule" />
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				:events="events"
+				data-action="youtube.getApiRequests"
+				name="admin-youtube-api-requests"
+				:max-width="1140"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="button is-primary icon-with-button material-icons"
+							@click="
+								openModal({
+									modal: 'viewApiRequest',
+									data: {
+										requestId: slotProps.item._id,
+										removeAction:
+											'youtube.removeStoredApiRequest'
+									}
+								})
+							"
+							:disabled="slotProps.item.removed"
+							content="View API Request"
+							v-tippy
+						>
+							open_in_full
+						</button>
+						<quick-confirm
+							@confirm="removeApiRequest(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+						>
+							<button
+								class="button is-danger icon-with-button material-icons"
+								content="Remove API Request"
+								v-tippy
+							>
+								delete_forever
+							</button>
+						</quick-confirm>
+					</div>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+				<template #column-quotaCost="slotProps">
+					<span :title="slotProps.item.quotaCost">{{
+						slotProps.item.quotaCost
+					}}</span>
+				</template>
+				<template #column-timestamp="slotProps">
+					<span :title="new Date(slotProps.item.date)">{{
+						getDateFormatted(slotProps.item.date)
+					}}</span>
+				</template>
+				<template #column-url="slotProps">
+					<span :title="slotProps.item.url">{{
+						slotProps.item.url
+					}}</span>
+				</template>
+			</advanced-table>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
+import LineChart from "@/components/LineChart.vue";
+
+import ws from "@/ws";
+
+export default {
+	components: {
+		AdvancedTable,
+		RunJobDropdown,
+		LineChart
+	},
+	data() {
+		return {
+			quotaStatus: {},
+			fromDate: null,
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "quotaCost",
+					displayName: "Quota Cost",
+					properties: ["quotaCost"],
+					sortProperty: ["quotaCost"],
+					minWidth: 150,
+					defaultWidth: 150
+				},
+				{
+					name: "timestamp",
+					displayName: "Timestamp",
+					properties: ["date"],
+					sortProperty: ["date"],
+					minWidth: 150,
+					defaultWidth: 150
+				},
+				{
+					name: "url",
+					displayName: "URL",
+					properties: ["url"],
+					sortProperty: ["url"]
+				},
+				{
+					name: "_id",
+					displayName: "Request ID",
+					properties: ["_id"],
+					sortProperty: ["_id"],
+					minWidth: 230,
+					defaultWidth: 230
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Request ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "quotaCost",
+					displayName: "Quota Cost",
+					property: "quotaCost",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "timestamp",
+					displayName: "Timestamp",
+					property: "date",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "url",
+					displayName: "URL",
+					property: "url",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			],
+			events: {
+				adminRoom: "youtube",
+				removed: {
+					event: "admin.youtubeApiRequest.removed",
+					id: "requestId"
+				}
+			},
+			charts: {
+				quotaUsage: null,
+				apiRequests: null
+			},
+			jobs: [
+				{
+					name: "Reset stored API requests",
+					socket: "youtube.resetStoredApiRequests"
+				}
+			]
+		};
+	},
+	computed: mapGetters({
+		socket: "websockets/getSocket"
+	}),
+	mounted() {
+		ws.onConnect(this.init);
+	},
+	methods: {
+		init() {
+			if (this.$route.query.fromDate)
+				this.fromDate = this.$route.query.fromDate;
+
+			this.socket.dispatch(
+				"youtube.getQuotaStatus",
+				this.fromDate,
+				res => {
+					if (res.status === "success")
+						this.quotaStatus = res.data.status;
+				}
+			);
+
+			this.socket.dispatch(
+				"youtube.getQuotaChartData",
+				"days",
+				new Date().setDate(new Date().getDate() - 6),
+				new Date().setDate(new Date().getDate() + 1),
+				"usage",
+				res => {
+					if (res.status === "success")
+						this.charts.quotaUsage = res.data;
+				}
+			);
+
+			this.socket.dispatch(
+				"youtube.getQuotaChartData",
+				"days",
+				new Date().setDate(new Date().getDate() - 6),
+				new Date().setDate(new Date().getDate() + 1),
+				"count",
+				res => {
+					if (res.status === "success")
+						this.charts.apiRequests = res.data;
+				}
+			);
+		},
+		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}`;
+		},
+		removeApiRequest(requestId) {
+			this.socket.dispatch(
+				"youtube.removeStoredApiRequest",
+				requestId,
+				res => new Toast(res.message)
+			);
+		},
+		...mapActions("modalVisibility", ["openModal"])
+	}
+};
+</script>
+
+<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>

+ 203 - 55
frontend/src/pages/Admin/index.vue

@@ -21,7 +21,46 @@
 								<i class="material-icons">menu_open</i>
 								<i class="material-icons">menu_open</i>
 								<span>Minimise</span>
 								<span>Minimise</span>
 							</div>
 							</div>
+							<div
+								v-if="sidebarActive"
+								class="sidebar-item with-children"
+								:class="{ 'is-active': childrenActive.songs }"
+							>
+								<span>
+									<router-link to="/admin/songs">
+										<i class="material-icons">music_note</i>
+										<span>Songs</span>
+									</router-link>
+									<i
+										class="material-icons toggle-sidebar-children"
+										@click="
+											toggleChildren({ child: 'songs' })
+										"
+									>
+										{{
+											childrenActive.songs
+												? "expand_less"
+												: "expand_more"
+										}}
+									</i>
+								</span>
+								<div class="sidebar-item-children">
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/songs"
+									>
+										Songs
+									</router-link>
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/songs/import"
+									>
+										Import
+									</router-link>
+								</div>
+							</div>
 							<router-link
 							<router-link
+								v-else
 								class="sidebar-item songs"
 								class="sidebar-item songs"
 								to="/admin/songs"
 								to="/admin/songs"
 								content="Songs"
 								content="Songs"
@@ -105,6 +144,12 @@
 									>
 									>
 										Data Requests
 										Data Requests
 									</router-link>
 									</router-link>
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/users/punishments"
+									>
+										Punishments
+									</router-link>
 								</div>
 								</div>
 							</div>
 							</div>
 							<router-link
 							<router-link
@@ -120,18 +165,6 @@
 								<i class="material-icons">people</i>
 								<i class="material-icons">people</i>
 								<span>Users</span>
 								<span>Users</span>
 							</router-link>
 							</router-link>
-							<router-link
-								class="sidebar-item punishments"
-								to="/admin/punishments"
-								content="Punishments"
-								v-tippy="{
-									theme: 'info',
-									onShow: () => !sidebarActive
-								}"
-							>
-								<i class="material-icons">gavel</i>
-								<span>Punishments</span>
-							</router-link>
 							<router-link
 							<router-link
 								class="sidebar-item news"
 								class="sidebar-item news"
 								to="/admin/news"
 								to="/admin/news"
@@ -156,6 +189,59 @@
 								<i class="material-icons">show_chart</i>
 								<i class="material-icons">show_chart</i>
 								<span>Statistics</span>
 								<span>Statistics</span>
 							</router-link>
 							</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"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">smart_display</i>
+								<span>YouTube</span>
+							</router-link>
 						</div>
 						</div>
 					</div>
 					</div>
 				</div>
 				</div>
@@ -172,6 +258,7 @@
 		<floating-box
 		<floating-box
 			id="keyboardShortcutsHelper"
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
+			title="Admin Keyboard Shortcuts"
 		>
 		>
 			<template #body>
 			<template #body>
 				<div>
 				<div>
@@ -341,6 +428,8 @@ export default {
 				this.toggleChildren({ child: "songs", force: false });
 				this.toggleChildren({ child: "songs", force: false });
 			} else if (this.currentTab.startsWith("users")) {
 			} else if (this.currentTab.startsWith("users")) {
 				this.toggleChildren({ child: "users", force: false });
 				this.toggleChildren({ child: "users", force: false });
+			} else if (this.currentTab.startsWith("youtube")) {
+				this.toggleChildren({ child: "youtube", force: false });
 			}
 			}
 			this.currentTab = this.getTabFromPath();
 			this.currentTab = this.getTabFromPath();
 			if (this.$refs[`${this.currentTab}-tab`])
 			if (this.$refs[`${this.currentTab}-tab`])
@@ -353,6 +442,8 @@ export default {
 				this.toggleChildren({ child: "songs", force: true });
 				this.toggleChildren({ child: "songs", force: true });
 			else if (this.currentTab.startsWith("users"))
 			else if (this.currentTab.startsWith("users"))
 				this.toggleChildren({ child: "users", force: true });
 				this.toggleChildren({ child: "users", force: true });
+			else if (this.currentTab.startsWith("youtube"))
+				this.toggleChildren({ child: "youtube", force: true });
 		},
 		},
 		toggleKeyboardShortcutsHelper() {
 		toggleKeyboardShortcutsHelper() {
 			this.$refs.keyboardShortcutsHelper.toggleBox();
 			this.$refs.keyboardShortcutsHelper.toggleBox();
@@ -383,22 +474,36 @@ export default {
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
 .night-mode {
 .night-mode {
-	.main-container .admin-area .admin-sidebar .inner {
-		.top {
-			background-color: var(--dark-grey-3);
-		}
-
-		.bottom {
-			background-color: var(--dark-grey-2);
+	.main-container .admin-area {
+		.admin-sidebar .inner {
+			.top {
+				background-color: var(--dark-grey-3);
+			}
 
 
-			.sidebar-item {
+			.bottom {
 				background-color: var(--dark-grey-2);
 				background-color: var(--dark-grey-2);
-				border-color: var(--dark-grey-3);
 
 
-				&,
-				&.with-children .sidebar-item-child,
-				&.with-children > span > a {
-					color: var(--white);
+				.sidebar-item {
+					background-color: var(--dark-grey-2);
+					border-color: var(--dark-grey-3);
+
+					&,
+					&.with-children .sidebar-item-child,
+					&.with-children > span > a {
+						color: var(--white);
+					}
+				}
+			}
+		}
+
+		:deep(.admin-content .admin-container .admin-tab-container) {
+			.admin-tab {
+				.card {
+					background-color: var(--dark-grey-3);
+
+					p {
+						color: var(--light-grey-2);
+					}
 				}
 				}
 			}
 			}
 		}
 		}
@@ -601,28 +706,88 @@ export default {
 					padding: 10px 10px 20px 10px;
 					padding: 10px 10px 20px 10px;
 
 
 					.admin-tab {
 					.admin-tab {
+						display: flex;
+						flex-direction: column;
+						width: 100%;
 						max-width: 1900px;
 						max-width: 1900px;
 						margin: 0 auto;
 						margin: 0 auto;
 						padding: 0 10px;
 						padding: 0 10px;
-					}
 
 
-					.admin-tab,
-					.container {
-						.button-row {
+						.card {
 							display: flex;
 							display: flex;
-							flex-direction: row;
-							flex-wrap: wrap;
-							justify-content: center;
-							margin-bottom: 5px;
+							flex-grow: 1;
+							flex-direction: column;
+							padding: 20px;
+							margin: 10px 0;
+							border-radius: @border-radius;
+							background-color: var(--white);
+							color: var(--dark-grey);
+							box-shadow: @box-shadow;
+
+							h1 {
+								font-size: 36px;
+								margin: 0 0 5px 0;
+							}
 
 
-							& > .button,
-							& > span {
-								margin: 5px 0;
-								&:not(:first-child) {
-									margin-left: 5px;
+							h4 {
+								font-size: 22px;
+								margin: 0;
+							}
+
+							h5 {
+								font-size: 18px;
+								margin: 0;
+							}
+
+							hr {
+								margin: 10px 0;
+							}
+
+							&.tab-info {
+								flex-direction: row;
+								flex-wrap: wrap;
+
+								.info-row {
+									display: flex;
+									flex-grow: 1;
+									flex-direction: column;
+								}
+
+								.button-row {
+									display: flex;
+									flex-direction: row;
+									flex-wrap: wrap;
+									justify-content: center;
+									margin: auto 0;
+									padding: 5px 0;
+
+									& > .button,
+									& > span {
+										margin: auto 0;
+										&:not(:first-child) {
+											margin-left: 5px;
+										}
+									}
+
+									& > span > .control.has-addons {
+										margin-bottom: 0 !important;
+									}
 								}
 								}
 							}
 							}
 						}
 						}
+
+						@media screen and (min-width: 980px) {
+							&.container {
+								margin: 0 auto;
+								max-width: 960px;
+							}
+						}
+
+						@media screen and (min-width: 1180px) {
+							&.container {
+								max-width: 1200px;
+							}
+						}
 					}
 					}
 				}
 				}
 			}
 			}
@@ -630,10 +795,6 @@ export default {
 	}
 	}
 }
 }
 
 
-:deep(.container) {
-	position: relative;
-}
-
 :deep(.box) {
 :deep(.box) {
 	box-shadow: @box-shadow;
 	box-shadow: @box-shadow;
 	display: block;
 	display: block;
@@ -658,17 +819,4 @@ export default {
 		}
 		}
 	}
 	}
 }
 }
-
-@media screen and (min-width: 980px) {
-	:deep(.container) {
-		margin: 0 auto;
-		max-width: 960px;
-	}
-}
-
-@media screen and (min-width: 1180px) {
-	:deep(.container) {
-		max-width: 1200px;
-	}
-}
 </style>
 </style>

+ 2 - 7
frontend/src/pages/Home.vue

@@ -168,10 +168,9 @@
 														siteSettings.sitename
 														siteSettings.sitename
 													}}</span
 													}}</span
 												>
 												>
-												<user-id-to-username
+												<user-link
 													v-else
 													v-else
 													:user-id="element.owner"
 													:user-id="element.owner"
-													:link="true"
 												/>
 												/>
 											</span>
 											</span>
 										</p>
 										</p>
@@ -418,10 +417,9 @@
 											:title="siteSettings.sitename"
 											:title="siteSettings.sitename"
 											>{{ siteSettings.sitename }}</span
 											>{{ siteSettings.sitename }}</span
 										>
 										>
-										<user-id-to-username
+										<user-link
 											v-else
 											v-else
 											:user-id="station.owner"
 											:user-id="station.owner"
-											:link="true"
 										/>
 										/>
 									</span>
 									</span>
 								</p>
 								</p>
@@ -521,14 +519,11 @@ import { mapState, mapGetters, mapActions } from "vuex";
 import draggable from "vuedraggable";
 import draggable from "vuedraggable";
 import Toast from "toasters";
 import Toast from "toasters";
 
 
-import SongThumbnail from "@/components/SongThumbnail.vue";
-
 import ws from "@/ws";
 import ws from "@/ws";
 import keyboardShortcuts from "@/keyboardShortcuts";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
 
 export default {
 export default {
 	components: {
 	components: {
-		SongThumbnail,
 		draggable
 		draggable
 	},
 	},
 	data() {
 	data() {

+ 1 - 2
frontend/src/pages/News.vue

@@ -14,10 +14,9 @@
 					<div class="info">
 					<div class="info">
 						<hr />
 						<hr />
 						By
 						By
-						<user-id-to-username
+						<user-link
 							:user-id="item.createdBy"
 							:user-id="item.createdBy"
 							:alt="item.createdBy"
 							:alt="item.createdBy"
-							:link="true"
 						/>&nbsp;<span :title="new Date(item.createdAt)">
 						/>&nbsp;<span :title="new Date(item.createdAt)">
 							{{
 							{{
 								formatDistance(item.createdAt, new Date(), {
 								formatDistance(item.createdAt, new Date(), {

+ 3 - 3
frontend/src/pages/Profile/Tabs/RecentActivity.vue

@@ -109,8 +109,8 @@ export default {
 	methods: {
 	methods: {
 		init() {
 		init() {
 			if (this.myUserId !== this.userId)
 			if (this.myUserId !== this.userId)
-				this.getUsernameFromId(this.userId).then(username => {
-					if (username) this.username = username;
+				this.getBasicUser(this.userId).then(user => {
+					if (user && user.username) this.username = user.username;
 				});
 				});
 
 
 			this.socket.dispatch("activities.length", this.userId, res => {
 			this.socket.dispatch("activities.length", this.userId, res => {
@@ -154,7 +154,7 @@ export default {
 
 
 			return this.maxPosition === this.position;
 			return this.maxPosition === this.position;
 		},
 		},
-		...mapActions("user/auth", ["getUsernameFromId"])
+		...mapActions("user/auth", ["getBasicUser"])
 	}
 	}
 };
 };
 </script>
 </script>

+ 1 - 1
frontend/src/pages/Profile/index.vue

@@ -148,7 +148,7 @@ export default {
 	methods: {
 	methods: {
 		init() {
 		init() {
 			this.socket.dispatch(
 			this.socket.dispatch(
-				"users.findByUsername",
+				"users.getBasicUser",
 				this.$route.params.username,
 				this.$route.params.username,
 				res => {
 				res => {
 					if (res.status === "error") this.$router.push("/404");
 					if (res.status === "error") this.$router.push("/404");

+ 2 - 2
frontend/src/pages/Station/Sidebar/Users.vue

@@ -44,10 +44,10 @@
 					>
 					>
 						<profile-picture
 						<profile-picture
 							:avatar="user.avatar"
 							:avatar="user.avatar"
-							:name="user.name ? user.name : user.username"
+							:name="user.name || user.username"
 						/>
 						/>
 
 
-						{{ user.username }}
+						{{ user.name || user.username }}
 					</router-link>
 					</router-link>
 				</li>
 				</li>
 			</ul>
 			</ul>

+ 33 - 25
frontend/src/pages/Station/index.vue

@@ -605,7 +605,11 @@
 			<main-footer />
 			<main-footer />
 		</div>
 		</div>
 
 
-		<floating-box id="player-debug-box" ref="playerDebugBox">
+		<floating-box
+			id="player-debug-box"
+			ref="playerDebugBox"
+			title="Station Debug"
+		>
 			<template #body>
 			<template #body>
 				<span><b>No song</b>: {{ noSong }}</span>
 				<span><b>No song</b>: {{ noSong }}</span>
 				<span><b>Song id</b>: {{ currentSong._id }}</span>
 				<span><b>Song id</b>: {{ currentSong._id }}</span>
@@ -691,6 +695,7 @@
 		<floating-box
 		<floating-box
 			id="keyboardShortcutsHelper"
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
+			title="Station Keyboard Shortcuts"
 		>
 		>
 			<template #body>
 			<template #body>
 				<div>
 				<div>
@@ -1030,7 +1035,7 @@ export default {
 			return true;
 			return true;
 		});
 		});
 
 
-		this.socket.on("event:song.liked", res => {
+		this.socket.on("event:ratings.liked", res => {
 			if (!this.noSong) {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
 					this.updateCurrentSongRatings(res.data);
@@ -1038,7 +1043,7 @@ export default {
 			}
 			}
 		});
 		});
 
 
-		this.socket.on("event:song.disliked", res => {
+		this.socket.on("event:ratings.disliked", res => {
 			if (!this.noSong) {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
 					this.updateCurrentSongRatings(res.data);
@@ -1046,7 +1051,7 @@ export default {
 			}
 			}
 		});
 		});
 
 
-		this.socket.on("event:song.unliked", res => {
+		this.socket.on("event:ratings.unliked", res => {
 			if (!this.noSong) {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
 					this.updateCurrentSongRatings(res.data);
@@ -1054,7 +1059,7 @@ export default {
 			}
 			}
 		});
 		});
 
 
-		this.socket.on("event:song.undisliked", res => {
+		this.socket.on("event:ratings.undisliked", res => {
 			if (!this.noSong) {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateCurrentSongRatings(res.data);
 					this.updateCurrentSongRatings(res.data);
@@ -1062,7 +1067,7 @@ export default {
 			}
 			}
 		});
 		});
 
 
-		this.socket.on("event:song.ratings.updated", res => {
+		this.socket.on("event:ratings.updated", res => {
 			if (!this.noSong) {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
 					this.updateOwnCurrentSongRatings(res.data);
 					this.updateOwnCurrentSongRatings(res.data);
@@ -1416,10 +1421,12 @@ export default {
 				);
 				);
 
 
 				this.socket.dispatch(
 				this.socket.dispatch(
-					"songs.getSongRatings",
-					currentSong._id,
+					"media.getRatings",
+					currentSong.youtubeId,
 					res => {
 					res => {
-						if (currentSong._id === this.currentSong._id) {
+						if (
+							currentSong.youtubeId === this.currentSong.youtubeId
+						) {
 							const { likes, dislikes } = res.data;
 							const { likes, dislikes } = res.data;
 							this.updateCurrentSongRatings({ likes, dislikes });
 							this.updateCurrentSongRatings({ likes, dislikes });
 						}
 						}
@@ -1428,7 +1435,7 @@ export default {
 
 
 				if (this.loggedIn) {
 				if (this.loggedIn) {
 					this.socket.dispatch(
 					this.socket.dispatch(
-						"songs.getOwnSongRatings",
+						"media.getOwnRatings",
 						currentSong.youtubeId,
 						currentSong.youtubeId,
 						res => {
 						res => {
 							console.log("getOwnSongRatings", res);
 							console.log("getOwnSongRatings", res);
@@ -1441,10 +1448,10 @@ export default {
 
 
 								if (
 								if (
 									this.autoSkipDisliked &&
 									this.autoSkipDisliked &&
-									res.data.disliked === true
+									res.data.disliked === true &&
+									!(this.localPaused || this.stationPaused)
 								) {
 								) {
-									this.voteSkipStation();
-									new Toast(
+									this.voteSkipStation(
 										"Automatically voted to skip disliked song."
 										"Automatically voted to skip disliked song."
 									);
 									);
 								}
 								}
@@ -1509,12 +1516,12 @@ export default {
 							console.log("error with youtube video", err);
 							console.log("error with youtube video", err);
 
 
 							if (err.data === 150 && this.loggedIn) {
 							if (err.data === 150 && this.loggedIn) {
-								new Toast(
-									"Automatically voted to skip as this song isn't available for you."
-								);
-
-								// automatically vote to skip
-								this.voteSkipStation();
+								if (!(this.localPaused || this.stationPaused)) {
+									// automatically vote to skip
+									this.voteSkipStation(
+										"Automatically voted to skip as this song isn't available for you."
+									);
+								}
 
 
 								// persistent message while song is playing
 								// persistent message while song is playing
 								const persistentToast = new Toast({
 								const persistentToast = new Toast({
@@ -1784,7 +1791,7 @@ export default {
 				}
 				}
 			);
 			);
 		},
 		},
-		voteSkipStation() {
+		voteSkipStation(message) {
 			this.socket.dispatch(
 			this.socket.dispatch(
 				"stations.voteSkip",
 				"stations.voteSkip",
 				this.station._id,
 				this.station._id,
@@ -1793,7 +1800,8 @@ export default {
 						new Toast(`Error: ${data.message}`);
 						new Toast(`Error: ${data.message}`);
 					else
 					else
 						new Toast(
 						new Toast(
-							"Successfully voted to skip the current song."
+							message ||
+								"Successfully voted to skip the current song."
 						);
 						);
 				}
 				}
 			);
 			);
@@ -1845,7 +1853,7 @@ export default {
 		toggleLike() {
 		toggleLike() {
 			if (this.currentSong.liked)
 			if (this.currentSong.liked)
 				this.socket.dispatch(
 				this.socket.dispatch(
-					"songs.unlike",
+					"media.unlike",
 					this.currentSong.youtubeId,
 					this.currentSong.youtubeId,
 					res => {
 					res => {
 						if (res.status !== "success")
 						if (res.status !== "success")
@@ -1854,7 +1862,7 @@ export default {
 				);
 				);
 			else
 			else
 				this.socket.dispatch(
 				this.socket.dispatch(
-					"songs.like",
+					"media.like",
 					this.currentSong.youtubeId,
 					this.currentSong.youtubeId,
 					res => {
 					res => {
 						if (res.status !== "success")
 						if (res.status !== "success")
@@ -1865,7 +1873,7 @@ export default {
 		toggleDislike() {
 		toggleDislike() {
 			if (this.currentSong.disliked)
 			if (this.currentSong.disliked)
 				return this.socket.dispatch(
 				return this.socket.dispatch(
-					"songs.undislike",
+					"media.undislike",
 					this.currentSong.youtubeId,
 					this.currentSong.youtubeId,
 					res => {
 					res => {
 						if (res.status !== "success")
 						if (res.status !== "success")
@@ -1874,7 +1882,7 @@ export default {
 				);
 				);
 
 
 			return this.socket.dispatch(
 			return this.socket.dispatch(
-				"songs.dislike",
+				"media.dislike",
 				this.currentSong.youtubeId,
 				this.currentSong.youtubeId,
 				res => {
 				res => {
 					if (res.status !== "success")
 					if (res.status !== "success")

+ 6 - 3
frontend/src/store/index.js

@@ -8,6 +8,7 @@ import settings from "./modules/settings";
 import modalVisibility from "./modules/modalVisibility";
 import modalVisibility from "./modules/modalVisibility";
 import station from "./modules/station";
 import station from "./modules/station";
 import admin from "./modules/admin";
 import admin from "./modules/admin";
+import longJobs from "./modules/longJobs";
 
 
 const emptyModule = {
 const emptyModule = {
 	namespaced: true
 	namespaced: true
@@ -27,20 +28,22 @@ export default createStore({
 				editSong: emptyModule,
 				editSong: emptyModule,
 				editSongs: emptyModule,
 				editSongs: emptyModule,
 				importAlbum: emptyModule,
 				importAlbum: emptyModule,
-				importPlaylist: emptyModule,
 				editPlaylist: emptyModule,
 				editPlaylist: emptyModule,
 				manageStation: emptyModule,
 				manageStation: emptyModule,
 				editUser: emptyModule,
 				editUser: emptyModule,
 				whatIsNew: emptyModule,
 				whatIsNew: emptyModule,
 				createStation: emptyModule,
 				createStation: emptyModule,
 				editNews: emptyModule,
 				editNews: emptyModule,
+				viewApiRequest: emptyModule,
 				viewPunishment: emptyModule,
 				viewPunishment: emptyModule,
 				report: emptyModule,
 				report: emptyModule,
 				viewReport: emptyModule,
 				viewReport: emptyModule,
 				confirm: emptyModule,
 				confirm: emptyModule,
-				bulkActions: emptyModule
+				bulkActions: emptyModule,
+				viewYoutubeVideo: emptyModule
 			}
 			}
-		}
+		},
+		longJobs
 	},
 	},
 	strict: false
 	strict: false
 });
 });

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

@@ -69,7 +69,9 @@ export default {
 	namespaced: true,
 	namespaced: true,
 	state: {
 	state: {
 		childrenActive: {
 		childrenActive: {
-			users: false
+			songs: false,
+			users: false,
+			youtube: false
 		}
 		}
 	},
 	},
 	getters: {},
 	getters: {},

+ 56 - 0
frontend/src/store/modules/longJobs.js

@@ -0,0 +1,56 @@
+/* eslint no-param-reassign: 0 */
+
+const state = {
+	activeJobs: [],
+	removedJobIds: []
+};
+
+const getters = {};
+
+const actions = {
+	setJob: ({ commit }, job) => commit("setJob", job),
+	setJobs: ({ commit }, jobs) => commit("setJobs", jobs),
+	removeJob: ({ commit }, job) => commit("removeJob", job)
+};
+
+const mutations = {
+	setJob(state, { id, name, status, message }) {
+		if (state.removedJobIds.indexOf(id) === -1)
+			if (!state.activeJobs.find(activeJob => activeJob.id === id))
+				state.activeJobs.push({
+					id,
+					name,
+					status,
+					message
+				});
+			else
+				state.activeJobs.forEach((activeJob, index) => {
+					if (activeJob.id === id) {
+						state.activeJobs[index] = {
+							...state.activeJobs[index],
+							status,
+							message
+						};
+					}
+				});
+	},
+	setJobs(state, jobs) {
+		state.activeJobs = jobs;
+	},
+	removeJob(state, jobId) {
+		state.activeJobs.forEach((activeJob, index) => {
+			if (activeJob.id === jobId) {
+				state.activeJobs.splice(index, 1);
+				state.removedJobIds.push(jobId);
+			}
+		});
+	}
+};
+
+export default {
+	namespaced: true,
+	state,
+	getters,
+	actions,
+	mutations
+};

+ 5 - 3
frontend/src/store/modules/modalVisibility.js

@@ -6,16 +6,17 @@ import whatIsNew from "./modals/whatIsNew";
 import createStation from "./modals/createStation";
 import createStation from "./modals/createStation";
 import editNews from "./modals/editNews";
 import editNews from "./modals/editNews";
 import manageStation from "./modals/manageStation";
 import manageStation from "./modals/manageStation";
-import importPlaylist from "./modals/importPlaylist";
 import editPlaylist from "./modals/editPlaylist";
 import editPlaylist from "./modals/editPlaylist";
 import report from "./modals/report";
 import report from "./modals/report";
 import viewReport from "./modals/viewReport";
 import viewReport from "./modals/viewReport";
 import bulkActions from "./modals/bulkActions";
 import bulkActions from "./modals/bulkActions";
+import viewApiRequest from "./modals/viewApiRequest";
 import viewPunishment from "./modals/viewPunishment";
 import viewPunishment from "./modals/viewPunishment";
 import importAlbum from "./modals/importAlbum";
 import importAlbum from "./modals/importAlbum";
 import confirm from "./modals/confirm";
 import confirm from "./modals/confirm";
 import editSongs from "./modals/editSongs";
 import editSongs from "./modals/editSongs";
 import editSong from "./modals/editSong";
 import editSong from "./modals/editSong";
+import viewYoutubeVideo from "./modals/viewYoutubeVideo";
 
 
 const state = {
 const state = {
 	modals: {},
 	modals: {},
@@ -28,16 +29,17 @@ const modalModules = {
 	createStation,
 	createStation,
 	editNews,
 	editNews,
 	manageStation,
 	manageStation,
-	importPlaylist,
 	editPlaylist,
 	editPlaylist,
 	report,
 	report,
 	viewReport,
 	viewReport,
 	bulkActions,
 	bulkActions,
+	viewApiRequest,
 	viewPunishment,
 	viewPunishment,
 	importAlbum,
 	importAlbum,
 	confirm,
 	confirm,
 	editSongs,
 	editSongs,
-	editSong
+	editSong,
+	viewYoutubeVideo
 };
 };
 
 
 const getters = {};
 const getters = {};

+ 14 - 8
frontend/src/store/modules/modals/editSong.js

@@ -11,7 +11,7 @@ export default {
 			currentTime: 0,
 			currentTime: 0,
 			playbackRate: 1
 			playbackRate: 1
 		},
 		},
-		songId: null,
+		youtubeId: null,
 		song: {},
 		song: {},
 		originalSong: {},
 		originalSong: {},
 		reports: [],
 		reports: [],
@@ -26,7 +26,7 @@ export default {
 		setSong: ({ commit }, song) => commit("setSong", song),
 		setSong: ({ commit }, song) => commit("setSong", song),
 		updateOriginalSong: ({ commit }, song) =>
 		updateOriginalSong: ({ commit }, song) =>
 			commit("updateOriginalSong", song),
 			commit("updateOriginalSong", song),
-		resetSong: ({ commit }, songId) => commit("resetSong", songId),
+		resetSong: ({ commit }, youtubeId) => commit("resetSong", youtubeId),
 		stopVideo: ({ commit }) => commit("stopVideo"),
 		stopVideo: ({ commit }) => commit("stopVideo"),
 		hardStopVideo: ({ commit }) => commit("hardStopVideo"),
 		hardStopVideo: ({ commit }) => commit("hardStopVideo"),
 		loadVideoById: ({ commit }, id, skipDuration) =>
 		loadVideoById: ({ commit }, id, skipDuration) =>
@@ -58,22 +58,28 @@ export default {
 			state.tab = tab;
 			state.tab = tab;
 		},
 		},
 		editSong(state, song) {
 		editSong(state, song) {
-			state.newSong = !!song.newSong;
-			state.songId = song.newSong ? null : song.songId;
+			state.newSong = !!song.newSong || !song._id;
+			state.youtubeId = song.newSong ? null : song.youtubeId;
 			state.prefillData = song.prefill ? song.prefill : {};
 			state.prefillData = song.prefill ? song.prefill : {};
 		},
 		},
 		setSong(state, song) {
 		setSong(state, song) {
 			if (song.discogs === undefined) song.discogs = null;
 			if (song.discogs === undefined) song.discogs = null;
 			state.originalSong = JSON.parse(JSON.stringify(song));
 			state.originalSong = JSON.parse(JSON.stringify(song));
 			state.song = { ...song };
 			state.song = { ...song };
+			state.newSong = !song._id;
+			state.youtubeId = song.youtubeId;
 		},
 		},
 		updateOriginalSong(state, song) {
 		updateOriginalSong(state, song) {
 			state.originalSong = JSON.parse(JSON.stringify(song));
 			state.originalSong = JSON.parse(JSON.stringify(song));
 		},
 		},
-		resetSong(state, songId) {
-			if (state.songId === songId) state.songId = "";
-			if (state.song && state.song._id === songId) state.song = {};
-			if (state.originalSong && state.originalSong._id === songId)
+		resetSong(state, youtubeId) {
+			if (state.youtubeId === youtubeId) state.youtubeId = "";
+			if (state.song && state.song.youtubeId === youtubeId)
+				state.song = {};
+			if (
+				state.originalSong &&
+				state.originalSong.youtubeId === youtubeId
+			)
 				state.originalSong = {};
 				state.originalSong = {};
 		},
 		},
 		stopVideo(state) {
 		stopVideo(state) {

+ 4 - 4
frontend/src/store/modules/modals/editSongs.js

@@ -3,7 +3,7 @@
 export default {
 export default {
 	namespaced: true,
 	namespaced: true,
 	state: {
 	state: {
-		songIds: [],
+		youtubeIds: [],
 		songPrefillData: {}
 		songPrefillData: {}
 	},
 	},
 	getters: {},
 	getters: {},
@@ -13,16 +13,16 @@ export default {
 	},
 	},
 	mutations: {
 	mutations: {
 		init(state, { songs }) {
 		init(state, { songs }) {
-			state.songIds = songs.map(song => song.songId);
+			state.youtubeIds = songs.map(song => song.youtubeId);
 			state.songPrefillData = Object.fromEntries(
 			state.songPrefillData = Object.fromEntries(
 				songs.map(song => [
 				songs.map(song => [
-					song.songId,
+					song.youtubeId,
 					song.prefill ? song.prefill : {}
 					song.prefill ? song.prefill : {}
 				])
 				])
 			);
 			);
 		}
 		}
 		// resetSongs(state) {
 		// resetSongs(state) {
-		// 	state.songIds = [];
+		// 	state.youtubeIds = [];
 		// 	state.songPrefillData = {};
 		// 	state.songPrefillData = {};
 		// }
 		// }
 	}
 	}

+ 5 - 0
frontend/src/store/modules/modals/importAlbum.js

@@ -12,6 +12,7 @@ export default {
 	},
 	},
 	getters: {},
 	getters: {},
 	actions: {
 	actions: {
+		init: ({ commit }, data) => commit("init", data),
 		showDiscogsTab: ({ commit }, tab) => commit("showDiscogsTab", tab),
 		showDiscogsTab: ({ commit }, tab) => commit("showDiscogsTab", tab),
 		selectDiscogsAlbum: ({ commit }, discogsAlbum) =>
 		selectDiscogsAlbum: ({ commit }, discogsAlbum) =>
 			commit("selectDiscogsAlbum", discogsAlbum),
 			commit("selectDiscogsAlbum", discogsAlbum),
@@ -31,6 +32,10 @@ export default {
 			commit("updatePlaylistSong", updatedSong)
 			commit("updatePlaylistSong", updatedSong)
 	},
 	},
 	mutations: {
 	mutations: {
+		init(state, { songs }) {
+			state.originalPlaylistSongs = JSON.parse(JSON.stringify(songs));
+			state.playlistSongs = JSON.parse(JSON.stringify(songs));
+		},
 		showDiscogsTab(state, tab) {
 		showDiscogsTab(state, tab) {
 			state.discogsTab = tab;
 			state.discogsTab = tab;
 		},
 		},

+ 25 - 0
frontend/src/store/modules/modals/viewApiRequest.js

@@ -0,0 +1,25 @@
+/* eslint no-param-reassign: 0 */
+
+export default {
+	namespaced: true,
+	state: {
+		requestId: null,
+		request: {},
+		removeAction: null
+	},
+	getters: {},
+	actions: {
+		init: ({ commit }, data) => commit("init", data),
+		viewApiRequest: ({ commit }, request) =>
+			commit("viewApiRequest", request)
+	},
+	mutations: {
+		init(state, { requestId, removeAction }) {
+			state.requestId = requestId;
+			state.removeAction = removeAction;
+		},
+		viewApiRequest(state, request) {
+			state.request = request;
+		}
+	}
+};

+ 83 - 0
frontend/src/store/modules/modals/viewYoutubeVideo.js

@@ -0,0 +1,83 @@
+/* eslint no-param-reassign: 0 */
+
+export default {
+	namespaced: true,
+	state: {
+		videoId: null,
+		youtubeId: null,
+		video: {},
+		player: {
+			error: false,
+			errorMessage: "",
+			player: null,
+			paused: true,
+			playerReady: false,
+			autoPlayed: false,
+			duration: "0.000",
+			currentTime: 0,
+			playbackRate: 1,
+			videoNote: "",
+			volume: 0,
+			muted: false,
+			showRateDropdown: false
+		}
+	},
+	getters: {},
+	actions: {
+		init: ({ commit }, data) => commit("init", data),
+		viewYoutubeVideo: ({ commit }, video) =>
+			commit("viewYoutubeVideo", video),
+		updatePlayer: ({ commit }, player) => commit("updatePlayer", player),
+		stopVideo: ({ commit }) => commit("stopVideo"),
+		loadVideoById: ({ commit }, id) => commit("loadVideoById", id),
+		pauseVideo: ({ commit }, status) => commit("pauseVideo", status),
+		setPlaybackRate: ({ commit }, rate) => commit("setPlaybackRate", rate)
+	},
+	mutations: {
+		init(state, { videoId, youtubeId }) {
+			state.videoId = videoId;
+			state.youtubeId = youtubeId;
+		},
+		viewYoutubeVideo(state, video) {
+			state.videoId = state.videoId || video._id;
+			state.youtubeId = video.youtubeId || video.youtubeId;
+			state.video = video;
+		},
+		updatePlayer(state, player) {
+			state.player = Object.assign(state.player, player);
+		},
+		stopVideo(state) {
+			if (state.player.player && state.player.player.pauseVideo) {
+				state.player.player.pauseVideo();
+				state.player.player.seekTo(0);
+			}
+		},
+		loadVideoById(state, id) {
+			state.player.player.loadVideoById(id);
+		},
+		pauseVideo(state, status) {
+			if (
+				(state.player.player && state.player.player.pauseVideo) ||
+				state.player.playVideo
+			) {
+				if (status) state.player.player.pauseVideo();
+				else state.player.player.playVideo();
+			}
+			state.player.paused = status;
+		},
+		setPlaybackRate(state, rate) {
+			if (rate) {
+				state.player.playbackRate = rate;
+				state.player.player.setPlaybackRate(rate);
+			} else if (
+				state.player.player.getPlaybackRate() !== undefined &&
+				state.player.playbackRate !==
+					state.player.player.getPlaybackRate()
+			) {
+				state.player.player.setPlaybackRate(state.player.playbackRate);
+				state.player.playbackRate =
+					state.player.player.getPlaybackRate();
+			}
+		}
+	}
+};

+ 13 - 10
frontend/src/store/modules/user.js

@@ -105,38 +105,41 @@ const modules = {
 						.then(() => resolve())
 						.then(() => resolve())
 						.catch(() => reject());
 						.catch(() => reject());
 				}),
 				}),
-			getUsernameFromId: ({ commit, state }, userId) =>
+			getBasicUser: ({ commit, state }, userId) =>
 				new Promise(resolve => {
 				new Promise(resolve => {
 					if (typeof state.userIdMap[`Z${userId}`] !== "string") {
 					if (typeof state.userIdMap[`Z${userId}`] !== "string") {
 						if (state.userIdRequested[`Z${userId}`] !== true) {
 						if (state.userIdRequested[`Z${userId}`] !== true) {
 							commit("requestingUserId", userId);
 							commit("requestingUserId", userId);
 							ws.socket.dispatch(
 							ws.socket.dispatch(
-								"users.getUsernameFromId",
+								"users.getBasicUser",
 								userId,
 								userId,
 								res => {
 								res => {
 									if (res.status === "success") {
 									if (res.status === "success") {
-										const { username } = res.data;
+										const user = res.data;
 
 
 										commit("mapUserId", {
 										commit("mapUserId", {
 											userId,
 											userId,
-											username
+											user: {
+												name: user.name,
+												username: user.username
+											}
 										});
 										});
 
 
 										state.pendingUserIdCallbacks[
 										state.pendingUserIdCallbacks[
 											`Z${userId}`
 											`Z${userId}`
-										].forEach(cb => cb(username));
+										].forEach(cb => cb(user));
 
 
 										commit("clearPendingCallbacks", userId);
 										commit("clearPendingCallbacks", userId);
 
 
-										return resolve(username);
+										return resolve(user);
 									}
 									}
 									return resolve();
 									return resolve();
 								}
 								}
 							);
 							);
 						} else {
 						} else {
-							commit("pendingUsername", {
+							commit("pendingUser", {
 								userId,
 								userId,
-								callback: username => resolve(username)
+								callback: user => resolve(user)
 							});
 							});
 						}
 						}
 					} else {
 					} else {
@@ -155,7 +158,7 @@ const modules = {
 		},
 		},
 		mutations: {
 		mutations: {
 			mapUserId(state, data) {
 			mapUserId(state, data) {
-				state.userIdMap[`Z${data.userId}`] = data.username;
+				state.userIdMap[`Z${data.userId}`] = data.user;
 				state.userIdRequested[`Z${data.userId}`] = false;
 				state.userIdRequested[`Z${data.userId}`] = false;
 			},
 			},
 			requestingUserId(state, userId) {
 			requestingUserId(state, userId) {
@@ -163,7 +166,7 @@ const modules = {
 				if (!state.pendingUserIdCallbacks[`Z${userId}`])
 				if (!state.pendingUserIdCallbacks[`Z${userId}`])
 					state.pendingUserIdCallbacks[`Z${userId}`] = [];
 					state.pendingUserIdCallbacks[`Z${userId}`] = [];
 			},
 			},
-			pendingUsername(state, data) {
+			pendingUser(state, data) {
 				state.pendingUserIdCallbacks[`Z${data.userId}`].push(
 				state.pendingUserIdCallbacks[`Z${data.userId}`].push(
 					data.callback
 					data.callback
 				);
 				);

Some files were not shown because too many files changed in this diff