mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-11-03 23:14:13 +00:00 
			
		
		
		
	upd: port Listenbrainz
This commit is contained in:
		
							parent
							
								
									57c37a8938
								
							
						
					
					
						commit
						113a67077e
					
				
					 9 changed files with 210 additions and 3 deletions
				
			
		
							
								
								
									
										20
									
								
								packages/backend/migration/1691264431000-add-lb-to-user.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/backend/migration/1691264431000-add-lb-to-user.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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"
 | 
			
		||||
        `);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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: ちゃんと数える
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										138
									
								
								packages/frontend/src/pages/user/index.listenbrainz.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								packages/frontend/src/pages/user/index.listenbrainz.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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> 
 | 
			
		||||
		Loading…
	
	Add table
		
		Reference in a new issue