Browse Source

Merge branch 'staging'

Owen Diffey 3 years ago
parent
commit
04bac82052
100 changed files with 11952 additions and 5020 deletions
  1. 1 1
      .env.example
  2. 1 1
      .github/workflows/build-eslint.yml
  3. 4 0
      .wiki/Configuration.md
  4. 49 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. 54 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. 242 255
      backend/package-lock.json
  37. 12 12
      backend/package.json
  38. 4 4
      docker-compose.dev.yml
  39. 18 10
      docker-compose.yml
  40. 13 4
      frontend/Dockerfile
  41. 0 9
      frontend/bootstrap.sh
  42. 8 0
      frontend/entrypoint.sh
  43. 277 245
      frontend/package-lock.json
  44. 22 18
      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. 101 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. 0 1
      frontend/src/components/PlaylistTabBase.vue
  53. 2 3
      frontend/src/components/PunishmentItem.vue
  54. 0 1
      frontend/src/components/Queue.vue
  55. 19 11
      frontend/src/components/RunJobDropdown.vue
  56. 19 11
      frontend/src/components/SongItem.vue
  57. 0 110
      frontend/src/components/SongThumbnail.vue
  58. 141 0
      frontend/src/components/global/SongThumbnail.vue
  59. 0 46
      frontend/src/components/global/UserIdToUsername.vue
  60. 53 0
      frontend/src/components/global/UserLink.vue
  61. 22 6
      frontend/src/components/modals/BulkActions.vue
  62. 1 2
      frontend/src/components/modals/EditNews.vue
  63. 14 41
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  64. 0 1
      frontend/src/components/modals/EditPlaylist/index.vue
  65. 378 218
      frontend/src/components/modals/EditSong/index.vue
  66. 224 167
      frontend/src/components/modals/EditSongs.vue
  67. 29 16
      frontend/src/components/modals/ImportAlbum.vue
  68. 0 164
      frontend/src/components/modals/ImportPlaylist.vue
  69. 149 0
      frontend/src/components/modals/ViewApiRequest.vue
  70. 1 1
      frontend/src/components/modals/ViewReport.vue
  71. 1019 0
      frontend/src/components/modals/ViewYoutubeVideo.vue
  72. 2 3
      frontend/src/components/modals/WhatIsNew.vue
  73. 16 3
      frontend/src/main.js
  74. 156 0
      frontend/src/mixins/DragBox.vue
  75. 68 65
      frontend/src/pages/Admin/News.vue
  76. 77 79
      frontend/src/pages/Admin/Playlists.vue
  77. 101 103
      frontend/src/pages/Admin/Reports.vue
  78. 717 0
      frontend/src/pages/Admin/Songs/Import.vue
  79. 294 289
      frontend/src/pages/Admin/Songs/index.vue
  80. 123 125
      frontend/src/pages/Admin/Stations.vue
  81. 82 120
      frontend/src/pages/Admin/Statistics.vue
  82. 57 53
      frontend/src/pages/Admin/Users/DataRequests.vue
  83. 115 144
      frontend/src/pages/Admin/Users/Punishments.vue
  84. 96 92
      frontend/src/pages/Admin/Users/index.vue
  85. 411 0
      frontend/src/pages/Admin/YouTube/Videos.vue
  86. 411 0
      frontend/src/pages/Admin/YouTube/index.vue
  87. 203 55
      frontend/src/pages/Admin/index.vue
  88. 2 7
      frontend/src/pages/Home.vue
  89. 1 2
      frontend/src/pages/News.vue
  90. 0 1
      frontend/src/pages/Profile/Tabs/Playlists.vue
  91. 3 3
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  92. 1 1
      frontend/src/pages/Profile/index.vue
  93. 0 1
      frontend/src/pages/Station/Sidebar/Playlists.vue
  94. 2 2
      frontend/src/pages/Station/Sidebar/Users.vue
  95. 33 25
      frontend/src/pages/Station/index.vue
  96. 6 3
      frontend/src/store/index.js
  97. 3 1
      frontend/src/store/modules/admin.js
  98. 56 0
      frontend/src/store/modules/longJobs.js
  99. 5 3
      frontend/src/store/modules/modalVisibility.js
  100. 15 9
      frontend/src/store/modules/modals/editSong.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). |

