ListenBrainzRPC
This commit is contained in:
parent
0d17bae4f0
commit
9f2f511ff4
2 changed files with 376 additions and 1 deletions
371
src/equicordplugins/listenBrainzRPC/index.tsx
Normal file
371
src/equicordplugins/listenBrainzRPC/index.tsx
Normal file
|
|
@ -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<string> {
|
||||||
|
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: () => (
|
||||||
|
<>
|
||||||
|
<Forms.FormTitle tag="h3">About MusicBrainz API</Forms.FormTitle>
|
||||||
|
<Forms.FormText>
|
||||||
|
The MusicBrainz API does not require an API key, but it does require a{" "}
|
||||||
|
<Link href="https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#Provide_meaningful_User-Agent_strings">
|
||||||
|
{" "}
|
||||||
|
meaningful user-agent string
|
||||||
|
</Link>{" "}
|
||||||
|
. For most, an email address should suffice.
|
||||||
|
</Forms.FormText>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
settings,
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.updatePresence();
|
||||||
|
this.updateInterval = setInterval(() => {
|
||||||
|
this.updatePresence();
|
||||||
|
}, 16000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTrackData(): Promise<TrackData | null> {
|
||||||
|
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<Activity | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1152,7 +1152,11 @@ export const EquicordDevs = Object.freeze({
|
||||||
nicola02nb: {
|
nicola02nb: {
|
||||||
name: "nicola02nb",
|
name: "nicola02nb",
|
||||||
id: 257900031351193600n
|
id: 257900031351193600n
|
||||||
}
|
},
|
||||||
|
qouesm: {
|
||||||
|
name: "qouesm",
|
||||||
|
id: 130388483494641664n,
|
||||||
|
},
|
||||||
} satisfies Record<string, Dev>);
|
} satisfies Record<string, Dev>);
|
||||||
|
|
||||||
// iife so #__PURE__ works correctly
|
// iife so #__PURE__ works correctly
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue