From 113a67077efb3576889372d0d753639f49c1a728 Mon Sep 17 00:00:00 2001
From: Insert5StarName <anime@shourai.de>
Date: Fri, 22 Sep 2023 03:31:59 +0200
Subject: [PATCH] upd: port Listenbrainz

---
 .../migration/1691264431000-add-lb-to-user.js |  20 +++
 .../src/core/entities/UserEntityService.ts    |   4 +-
 packages/backend/src/models/User.ts           |   1 +
 packages/backend/src/models/UserProfile.ts    |   7 +
 .../backend/src/models/json-schema/user.ts    |   6 +
 .../src/server/api/endpoints/i/update.ts      |   4 +-
 .../frontend/src/pages/settings/profile.vue   |   7 +
 packages/frontend/src/pages/user/home.vue     |  26 +++-
 .../src/pages/user/index.listenbrainz.vue     | 138 ++++++++++++++++++
 9 files changed, 210 insertions(+), 3 deletions(-)
 create mode 100644 packages/backend/migration/1691264431000-add-lb-to-user.js
 create mode 100644 packages/frontend/src/pages/user/index.listenbrainz.vue

diff --git a/packages/backend/migration/1691264431000-add-lb-to-user.js b/packages/backend/migration/1691264431000-add-lb-to-user.js
new file mode 100644
index 0000000000..fe6265e3f5
--- /dev/null
+++ b/packages/backend/migration/1691264431000-add-lb-to-user.js
@@ -0,0 +1,20 @@
+export class AddLbToUser1691264431000 {
+	name = "AddLbToUser1691264431000";
+
+	async up(queryRunner) {
+		await queryRunner.query(`
+            ALTER TABLE "user_profile"
+            ADD "listenbrainz" character varying(128) NULL
+        `);
+		await queryRunner.query(`
+            COMMENT ON COLUMN "user_profile"."listenbrainz"
+						IS 'listenbrainz username to fetch currently playing.'
+        `);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(`
+            ALTER TABLE "user_profile" DROP COLUMN "listenbrainz"
+        `);
+	}
+}
\ No newline at end of file
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 3dd64ce625..b6af3cb8c9 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -14,7 +14,7 @@ import type { Promiseable } from '@/misc/prelude/await-all.js';
 import { awaitAll } from '@/misc/prelude/await-all.js';
 import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
 import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
