From 9f2f511ff48f3e34f4ce4d32236070f76cf0965b Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:41:52 -0400 Subject: [PATCH] ListenBrainzRPC --- src/equicordplugins/listenBrainzRPC/index.tsx | 371 ++++++++++++++++++ src/utils/constants.ts | 6 +- 2 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/equicordplugins/listenBrainzRPC/index.tsx diff --git a/src/equicordplugins/listenBrainzRPC/index.tsx b/src/equicordplugins/listenBrainzRPC/index.tsx new file mode 100644 index 000000000..5f87751d5 --- /dev/null +++ b/src/equicordplugins/listenBrainzRPC/index.tsx @@ -0,0 +1,371 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Link } from "@components/Link"; +import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin, { OptionType } from "@utils/types"; +import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types"; +import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums"; +import { findByPropsLazy } from "@webpack"; +import { ApplicationAssetUtils, FluxDispatcher, Forms } from "@webpack/common"; + +interface TrackData { + name: string; + album: string; + artist: string; + durationMs?: number; + recordingMBID?: string; + url: string; + imageUrl?: string; +} + +const enum NameFormat { + StatusName = "status-name", + ArtistFirst = "artist-first", + SongFirst = "song-first", + ArtistOnly = "artist", + SongOnly = "song", + AlbumName = "album", +} + +const applicationId = "1090155131007406132"; +const placeholderId = "2a96cbd8b46e442fc41c2b86b821562f"; + +const logger = new Logger("ListenBrainzRPC"); + +const PresenceStore = findByPropsLazy("getLocalPresence"); + +async function getApplicationAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ + type: "LOCAL_ACTIVITY_UPDATE", + activity, + socketId: "ListenBrainz", + }); +} + +const settings = definePluginSettings({ + username: { + description: "ListenBrainz username", + type: OptionType.STRING, + }, + mbContact: { + description: "ListenBrainz contact", + type: OptionType.STRING, + }, + shareUsername: { + description: + "show link to ListenBrainz profile (may only be visible to other users)", + type: OptionType.BOOLEAN, + default: false, + }, + shareSong: { + description: + "show link to song on ListenBrainz (may only be visible to other users)", + type: OptionType.BOOLEAN, + default: true, + }, + hideWithSpotify: { + description: "hide ListenBrainz presence if Spotify is running", + type: OptionType.BOOLEAN, + default: true, + }, + hideWithActivity: { + description: "hide ListenBrainz presence if you have any other presence", + type: OptionType.BOOLEAN, + default: false, + }, + useTimeBar: { + description: + "use track duration to display a time bar (must be using Listening status)", + type: OptionType.BOOLEAN, + default: true, + }, + statusName: { + description: "custom status text", + type: OptionType.STRING, + default: "some music", + }, + nameFormat: { + description: "Show name of song and artist in status name", + type: OptionType.SELECT, + options: [ + { + label: "Use custom status name", + value: NameFormat.StatusName, + default: true, + }, + { + label: "Use format 'artist - song'", + value: NameFormat.ArtistFirst, + }, + { + label: "Use format 'song - artist'", + value: NameFormat.SongFirst, + }, + { + label: "Use artist name only", + value: NameFormat.ArtistOnly, + }, + { + label: "Use song name only", + value: NameFormat.SongOnly, + }, + { + label: + "Use album name (falls back to custom status text if song has no album)", + value: NameFormat.AlbumName, + }, + ], + }, + useListeningStatus: { + description: 'show "Listening to" status instead of "Playing"', + type: OptionType.BOOLEAN, + default: true, + }, + missingArt: { + description: "When album or album art is missing", + type: OptionType.SELECT, + options: [ + { + label: "Use large ListenBrainz logo", + value: "listenbrainzLogo", + default: true, + }, + { + label: "Use generic placeholder", + value: "placeholder", + }, + ], + }, + useLogo: { + description: "Show ListenBrainz logo on album art", + type: OptionType.BOOLEAN, + default: true, + }, +}); + +var currentRecordingMBID = ""; +var currentStart = 0; + +export default definePlugin({ + name: "ListenBrainzRPC", + description: "Little plugin for ListenBrainz rich presence", + authors: [EquicordDevs.qouesm], + + settingsAboutComponent: () => ( + <> + About MusicBrainz API + + The MusicBrainz API does not require an API key, but it does require a{" "} + + {" "} + meaningful user-agent string + {" "} + . For most, an email address should suffice. + + + ), + + settings, + + start() { + this.updatePresence(); + this.updateInterval = setInterval(() => { + this.updatePresence(); + }, 16000); + }, + + stop() { + clearInterval(this.updateInterval); + }, + + async fetchTrackData(): Promise { + if (!settings.store.username) return null; + + try { + const lbRes = await fetch( + `https://api.listenbrainz.org/1/user/${settings.store.username}/playing-now`, + ); + if (!lbRes.ok) throw `${lbRes.status} ${lbRes.statusText}`; + + const lbJson = await lbRes.json(); + if (lbJson.error) { + logger.error( + "Error from ListenBrainz API", + `${lbJson.error}: ${lbJson.message}`, + ); + return null; + } + + const listen = lbJson.payload?.listens?.[0]; + if (!listen?.playing_now) return null; + + const trackMetadata = listen.track_metadata; + const albumName = trackMetadata.release_name || "Unknown"; + const artistName = trackMetadata.artist_name || "Unknown"; + + const mbRes = await fetch(`https://musicbrainz.org/ws/2/release/?query=release:${encodeURIComponent(albumName)}%20AND%20artist:${encodeURIComponent(artistName)}&fmt=json`,); + + if (!mbRes.ok) throw `${mbRes.status} ${mbRes.statusText}`; + + const mbJson = await mbRes.json(); + const releases = mbJson.releases || []; + + const releaseGroup = releases[0]["release-group"].id; + + const caaRes = await fetch( + `https://coverartarchive.org/release-group/${releaseGroup}`, + ); + if (!caaRes.ok) throw `${caaRes.status} ${caaRes.statusText}`; + const caaJson = await caaRes.json(); + + const url: string = caaJson.release; + + const { images } = caaJson; + let imageUrl: string = ""; + for (const image of images) { + imageUrl = image.thumbnails.large || ""; + if (!imageUrl) continue; + break; + } + + return { + name: trackMetadata.track_name || "Unknown", + album: albumName, + artist: artistName, + durationMs: trackMetadata.additional_info.duration_ms, + recordingMBID: trackMetadata.additional_info.recording_mbid, + url: url, + imageUrl: imageUrl, + }; + } catch (e) { + logger.error("Failed to query ListenBrainz API", e); + // will clear the rich presence if API fails + return null; + } + }, + + async updatePresence() { + setActivity(await this.getActivity()); + }, + + getLargeImage(track: TrackData): string | undefined { + if (track.imageUrl && !track.imageUrl.includes(placeholderId)) + return track.imageUrl; + + if (settings.store.missingArt === "placeholder") return "placeholder"; + }, + + async getActivity(): Promise { + if (settings.store.hideWithActivity) { + if ( + PresenceStore.getActivities().some( + a => a.application_id !== applicationId, + ) + ) { + return null; + } + } + + if (settings.store.hideWithSpotify) { + if ( + PresenceStore.getActivities().some( + a => + a.type === ActivityType.LISTENING && + a.application_id !== applicationId, + ) + ) { + // there is already music status because of Spotify or richerCider (probably more) + return null; + } + } + + const trackData = await this.fetchTrackData(); + if (!trackData) return null; + + const largeImage = this.getLargeImage(trackData); + const assets: ActivityAssets = largeImage + ? { + large_image: await getApplicationAsset(largeImage), + large_text: trackData.album || undefined, + small_image: settings.store.useLogo + ? await getApplicationAsset("listenbrainz") + : undefined, + small_text: "ListenBrainz", + } + : { + large_image: await getApplicationAsset("listenbrainz"), + large_text: trackData.album || undefined, + }; + + const buttons: ActivityButton[] = []; + + if (settings.store.shareUsername) + buttons.push({ + label: "ListenBrainz Profile", + url: `https://www.listenbrainz.org/user/${settings.store.username}`, + }); + + if (settings.store.shareSong) + buttons.push({ + label: "View Song", + url: trackData.url, + }); + + const statusName = (() => { + switch (settings.store.nameFormat) { + case NameFormat.ArtistFirst: + return trackData.artist + " - " + trackData.name; + case NameFormat.SongFirst: + return trackData.name + " - " + trackData.artist; + case NameFormat.ArtistOnly: + return trackData.artist; + case NameFormat.SongOnly: + return trackData.name; + case NameFormat.AlbumName: + return trackData.album || settings.store.statusName; + default: + return settings.store.statusName; + } + })(); + + if (trackData.recordingMBID && trackData.recordingMBID !== currentRecordingMBID) { + currentRecordingMBID = trackData.recordingMBID; + currentStart = Date.now(); + } + + return { + application_id: applicationId, + name: statusName, + + details: trackData.name, + state: trackData.artist, + assets, + + timestamps: { + start: settings.store.useTimeBar ? currentStart : undefined, + end: settings.store.useTimeBar + ? currentStart + (trackData.durationMs ?? 0) + : undefined, + }, + buttons: buttons.length ? buttons.map(v => v.label) : undefined, + metadata: { + button_urls: buttons.map(v => v.url), + }, + + type: settings.store.useListeningStatus + ? ActivityType.LISTENING + : ActivityType.PLAYING, + flags: ActivityFlags.INSTANCE, + }; + }, +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index cec3b5004..e37abdc79 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1152,7 +1152,11 @@ export const EquicordDevs = Object.freeze({ nicola02nb: { name: "nicola02nb", id: 257900031351193600n - } + }, + qouesm: { + name: "qouesm", + id: 130388483494641664n, + }, } satisfies Record); // iife so #__PURE__ works correctly