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