-import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js';
+import { birthdaySchema, listenbrainzSchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js';
 import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js';
 import { bindThis } from '@/decorators.js';
 import { RoleService } from '@/core/RoleService.js';
@@ -139,6 +139,7 @@ export class UserEntityService implements OnModuleInit {
 	public validateDescription = ajv.compile(descriptionSchema);
 	public validateLocation = ajv.compile(locationSchema);
 	public validateBirthday = ajv.compile(birthdaySchema);
+	public validateListenBrainz = ajv.compile(listenbrainzSchema);
 	//#endregion
 
 	public isLocalUser = isLocalUser;
@@ -381,6 +382,7 @@ export class UserEntityService implements OnModuleInit {
 				description: profile!.description,
 				location: profile!.location,
 				birthday: profile!.birthday,
+				listenbrainz: profile!.listenbrainz,
 				lang: profile!.lang,
 				fields: profile!.fields,
 				verifiedLinks: profile!.verifiedLinks,
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index b040d302ce..8f0122a90c 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -280,4 +280,5 @@ export const passwordSchema = { type: 'string', minLength: 1 } as const;
 export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
 export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const;
 export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
+export const listenbrainzSchema = { type: "string", minLength: 1, maxLength: 128 } as const;
 export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts
index e4405c9da7..09c2c64464 100644
--- a/packages/backend/src/models/UserProfile.ts
+++ b/packages/backend/src/models/UserProfile.ts
@@ -34,6 +34,13 @@ export class MiUserProfile {
 	})
 	public birthday: string | null;
 
+	@Column("varchar", {
+		length: 128,
+		nullable: true,
+		comment: "The ListenBrainz username of the User.",
+	})
+	public listenbrainz: string | null;
+
 	@Column('varchar', {
 		length: 2048, nullable: true,
 		comment: 'The description (bio) of the User.',
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index f15b225a30..5112e680e7 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -145,6 +145,12 @@ export const packedUserDetailedNotMeOnlySchema = {
 			nullable: true, optional: false,
 			example: '2018-03-12',
 		},
+		ListenBrainz: {
+			type: "string",
+			nullable: true,
+			optional: false,
+			example: "Steve",
+		},
 		lang: {
 			type: 'string',
 			nullable: true, optional: false,
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index b11e091957..2049063701 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -13,7 +13,7 @@ import { extractHashtags } from '@/misc/extract-hashtags.js';
 import * as Acct from '@/misc/acct.js';
 import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
 import type { MiLocalUser, MiUser } from '@/models/User.js';
-import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js';
+import { birthdaySchema, listenbrainzSchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js';
 import type { MiUserProfile } from '@/models/UserProfile.js';
 import { notificationTypes } from '@/types.js';
 import { normalizeForSearch } from '@/misc/normalize-for-search.js';
@@ -129,6 +129,7 @@ export const paramDef = {
 		description: { ...descriptionSchema, nullable: true },
 		location: { ...locationSchema, nullable: true },
 		birthday: { ...birthdaySchema, nullable: true },
+		listenbrainz: { ...listenbrainzSchema, nullable: true },
 		lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
 		avatarId: { type: 'string', format: 'misskey:id', nullable: true },
 		bannerId: { type: 'string', format: 'misskey:id', nullable: true },
@@ -224,6 +225,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			if (ps.lang !== undefined) profileUpdates.lang = ps.lang;
 			if (ps.location !== undefined) profileUpdates.location = ps.location;
 			if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
+			if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz;
 			if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility;
 			if (ps.mutedWords !== undefined) {
 				// TODO: ちゃんと数える
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 5e4889f61c..8c12fcf359 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -32,6 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template #prefix><i class="ti ti-cake"></i></template>
 	</MkInput>
 
+	<MkInput v-model="profile.listenbrainz" manualSave>
+		<template #label>ListenBrainz</template>
+		<template #prefix><i class="ti ti-headphones"></i></template>
+	</MkInput>
+
 	<MkSelect v-model="profile.lang">
 		<template #label>{{ i18n.ts.language }}</template>
 		<option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option>
@@ -132,6 +137,7 @@ const profile = reactive({
 	description: $i.description,
 	location: $i.location,
 	birthday: $i.birthday,
+	listenbrainz: $i?.listenbrainz,
 	lang: $i.lang,
 	isBot: $i.isBot,
 	isCat: $i.isCat,
@@ -179,6 +185,7 @@ function save() {
 		location: profile.location || null,
 		// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
 		birthday: profile.birthday || null,
+		listenbrainz: profile.listenbrainz || null,
 		// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
 		lang: profile.lang || null,
 		isBot: !!profile.isBot,
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 385c81a97f..6ddc81e1c8 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -137,6 +137,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
 			<XPhotos :key="user.id" :user="user"/>
 			<XActivity :key="user.id" :user="user"/>
+			<XListenBrainz
+					v-if="user.listenbrainz && listenbrainzdata"
+					:key="user.id"
+					:user="user"
+					style="margin-top: var(--margin)"
+				/>
 		</div>
 	</div>
 </MkSpacer>
@@ -166,7 +172,6 @@ import { confetti } from '@/scripts/confetti.js';
 import MkNotes from '@/components/MkNotes.vue';
 import { api } from '@/os.js';
 import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
-
 function calcAge(birthdate: string): number {
 	const date = new Date(birthdate);
 	const now = new Date();
@@ -184,6 +189,7 @@ function calcAge(birthdate: string): number {
 
 const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
 const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
+const XListenBrainz = defineAsyncComponent(() => import("./index.listenbrainz.vue"));;
 
 const props = withDefaults(defineProps<{
 	user: Misskey.entities.UserDetailed;
@@ -205,6 +211,24 @@ let isEditingMemo = $ref(false);
 let moderationNote = $ref(props.user.moderationNote);
 let editModerationNote = $ref(false);
 
+let listenbrainzdata = false;
+if (props.user.listenbrainz) {
+	try {
+		const response = await fetch(`https://api.listenbrainz.org/1/user/${props.user.listenbrainz}/playing-now`, {
+			method: 'GET',
+			headers: {
+				'Content-Type': 'application/json'
+			},
+		});
+		const data = await response.json();
+		if (data.payload.listens && data.payload.listens.length !== 0) {
+			listenbrainzdata = true;
+		}
+	} catch(err) {
+		listenbrainzdata = false;
+	}
+}
+
 watch($$(moderationNote), async () => {
 	await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote });
 });
diff --git a/packages/frontend/src/pages/user/index.listenbrainz.vue b/packages/frontend/src/pages/user/index.listenbrainz.vue
new file mode 100644
index 0000000000..266ff14033
--- /dev/null
+++ b/packages/frontend/src/pages/user/index.listenbrainz.vue
@@ -0,0 +1,138 @@
+<template>
+	<MkContainer :foldable="true">
+		<template #header
+			><i
+				class="ti ti-headphones"
+				style="margin-right: 0.5em"
+			></i
+			>Music</template
+		>
+
+		<div style="padding: 8px">
+			<div class="flex">
+				<a :href="listenbrainz.musicbrainzurl">
+					<img class="image" :src="listenbrainz.img" :alt="listenbrainz.title" />
+					<div class="flex flex-col items-start">
+						<p class="text-sm font-bold">Now Playing: {{ listenbrainz.title }}</p>
+						<p class="text-xs font-medium">{{ listenbrainz.artist }}</p>
+					</div>
+				</a>
+				<a :href="listenbrainz.listenbrainzurl">
+					<div class="playicon">
+						<i class="ti ti-player-play-filled"></i>
+					</div>
+				</a>
+			</div>
+		</div>
+	</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import {} from "vue";
+import * as misskey from "misskey-js";
+import MkContainer from "@/components/MkContainer.vue";
+const props = withDefaults(
+	defineProps<{
+		user: misskey.entities.User;
+	}>(),
+	{},
+);
+const listenbrainz = {title: '', artist: '', lastlisten: '', img: '', musicbrainzurl: '', listenbrainzurl: ''};
+if (props.user.listenbrainz) {
+	const getLMData = async (title: string, artist: string) => {
+		const response = await fetch(`https://api.listenbrainz.org/1/metadata/lookup/?artist_name=${artist}&recording_name=${title}`, {
+			method: 'GET',
+			headers: {
+				'Content-Type': 'application/json'
+			},
+		})
+		const data = await response.json();
+		if (!data.recording_name) {
+		return null;
+		}
+		const titler: string = data.recording_name;
+		const artistr: string = data.artist_credit_name;
+		const img: string = data.release_mbid ? `https://coverartarchive.org/release/${data.release_mbid}/front-250` : 'https://coverartarchive.org/img/big_logo.svg';
+		const musicbrainzurl: string = data.recording_mbid ? `https://musicbrainz.org/recording/${data.recording_mbid}` : '#';
+		const listenbrainzurl: string = data.recording_mbid ? `https://listenbrainz.org/player?recording_mbids=${data.recording_mbid}` : '#';
+		return [titler, artistr, img, musicbrainzurl, listenbrainzurl];
+	};
+	const response = await fetch(`https://api.listenbrainz.org/1/user/${props.user.listenbrainz}/playing-now`, {
+        method: 'GET',
+        headers: {
+        	'Content-Type': 'application/json'
+        },
+    });
+    const data = await response.json();
+	if (data.payload.listens && data.payload.listens.length !== 0) {
+      const title: string = data.payload.listens[0].track_metadata.track_name;
+      const artist: string = data.payload.listens[0].track_metadata.artist_name;
+      const lastlisten: string = data.payload.listens[0].playing_now;
+      const img: string = 'https://coverartarchive.org/img/big_logo.svg';
+      await getLMData(title, artist).then((data) => {
+        if (!data) {
+          listenbrainz.title = title;
+		  listenbrainz.img = img;
+		  listenbrainz.artist = artist;
+		  listenbrainz.lastlisten = lastlisten;
+		  return;
+        } else {
+          listenbrainz.title = data[0];
+		  listenbrainz.img = data[2];
+		  listenbrainz.artist = data[1];
+		  listenbrainz.lastlisten = lastlisten;
+		  listenbrainz.musicbrainzurl = data[3];
+		  listenbrainz.listenbrainzurl = data[4];
+          return;
+        }
+      });
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.flex {
+	display: flex;
+	align-items: center;
+}
+.flex a {
+  display: flex;
+  align-items: center;
+  text-decoration: none;
+}
+.image {
+	height: 4.8rem;
+	margin-right: 0.7rem;
+}
+.items-start {
+	align-items: flex-start;
+}
+.flex-col {
+	display: flex;
+	flex-direction: column;
+}
+.text-sm {
+	font-size: 0.875rem;
+	margin: 0;
+	margin-bottom: 0.3rem;
+}
+.font-bold {
+	font-weight: 700;
+}
+.text-xs {
+	font-size: 0.75rem;
+	margin: 0;
+}
+.font-medium {
+	font-weight: 500;
+}
+.playicon {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 3rem;
+	height: 3rem;
+	font-size: 1.7rem;
+	padding-left: 3rem;
+}
+</style> 
\ No newline at end of file