+ 49 - 0
CHANGELOG.md

@@ -1,5 +1,54 @@
 # Changelog
 # Changelog
 
 
+## [v3.6.0] - 2022-06-12
+
+This release includes all changes from v3.6.0-rc1, in addition to the following. Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Fixed
+- fix: Removed tag="transition-group" from draggable components
+
+## [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 {

+ 54 - 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,8 @@ 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()];
+		if (!this.jobs[job.module.name]) this.jobs[job.module.name] = {};
+		delete this.jobs[job.module.name][job.toString()];
 	}
 	}
 
 
 	/**
 	/**
@@ -103,8 +103,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 +258,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 +298,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 +407,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 +425,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",
@@ -1110,17 +1110,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";
@@ -1140,66 +1129,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) => {
@@ -1236,14 +1171,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: {
@@ -1326,16 +1253,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) {
@@ -1351,6 +1296,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;
@@ -1413,12 +1359,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) => {
@@ -1439,6 +1387,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 });
 				}
 				}
 
 
@@ -1457,7 +1409,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)`,
@@ -1484,8 +1439,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 => {
@@ -1500,38 +1453,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,
@@ -1541,24 +1474,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) => {
@@ -1585,7 +1510,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,
@@ -1660,11 +1585,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",
@@ -2071,6 +1991,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 => {
@@ -2087,6 +2024,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(
@@ -2094,6 +2035,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." });
 			}
 			}
 		);
 		);
@@ -2106,6 +2051,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 => {
@@ -2122,6 +2084,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(
@@ -2129,6 +2095,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." });
 			}
 			}
 		);
 		);
@@ -2141,6 +2111,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 => {
@@ -2157,6 +2144,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(
@@ -2164,6 +2155,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." });
 			}
 			}
 		);
 		);
@@ -2276,6 +2271,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 => {
@@ -2293,6 +2305,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 },
@@ -2318,7 +2334,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 });
 				}
 				}
 
 
@@ -2327,7 +2346,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"
@@ -2343,6 +2365,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 => {
@@ -2360,6 +2399,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 },
@@ -2385,7 +2428,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 });
 				}
 				}
 
 
@@ -2394,7 +2440,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"
@@ -2410,6 +2459,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 => {
@@ -2431,7 +2497,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 });
 				}
 				}
 
 
@@ -2440,7 +2509,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 => {
@@ -242,6 +257,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 {
@@ -389,7 +408,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 => {
@@ -420,6 +439,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: 32, trim: true, required: true },
 	displayName: { type: String, min: 2, max: 32, 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) => {
@@ -351,35 +382,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 });
 				}
 				}
 			);
 			);
 		});
 		});
@@ -574,6 +754,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");
@@ -647,6 +828,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) => {
@@ -543,6 +491,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);
 					},
 					},
 
 
@@ -550,6 +500,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,
@@ -571,6 +523,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,
@@ -597,6 +551,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 {
@@ -649,6 +605,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,
@@ -675,6 +633,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(
@@ -724,6 +684,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,
@@ -750,6 +712,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 => {
@@ -777,6 +741,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,
@@ -824,6 +790,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();
@@ -1024,101 +991,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
 	 *
 	 *
@@ -1227,104 +1099,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
 	 *
 	 *
@@ -1345,6 +1119,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}`
@@ -1353,21 +1131,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.youtubeId === song.youtubeId)
+									);
+									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
+ 242 - 255
backend/package-lock.json


+ 12 - 12
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",
   "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,37 +10,37 @@
   "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"
   },
   },
   "dependencies": {
   "dependencies": {
     "async": "^3.2.3",
     "async": "^3.2.3",
-    "axios": "^0.26.1",
+    "axios": "^0.27.2",
     "bcrypt": "^5.0.1",
     "bcrypt": "^5.0.1",
     "bluebird": "^3.7.2",
     "bluebird": "^3.7.2",
     "body-parser": "^1.20.0",
     "body-parser": "^1.20.0",
     "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": "^3.0.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

+ 13 - 4
frontend/Dockerfile

@@ -1,9 +1,13 @@
-FROM node:16.15
+FROM node:16.15 AS musare_frontend
+
+ARG FRONTEND_MODE=prod
+ENV FRONTEND_MODE=${FRONTEND_MODE}
+ENV SUPPRESS_NO_CONFIG_WARNING=1
 
 
 RUN apt-get update
 RUN apt-get update
 RUN apt-get install nginx -y
 RUN apt-get install nginx -y
 
 
-RUN npm install -g webpack@5.72.0 webpack-cli@4.9.2
+RUN npm install -g webpack@5.73.0 webpack-cli@4.9.2
 
 
 RUN mkdir -p /opt/app
 RUN mkdir -p /opt/app
 WORKDIR /opt/app
 WORKDIR /opt/app
@@ -17,6 +21,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
+ 277 - 245
frontend/package-lock.json


+ 22 - 18
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
     "*.vue"
   ],
   ],
   "private": true,
   "private": true,
-  "version": "3.5.2",
+  "version": "3.6.0",
   "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,50 +17,54 @@
     "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": "^9.1.0",
     "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": "^11.0.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.36",
+    "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",
-    "webpack": "5.72.0",
+    "webpack": "^5.73.0",
     "webpack-bundle-analyzer": "^4.5.0",
     "webpack-bundle-analyzer": "^4.5.0",
     "webpack-merge": "^5.8.0"
     "webpack-merge": "^5.8.0"
   }
   }

+ 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 {

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

@@ -0,0 +1,101 @@
+<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",
+	// eslint-disable-next-line vue/no-reserved-component-names
+	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">

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

@@ -507,7 +507,6 @@
 					v-if="playlists.length > 0"
 					v-if="playlists.length > 0"
 				>
 				>
 					<draggable
 					<draggable
-						tag="transition-group"
 						:component-data="{
 						:component-data="{
 							name: !drag ? 'draggable-list-transition' : null
 							name: !drag ? 'draggable-list-transition' : null
 						}"
 						}"

+ 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"
 					/>
 					/>

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

@@ -8,7 +8,6 @@
 			}"
 			}"
 		>
 		>
 			<draggable
 			<draggable
-				tag="transition-group"
 				:component-data="{
 				:component-data="{
 					name: !drag ? 'draggable-list-transition' : null
 					name: !drag ? 'draggable-list-transition' : null
 				}"
 				}"

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

+ 0 - 1
frontend/src/components/modals/EditPlaylist/index.vue

@@ -93,7 +93,6 @@
 
 
 					<aside class="menu">
 					<aside class="menu">
 						<draggable
 						<draggable
-							tag="transition-group"
 							:component-data="{
 							:component-data="{
 								name: !drag ? 'draggable-list-transition' : null
 								name: !drag ? 'draggable-list-transition' : null
 							}"
 							}"

File diff suppressed because it is too large
+ 378 - 218
frontend/src/components/modals/EditSong/index.vue


+ 224 - 167
frontend/src/components/modals/EditSongs.vue

@@ -40,103 +40,114 @@
 					</header>
 					</header>
 					<section class="sidebar-body">
 					<section class="sidebar-body">
 						<div
 						<div
-							class="item"
-							v-for="(
-								{ status, flagged, song }, index
-							) in filteredItems"
-							:key="song._id"
-							:ref="`edit-songs-item-${song._id}`"
+							v-show="filteredItems.length > 0"
+							class="edit-songs-items"
 						>
 						>
-							<song-item
-								:song="song"
-								:thumbnail="false"
-								:duration="false"
-								:disabled-actions="
-									song.removed ? ['all'] : ['report', 'edit']
-								"
-								:class="{
-									updated: song.updated,
-									removed: song.removed
-								}"
+							<div
+								class="item"
+								v-for="(
+									{ status, flagged, song }, index
+								) in filteredItems"
+								:key="`edit-songs-item-${index}`"
+								:ref="`edit-songs-item-${song.youtubeId}`"
 							>
 							>
-								<template #leftIcon>
-									<i
-										v-if="currentSong._id === song._id"
-										class="material-icons item-icon editing-icon"
-										content="Currently editing song"
-										v-tippy="{ theme: 'info' }"
-										@click="toggleDone(index)"
-										>edit</i
-									>
-									<i
-										v-else-if="song.removed"
-										class="material-icons item-icon removed-icon"
-										content="Song removed"
-										v-tippy="{ theme: 'info' }"
-										>delete_forever</i
-									>
-									<i
-										v-else-if="status === 'error'"
-										class="material-icons item-icon error-icon"
-										content="Error saving song"
-										v-tippy="{ theme: 'info' }"
-										@click="toggleDone(index)"
-										>error</i
-									>
-									<i
-										v-else-if="status === 'saving'"
-										class="material-icons item-icon saving-icon"
-										content="Currently saving song"
-										v-tippy="{ theme: 'info' }"
-										>pending</i
-									>
-									<i
-										v-else-if="flagged"
-										class="material-icons item-icon flag-icon"
-										content="Song flagged"
-										v-tippy="{ theme: 'info' }"
-										@click="toggleDone(index)"
-										>flag_circle</i
-									>
-									<i
-										v-else-if="status === 'done'"
-										class="material-icons item-icon done-icon"
-										content="Song marked complete"
-										v-tippy="{ theme: 'info' }"
-										@click="toggleDone(index)"
-										>check_circle</i
-									>
-									<i
-										v-else-if="status === 'todo'"
-										class="material-icons item-icon todo-icon"
-										content="Song marked todo"
-										v-tippy="{ theme: 'info' }"
-										@click="toggleDone(index)"
-										>cancel</i
-									>
-								</template>
-								<template v-if="!song.removed" #actions>
-									<i
-										class="material-icons edit-icon"
-										content="Edit Song"
-										v-tippy
-										@click="pickSong(song)"
-									>
-										edit
-									</i>
-								</template>
-								<template #tippyActions>
-									<i
-										class="material-icons flag-icon"
-										:class="{ flagged }"
-										content="Toggle Flag"
-										v-tippy
-										@click="toggleFlag(index)"
-									>
-										flag_circle
-									</i>
-								</template>
-							</song-item>
+								<song-item
+									:song="song"
+									:thumbnail="false"
+									:duration="false"
+									:disabled-actions="
+										song.removed
+											? ['all']
+											: ['report', 'edit']
+									"
+									:class="{
+										updated: song.updated,
+										removed: song.removed
+									}"
+								>
+									<template #leftIcon>
+										<i
+											v-if="
+												currentSong.youtubeId ===
+													song.youtubeId &&
+												!song.removed
+											"
+											class="material-icons item-icon editing-icon"
+											content="Currently editing song"
+											v-tippy="{ theme: 'info' }"
+											@click="toggleDone(index)"
+											>edit</i
+										>
+										<i
+											v-else-if="song.removed"
+											class="material-icons item-icon removed-icon"
+											content="Song removed"
+											v-tippy="{ theme: 'info' }"
+											>delete_forever</i
+										>
+										<i
+											v-else-if="status === 'error'"
+											class="material-icons item-icon error-icon"
+											content="Error saving song"
+											v-tippy="{ theme: 'info' }"
+											@click="toggleDone(index)"
+											>error</i
+										>
+										<i
+											v-else-if="status === 'saving'"
+											class="material-icons item-icon saving-icon"
+											content="Currently saving song"
+											v-tippy="{ theme: 'info' }"
+											>pending</i
+										>
+										<i
+											v-else-if="flagged"
+											class="material-icons item-icon flag-icon"
+											content="Song flagged"
+											v-tippy="{ theme: 'info' }"
+											@click="toggleDone(index)"
+											>flag_circle</i
+										>
+										<i
+											v-else-if="status === 'done'"
+											class="material-icons item-icon done-icon"
+											content="Song marked complete"
+											v-tippy="{ theme: 'info' }"
+											@click="toggleDone(index)"
+											>check_circle</i
+										>
+										<i
+											v-else-if="status === 'todo'"
+											class="material-icons item-icon todo-icon"
+											content="Song marked todo"
+											v-tippy="{ theme: 'info' }"
+											@click="toggleDone(index)"
+											>cancel</i
+										>
+									</template>
+									<template v-if="!song.removed" #actions>
+										<i
+											class="material-icons edit-icon"
+											content="Edit Song"
+											v-tippy
+											@click="pickSong(song)"
+										>
+											edit
+										</i>
+									</template>
+									<template #tippyActions>
+										<i
+											class="material-icons flag-icon"
+											:class="{ flagged }"
+											content="Toggle Flag"
+											v-tippy
+											@click="toggleFlag(index)"
+										>
+											flag_circle
+										</i>
+									</template>
+								</song-item>
+							</div>
 						</div>
 						</div>
 						<p v-if="filteredItems.length === 0" class="no-items">
 						<p v-if="filteredItems.length === 0" class="no-items">
 							{{
 							{{
@@ -201,12 +212,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,58 +228,82 @@ 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({
 			socket: "websockets/getSocket"
 			socket: "websockets/getSocket"
 		})
 		})
 	},
 	},
-	async mounted() {
-		this.socket.dispatch("apis.joinRoom", "edit-songs");
-
+	beforeMount() {
+		console.log("EDITSONGS BEFOREMOUNT");
 		this.$store.registerModule(
 		this.$store.registerModule(
 			["modals", "editSongs", this.modalUuid, "editSong"],
 			["modals", "editSongs", this.modalUuid, "editSong"],
 			editSong
 			editSong
 		);
 		);
+	},
+	async mounted() {
+		console.log("EDITSONGS MOUNTED");
 
 
-		this.socket.dispatch("songs.getSongsFromSongIds", this.songIds, res => {
-			res.data.songs.forEach(song => {
-				this.items.push({
-					status: "todo",
-					flagged: false,
-					song
-				});
-			});
+		this.socket.dispatch("apis.joinRoom", "edit-songs");
+
+		this.socket.dispatch(
+			"songs.getSongsFromYoutubeIds",
+			this.youtubeIds,
+			res => {
+				if (res.data.songs.length === 0) {
+					this.closeThisModal();
+					new Toast("You can't edit 0 songs.");
+				} else {
+					this.items = res.data.songs.map(song => ({
+						status: "todo",
+						flagged: false,
+						song
+					}));
+					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);
+				if (index >= 0)
+					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);
-				this.items[index].song = {
-					...this.items[index].song,
-					...res.data.song,
-					updated: true
-				};
+					.map(item => item.song.youtubeId)
+					.indexOf(res.data.song.youtubeId);
+				if (index >= 0)
+					this.items[index].song = {
+						...this.items[index].song,
+						...res.data.song,
+						updated: true
+					};
 			},
 			},
 			{ modalUuid: this.modalUuid }
 			{ modalUuid: this.modalUuid }
 		);
 		);
@@ -279,30 +314,45 @@ export default {
 				const index = this.items
 				const index = this.items
 					.map(item => item.song._id)
 					.map(item => item.song._id)
 					.indexOf(res.data.songId);
 					.indexOf(res.data.songId);
-				this.items[index].song.removed = true;
+				if (index >= 0) this.items[index].song.removed = true;
+			},
+			{ 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 >= 0) this.items[index].song.removed = true;
 			},
 			},
 			{ modalUuid: this.modalUuid }
 			{ modalUuid: this.modalUuid }
 		);
 		);
 	},
 	},
 	beforeUnmount() {
 	beforeUnmount() {
+		console.log("EDITSONGS BEFORE UNMOUNT");
 		this.socket.dispatch("apis.leaveRoom", "edit-songs");
 		this.socket.dispatch("apis.leaveRoom", "edit-songs");
 	},
 	},
 	unmounted() {
 	unmounted() {
+		console.log("EDITSONGS UNMOUNTED");
 		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
 		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
 		this.$store.unregisterModule(["modals", "editSongs", this.modalUuid]);
 		this.$store.unregisterModule(["modals", "editSongs", this.modalUuid]);
 	},
 	},
 	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 +370,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 +397,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";
 		},
 		},
@@ -519,45 +570,51 @@ export default {
 		overflow: auto;
 		overflow: auto;
 		padding: 10px;
 		padding: 10px;
 
 
-		.item {
+		.edit-songs-items {
 			display: flex;
 			display: flex;
-			flex-direction: row;
-			align-items: center;
-			column-gap: 8px;
-
-			:deep(.song-item) {
-				.item-icon {
-					margin-right: 10px;
-					cursor: pointer;
-				}
+			flex-direction: column;
+			row-gap: 8px;
 
 
-				.removed-icon,
-				.error-icon {
-					color: var(--red);
-				}
+			.item {
+				display: flex;
+				flex-direction: row;
+				align-items: center;
+				column-gap: 8px;
+
+				:deep(.song-item) {
+					.item-icon {
+						margin-right: 10px;
+						cursor: pointer;
+					}
 
 
-				.saving-icon,
-				.todo-icon,
-				.editing-icon {
-					color: var(--primary-color);
-				}
+					.removed-icon,
+					.error-icon {
+						color: var(--red);
+					}
 
 
-				.done-icon {
-					color: var(--green);
-				}
+					.saving-icon,
+					.todo-icon,
+					.editing-icon {
+						color: var(--primary-color);
+					}
 
 
-				.flag-icon {
-					color: var(--orange);
+					.done-icon {
+						color: var(--green);
+					}
+
+					.flag-icon {
+						color: var(--orange);
 
 
-					&.flagged {
-						color: var(--grey);
+						&.flagged {
+							color: var(--grey);
+						}
 					}
 					}
-				}
 
 
-				&.removed {
-					filter: grayscale(100%);
-					cursor: not-allowed;
-					user-select: none;
+					&.removed {
+						filter: grayscale(100%);
+						cursor: not-allowed;
+						user-select: none;
+					}
 				}
 				}
 			}
 			}
 		}
 		}

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

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

@@ -0,0 +1,717 @@
+<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: "",
+				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: "",
+				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: 200 }"
+		>
+			<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(), {

+ 0 - 1
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -19,7 +19,6 @@
 			<hr class="section-horizontal-rule" />
 			<hr class="section-horizontal-rule" />
 
 
 			<draggable
 			<draggable
-				tag="transition-group"
 				:component-data="{
 				:component-data="{
 					name: !drag ? 'draggable-list-transition' : null
 					name: !drag ? 'draggable-list-transition' : null
 				}"
 				}"

+ 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");

+ 0 - 1
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -2,7 +2,6 @@
 	<div id="my-playlists">
 	<div id="my-playlists">
 		<div class="menu-list scrollable-list" v-if="playlists.length > 0">
 		<div class="menu-list scrollable-list" v-if="playlists.length > 0">
 			<draggable
 			<draggable
-				tag="transition-group"
 				:component-data="{
 				:component-data="{
 					name: !drag ? 'draggable-list-transition' : null
 					name: !drag ? 'draggable-list-transition' : null
 				}"
 				}"

+ 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

@@ -559,7 +559,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>
@@ -645,6 +649,7 @@
 		<floating-box
 		<floating-box
 			id="keyboardShortcutsHelper"
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
+			title="Station Keyboard Shortcuts"
 		>
 		>
 			<template #body>
 			<template #body>
 				<div>
 				<div>
@@ -978,7 +983,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);
@@ -986,7 +991,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);
@@ -994,7 +999,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);
@@ -1002,7 +1007,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);
@@ -1010,7 +1015,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);
@@ -1364,10 +1369,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 });
 						}
 						}
@@ -1376,7 +1383,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);
@@ -1389,10 +1396,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."
 									);
 									);
 								}
 								}
@@ -1457,12 +1464,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({
@@ -1732,7 +1739,7 @@ export default {
 				}
 				}
 			);
 			);
 		},
 		},
-		voteSkipStation() {
+		voteSkipStation(message) {
 			this.socket.dispatch(
 			this.socket.dispatch(
 				"stations.voteSkip",
 				"stations.voteSkip",
 				this.station._id,
 				this.station._id,
@@ -1741,7 +1748,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."
 						);
 						);
 				}
 				}
 			);
 			);
@@ -1793,7 +1801,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")
@@ -1802,7 +1810,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")
@@ -1813,7 +1821,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")
@@ -1822,7 +1830,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 = {};

+ 15 - 9
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.youtubeId || null;
 			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 = JSON.parse(JSON.stringify(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) {

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