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: {
|
||||
name: "nicola02nb",
|
||||
id: 257900031351193600n
|
||||
}
|
||||
},
|
||||
qouesm: {
|
||||
name: "qouesm",
|
||||
id: 130388483494641664n,
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue