Bläddra i källkod

Merge pull request #69 from Musare/kris-activitywatch

ActivityWatch integration
Vos 3 år sedan
förälder
incheckning
22d8a0b952

+ 14 - 1
backend/logic/actions/users.js

@@ -739,6 +739,7 @@ export default {
 	 * @param {boolean} preferences.nightmode - whether or not the user is using the night mode theme
 	 * @param {boolean} preferences.autoSkipDisliked - whether to automatically skip disliked songs
 	 * @param {boolean} preferences.activityLogPublic - whether or not a user's activity log can be publicly viewed
+	 * @param {boolean} preferences.activityWatch - whether or not a user is using the ActivityWatch integration
 	 * @param {Function} cb - gets called with the result
 	 */
 	updatePreferences: isLoginRequired(async function updatePreferences(session, preferences, cb) {
@@ -754,7 +755,8 @@ export default {
 								preferences: {
 									nightmode: preferences.nightmode,
 									autoSkipDisliked: preferences.autoSkipDisliked,
-									activityLogPublic: preferences.activityLogPublic
+									activityLogPublic: preferences.activityLogPublic,
+									activityWatch: preferences.activityWatch
 								}
 							}
 						},
@@ -804,6 +806,17 @@ export default {
 						}
 					});
 
+				if (preferences.activityWatch !== user.preferences.activityWatch)
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "user__toggle_activity_watch",
+						payload: {
+							message: preferences.activityWatch
+								? "Enabled ActivityWatch integration"
+								: "Disabled ActivityWatch integration"
+						}
+					});
+
 				this.log(
 					"SUCCESS",
 					"UPDATE_USER_PREFERENCES",

+ 1 - 0
backend/logic/activities.js

@@ -95,6 +95,7 @@ class _ActivitiesModule extends CoreClass {
 						const spammableActivities = [
 							"user__toggle_nightmode",
 							"user__toggle_autoskip_disliked_songs",
+							"user__toggle_activity_watch",
 							"song__like",
 							"song__unlike",
 							"song__dislike",

+ 1 - 1
backend/logic/db/index.js

@@ -14,7 +14,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	report: 1,
 	song: 3,
 	station: 4,
-	user: 1
+	user: 2
 };
 
 const regex = {

+ 1 - 0
backend/logic/db/schemas/activity.js

@@ -13,6 +13,7 @@ export default {
 			"user__edit_location",
 			"user__toggle_nightmode",
 			"user__toggle_autoskip_disliked_songs",
+			"user__toggle_activity_watch",
 			/** Songs */
 			"song__report",
 			"song__like",

+ 3 - 2
backend/logic/db/schemas/user.js

@@ -44,7 +44,8 @@ export default {
 		orderOfPlaylists: [{ type: mongoose.Schema.Types.ObjectId }],
 		nightmode: { type: Boolean, default: false, required: true },
 		autoSkipDisliked: { type: Boolean, default: true, required: true },
-		activityLogPublic: { type: Boolean, default: false, required: true }
+		activityLogPublic: { type: Boolean, default: false, required: true },
+		activityWatch: { type: Boolean, default: false, required: true }
 	},
-	documentVersion: { type: Number, default: 1, required: true }
+	documentVersion: { type: Number, default: 2, required: true }
 };

+ 45 - 0
backend/logic/migration/migrations/migration6.js

@@ -0,0 +1,45 @@
+import async from "async";
+
+/**
+ * Migration 6
+ *
+ * Migration for adding activityWatch preference to user object
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 6. Finding users with document version 1.`);
+					userModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2, "preferences.activityWatch": false } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 6. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 1 - 1
frontend/package.json

@@ -47,7 +47,7 @@
     "date-fns": "^2.19.0",
     "eslint-config-airbnb-base": "^13.2.0",
     "html-webpack-plugin": "^5.3.1",
-    "toasters": "^2.1.2",
+    "toasters": "^2.2.3",
     "vue": "^2.6.12",
     "vue-content-loader": "^0.2.3",
     "vue-loader": "^15.9.6",

+ 19 - 11
frontend/src/App.vue

@@ -15,6 +15,7 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import Toast from "toasters";
 
 import ws from "./ws";
+import aw from "./aw";
 import keyboardShortcuts from "./keyboardShortcuts";
 
 export default {
@@ -41,7 +42,8 @@ export default {
 			banned: state => state.user.auth.banned,
 			modals: state => state.modalVisibility.modals,
 			currentlyActive: state => state.modalVisibility.currentlyActive,
-			nightmode: state => state.user.preferences.nightmode
+			nightmode: state => state.user.preferences.nightmode,
+			activityWatch: state => state.user.preferences.activityWatch
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
@@ -71,6 +73,10 @@ export default {
 		nightmode(nightmode) {
 			if (nightmode) this.enableNightMode();
 			else this.disableNightMode();
+		},
+		activityWatch(activityWatch) {
+			if (activityWatch) aw.enable();
+			else aw.disable();
 		}
 	},
 	async mounted() {
@@ -142,6 +148,7 @@ export default {
 				this.changeAutoSkipDisliked(res.data.autoSkipDisliked);
 				this.changeNightmode(res.data.nightmode);
 				this.changeActivityLogPublic(res.data.activityLogPublic);
+				this.changeActivityWatch(res.data.activityWatch);
 
 				if (this.nightmode) this.enableNightMode();
 				else this.disableNightMode();
@@ -170,7 +177,8 @@ export default {
 		...mapActions("user/preferences", [
 			"changeNightmode",
 			"changeAutoSkipDisliked",
-			"changeActivityLogPublic"
+			"changeActivityLogPublic",
+			"changeActivityWatch"
 		])
 	}
 };
@@ -214,12 +222,12 @@ export default {
 	}
 
 	#toasts-container .toast {
-		color: var(--dark-grey-2);
-		background-color: var(--light-grey-3) !important;
+		// color: var(--dark-grey-2);
+		// background-color: var(--light-grey-3) !important;
 
-		&:last-of-type {
-			background-color: var(--light-grey) !important;
-		}
+		// &:last-of-type {
+		// 	background-color: var(--light-grey) !important;
+		// }
 	}
 
 	h1,
@@ -254,11 +262,11 @@ body.night-mode {
 
 	.toast {
 		font-weight: 600;
-		background-color: var(--dark-grey) !important;
+		// background-color: var(--dark-grey) !important;
 
-		&:last-of-type {
-			background-color: var(--dark-grey-2) !important;
-		}
+		// &:last-of-type {
+		// 	background-color: var(--dark-grey-2) !important;
+		// }
 	}
 }
 

+ 165 - 0
frontend/src/aw.js

@@ -0,0 +1,165 @@
+import Toast from "toasters";
+
+let gotPong = false;
+let pingTries = 0;
+let uuid = null;
+let enabled = false;
+
+let lastTimeSentVideoDate = 0;
+let lastTimeDenied = 0;
+let lastTimeCompetitor = 0;
+
+const notConnectedToast = new Toast({
+	content: "ActivityWatch is not connected yet.",
+	persistent: true,
+	interactable: false
+});
+notConnectedToast.hide();
+
+const sendingVideoDataToast = new Toast({
+	content: "Sending video data to ActivityWatch.",
+	persistent: true,
+	interactable: false
+});
+sendingVideoDataToast.hide();
+
+const deniedToast = new Toast({
+	content:
+		"Another Musare instance is already sending data to ActivityWatch Musare extension. Please only use 1 active tab for stations and editsong.",
+	persistent: true,
+	interactable: false
+});
+deniedToast.hide();
+
+const competitorToast = new Toast({
+	content:
+		"Another Musare instance is already sending data to ActivityWatch Musare extension. Please only use 1 active tab for stations and editsong.",
+	persistent: true,
+	interactable: false
+});
+competitorToast.hide();
+
+export default {
+	sendVideoData(videoData) {
+		if (enabled) {
+			lastTimeSentVideoDate = Date.now();
+			this.sendEvent("videoData", videoData);
+		}
+	},
+
+	sendEvent(type, data) {
+		if (enabled)
+			document.dispatchEvent(
+				new CustomEvent("ActivityWatchMusareEvent", {
+					detail: {
+						type,
+						source: uuid,
+						data
+					}
+				})
+			);
+	},
+
+	enable() {
+		if (!enabled) {
+			uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
+				/[xy]/g,
+				function abc(c) {
+					// eslint-disable-next-line
+					const r = (Math.random() * 16) | 0;
+					// eslint-disable-next-line
+					const v = c == "x" ? r : (r & 0x3) | 0x8;
+					return v.toString(16);
+				}
+			);
+
+			document.addEventListener(
+				"ActivityWatchMusareEvent",
+				this.eventListener
+			);
+
+			notConnectedToast.show();
+
+			this.attemptPing();
+
+			enabled = true;
+			console.log("Enabled AW.");
+
+			setInterval(() => {
+				if (lastTimeDenied + 1000 > Date.now()) {
+					deniedToast.show();
+				} else {
+					deniedToast.hide();
+				}
+
+				if (lastTimeCompetitor + 1000 > Date.now()) {
+					competitorToast.show();
+				} else {
+					competitorToast.hide();
+				}
+
+				if (
+					!(lastTimeDenied + 1000 > Date.now()) &&
+					!(lastTimeCompetitor + 1000 > Date.now()) &&
+					lastTimeSentVideoDate + 1000 > Date.now()
+				) {
+					sendingVideoDataToast.show();
+				} else {
+					sendingVideoDataToast.hide();
+				}
+			}, 1000);
+		}
+	},
+
+	disable() {
+		document.removeEventListener(
+			"ActivityWatchMusareEvent",
+			this.eventListener
+		);
+		enabled = false;
+		notConnectedToast.hide();
+		console.log("Disabled AW.");
+	},
+
+	eventListener(event) {
+		const data = event.detail;
+
+		if (data.type === "pong") {
+			gotPong = true;
+			notConnectedToast.hide();
+			new Toast({
+				content:
+					"Got pong, connected to ActivityWatch Musare extension",
+				timeout: 8000
+			});
+		}
+
+		if (data.type === "denied") {
+			lastTimeDenied = Date.now();
+		}
+
+		if (data.type === "competitor") {
+			if (data.competitor !== uuid) {
+				lastTimeCompetitor = Date.now();
+			}
+		}
+	},
+
+	attemptPing() {
+		if (!gotPong) {
+			if (pingTries < 10) {
+				pingTries += 1;
+				this.sendEvent("ping", null);
+				setTimeout(() => {
+					this.attemptPing.apply(this);
+				}, 1000);
+			} else {
+				new Toast({
+					content:
+						"Couldn't connect to ActivityWatch Musare extension.",
+					timeout: 8000
+				});
+			}
+		}
+	}
+};

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

@@ -115,6 +115,7 @@ export default {
 				user__edit_location: "place",
 				user__toggle_nightmode: "nightlight_round",
 				user__toggle_autoskip_disliked_songs: "thumb_down_alt",
+				user__toggle_activity_watch: "visibility",
 				/** Songs */
 				song__report: "flag",
 				song__like: "thumb_up_alt",

+ 50 - 1
frontend/src/components/modals/EditSong.vue

@@ -509,6 +509,7 @@
 import { mapState, mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
 
+import aw from "@/aw";
 import validation from "@/validation";
 import keyboardShortcuts from "@/keyboardShortcuts";
 import Modal from "../Modal.vue";
@@ -548,6 +549,9 @@ export default {
 			keydownGenreInputTimeout: 0,
 			artistAutosuggestItems: [],
 			genreAutosuggestItems: [],
+			activityWatchVideoDataInterval: null,
+			activityWatchVideoLastStatus: "",
+			activityWatchVideoLastStartDuration: "",
 			genres: [
 				"Blues",
 				"Country",
@@ -612,6 +616,10 @@ export default {
 		//   this.song.skipDuration
 		// );
 
+		this.activityWatchVideoDataInterval = setInterval(() => {
+			this.sendActivityWatchVideoData();
+		}, 1000);
+
 		this.useHTTPS = await lofig.get("cookie.secure");
 
 		this.socket.dispatch(
@@ -640,7 +648,7 @@ export default {
 								this.song.skipDuration >
 								this.song.duration
 						) {
-							this.video.paused = false;
+							this.video.paused = true;
 							this.video.player.stopVideo();
 							this.drawCanvas();
 						}
@@ -932,6 +940,7 @@ export default {
 	beforeDestroy() {
 		this.playerReady = false;
 		clearInterval(this.interval);
+		clearInterval(this.activityWatchVideoDataInterval);
 
 		const shortcutNames = [
 			"editSong.pauseResume",
@@ -1401,6 +1410,46 @@ export default {
 		resetGenreHelper() {
 			this.$refs.genreHelper.resetBox();
 		},
+		sendActivityWatchVideoData() {
+			if (!this.video.paused) {
+				if (this.activityWatchVideoLastStatus !== "playing") {
+					this.activityWatchVideoLastStatus = "playing";
+					if (
+						this.song.skipDuration > 0 &&
+						parseFloat(this.youtubeVideoCurrentTime) === 0
+					) {
+						this.activityWatchVideoLastStartDuration = Math.floor(
+							this.song.skipDuration +
+								parseFloat(this.youtubeVideoCurrentTime)
+						);
+					} else {
+						this.activityWatchVideoLastStartDuration = Math.floor(
+							parseFloat(this.youtubeVideoCurrentTime)
+						);
+					}
+				}
+
+				const videoData = {
+					title: this.song.title,
+					artists: this.song.artists
+						? this.song.artists.join(", ")
+						: null,
+					youtubeId: this.song.songId,
+					muted: this.muted,
+					volume: this.volumeSliderValue / 100,
+					startedDuration:
+						this.activityWatchVideoLastStartDuration <= 0
+							? 0
+							: this.activityWatchVideoLastStartDuration,
+					source: `editSong#${this.song.songId}`,
+					hostname: window.location.hostname
+				};
+
+				aw.sendVideoData(videoData);
+			} else {
+				this.activityWatchVideoLastStatus = "not_playing";
+			}
+		},
 		...mapActions("modals/editSong", [
 			"stopVideo",
 			"loadVideoById",

+ 5 - 0
frontend/src/main.js

@@ -209,6 +209,11 @@ lofig.folder = "../config/default.json";
 			"user/preferences/changeActivityLogPublic",
 			preferences.activityLogPublic
 		);
+
+		store.dispatch(
+			"user/preferences/changeActivityWatch",
+			preferences.activityWatch
+		);
 	});
 
 	router.beforeEach((to, from, next) => {

+ 24 - 5
frontend/src/pages/Settings/tabs/Preferences.vue

@@ -35,6 +35,17 @@
 				<p>Allow my activity log to be viewed publicly</p>
 			</label>
 		</p>
+		<p class="control is-expanded checkbox-control">
+			<input
+				type="checkbox"
+				id="activityWatch"
+				v-model="localActivityWatch"
+			/>
+			<label for="activityWatch">
+				<span></span>
+				<p>Use ActivityWatch integration (requires extension)</p>
+			</label>
+		</p>
 
 		<save-button ref="saveButton" @clicked="saveChanges()" />
 	</div>
@@ -52,14 +63,17 @@ export default {
 		return {
 			localNightmode: false,
 			localAutoSkipDisliked: false,
-			localActivityLogPublic: false
+			localActivityLogPublic: false,
+			localActivityWatch: false
 		};
 	},
 	computed: {
 		...mapState({
 			nightmode: state => state.user.preferences.nightmode,
 			autoSkipDisliked: state => state.user.preferences.autoSkipDisliked,
-			activityLogPublic: state => state.user.preferences.activityLogPublic
+			activityLogPublic: state =>
+				state.user.preferences.activityLogPublic,
+			activityWatch: state => state.user.preferences.activityWatch
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
@@ -71,6 +85,7 @@ export default {
 				this.localNightmode = res.data.nightmode;
 				this.localAutoSkipDisliked = res.data.autoSkipDisliked;
 				this.localActivityLogPublic = res.data.activityLogPublic;
+				this.localActivityWatch = res.data.activityWatch;
 			}
 		});
 
@@ -78,6 +93,7 @@ export default {
 			this.localNightmode = preferences.nightmode;
 			this.localAutoSkipDisliked = preferences.autoSkipDisliked;
 			this.localActivityLogPublic = preferences.activityLogPublic;
+			this.localActivityWatch = preferences.activityWatch;
 		});
 	},
 	methods: {
@@ -85,7 +101,8 @@ export default {
 			if (
 				this.localNightmode === this.nightmode &&
 				this.localAutoSkipDisliked === this.autoSkipDisliked &&
-				this.localActivityLogPublic === this.activityLogPublic
+				this.localActivityLogPublic === this.activityLogPublic &&
+				this.localActivityWatch === this.activityWatch
 			) {
 				new Toast({
 					content: "Please make a change before saving.",
@@ -102,7 +119,8 @@ export default {
 				{
 					nightmode: this.localNightmode,
 					autoSkipDisliked: this.localAutoSkipDisliked,
-					activityLogPublic: this.localActivityLogPublic
+					activityLogPublic: this.localActivityLogPublic,
+					activityWatch: this.localActivityWatch
 				},
 				res => {
 					if (res.status !== "success") {
@@ -123,7 +141,8 @@ export default {
 		...mapActions("user/preferences", [
 			"changeNightmode",
 			"changeAutoSkipDisliked",
-			"changeActivityLogPublic"
+			"changeActivityLogPublic",
+			"changeActivityWatch"
 		])
 	}
 };

+ 53 - 1
frontend/src/pages/Station/index.vue

@@ -636,6 +636,7 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import Toast from "toasters";
 import { ContentLoader } from "vue-content-loader";
 
+import aw from "@/aw";
 import ws from "@/ws";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
@@ -695,7 +696,11 @@ export default {
 			showPlaylistDropdown: false,
 			theme: "var(--primary-color)",
 			seekerbarPercentage: 0,
-			frontendDevMode: "production"
+			frontendDevMode: "production",
+			activityWatchVideoDataInterval: null,
+			activityWatchVideoLastStatus: "",
+			activityWatchVideoLastYouTubeId: "",
+			activityWatchVideoLastStartDuration: ""
 		};
 	},
 	computed: {
@@ -734,6 +739,9 @@ export default {
 		this.stationIdentifier = this.$route.params.id;
 
 		window.stationInterval = 0;
+		this.activityWatchVideoDataInterval = setInterval(() => {
+			this.sendActivityWatchVideoData();
+		}, 1000);
 
 		if (this.socket.readyState === 1) this.join();
 		ws.onConnect(() => this.join());
@@ -1034,6 +1042,8 @@ export default {
 			keyboardShortcuts.unregisterShortcut(shortcutName);
 		});
 
+		clearInterval(this.activityWatchVideoDataInterval);
+
 		this.joinStation();
 	},
 	methods: {
@@ -1867,6 +1877,48 @@ export default {
 				}
 			);
 		},
+		sendActivityWatchVideoData() {
+			if (!this.stationPaused && !this.localPaused && this.currentSong) {
+				if (this.activityWatchVideoLastStatus !== "playing") {
+					this.activityWatchVideoLastStatus = "playing";
+					this.activityWatchVideoLastStartDuration =
+						this.currentSong.skipDuration + this.getTimeElapsed();
+				}
+
+				if (
+					this.activityWatchVideoLastYouTubeId !==
+					this.currentSong.songId
+				) {
+					this.activityWatchVideoLastYouTubeId = this.currentSong.songId;
+					this.activityWatchVideoLastStartDuration =
+						this.currentSong.skipDuration + this.getTimeElapsed();
+				}
+
+				const videoData = {
+					title: this.currentSong ? this.currentSong.title : null,
+					artists:
+						this.currentSong && this.currentSong.artists
+							? this.currentSong.artists.join(", ")
+							: null,
+					youtubeId: this.currentSong.songId,
+					muted: this.muted,
+					volume: this.volumeSliderValue / 100,
+					startedDuration:
+						this.activityWatchVideoLastStartDuration <= 0
+							? 0
+							: Math.floor(
+									this.activityWatchVideoLastStartDuration /
+										1000
+							  ),
+					source: `station#${this.station.name}`,
+					hostname: window.location.hostname
+				};
+
+				aw.sendVideoData(videoData);
+			} else {
+				this.activityWatchVideoLastStatus = "not_playing";
+			}
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", [
 			"joinStation",

+ 8 - 1
frontend/src/store/modules/user.js

@@ -220,7 +220,8 @@ const modules = {
 		state: {
 			nightmode: false,
 			autoSkipDisliked: true,
-			activityLogPublic: false
+			activityLogPublic: false,
+			activityWatch: false
 		},
 		actions: {
 			changeNightmode: ({ commit }, nightmode) => {
@@ -231,6 +232,9 @@ const modules = {
 			},
 			changeActivityLogPublic: ({ commit }, activityLogPublic) => {
 				commit("changeActivityLogPublic", activityLogPublic);
+			},
+			changeActivityWatch: ({ commit }, activityWatch) => {
+				commit("changeActivityWatch", activityWatch);
 			}
 		},
 		mutations: {
@@ -242,6 +246,9 @@ const modules = {
 			},
 			changeActivityLogPublic(state, activityLogPublic) {
 				state.activityLogPublic = activityLogPublic;
+			},
+			changeActivityWatch(state, activityWatch) {
+				state.activityWatch = activityWatch;
 			}
 		}
 	}