diff --git a/src/main/csp/index.ts b/src/main/csp/index.ts
index bc5eb153c..adf1c73ba 100644
--- a/src/main/csp/index.ts
+++ b/src/main/csp/index.ts
@@ -54,6 +54,11 @@ export const CspPolicies: PolicyMap = {
// Function Specific
"api.github.com": ConnectSrc, // used for updating Vencord itself
"ws.audioscrobbler.com": ConnectSrc, // Last.fm API
+ "musicbrainz.org": ConnectSrc,
+ "*.listenbrainz.org": ConnectSrc,
+ "coverartarchive.org": ConnectSrc,
+ "archive.org": ConnectSrc,
+ "*.archive.org": ConnectSrc,
"translate-pa.googleapis.com": ConnectSrc, // Google Translate API
"*.vencord.dev": ImageSrc, // VenCloud (api.vencord.dev) and Badges (badges.vencord.dev)
"manti.vendicated.dev": ImageSrc, // ReviewDB API
diff --git a/src/plugins/lastfmRichPresence/index.tsx b/src/plugins/musicRichPresence/index.tsx
similarity index 74%
rename from src/plugins/lastfmRichPresence/index.tsx
rename to src/plugins/musicRichPresence/index.tsx
index 916243485..59fcd58c6 100644
--- a/src/plugins/lastfmRichPresence/index.tsx
+++ b/src/plugins/musicRichPresence/index.tsx
@@ -16,25 +16,38 @@
* along with this program. If not, see .
*/
-import { definePluginSettings } from "@api/Settings";
+import { definePluginSettings, migratePluginSetting, migratePluginSettings } from "@api/Settings";
import { LinkButton } from "@components/Button";
import { Card } from "@components/Card";
import { Heading } from "@components/Heading";
import { Margins } from "@components/margins";
import { Paragraph } from "@components/Paragraph";
import { Devs } from "@utils/constants";
-import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";
import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord/discord-types/enums";
import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common";
-interface TrackData {
+import { LastFMScrobbler } from "./lastfm";
+import { ListenBrainzScrobbler } from "./listenbrainz";
+
+export interface TrackData {
name: string;
album: string;
artist: string;
- url: string;
- imageUrl?: string;
+ trackURL?: string;
+ artistURL?: string;
+ albumURL?: string;
+ imageURL?: string;
+ serviceName?: string;
+}
+
+export interface ScrobblerBackend {
+ name: string,
+ id: string,
+
+ fetchTrackData(username: string, apiKey?: string): Promise;
+ getUserURL(username: string): string;
}
const enum NameFormat {
@@ -43,16 +56,15 @@ const enum NameFormat {
SongFirst = "song-first",
ArtistOnly = "artist",
SongOnly = "song",
- AlbumName = "album"
+ AlbumName = "album",
+ ServiceName = "service-name"
}
// Last.fm API keys are essentially public information and have no access to your account, so including one here is fine.
-const API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
+const LASTFM_API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
const DISCORD_APP_ID = "1108588077900898414";
const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f";
-const logger = new Logger("LastFMRichPresence");
-
async function getApplicationAsset(key: string): Promise {
return (await ApplicationAssetUtils.fetchAssetIds(DISCORD_APP_ID, [key]))[0];
}
@@ -66,17 +78,32 @@ function setActivity(activity: Activity | null) {
}
const settings = definePluginSettings({
+ scrobblerBackend: {
+ description: "The scrobbler backend to use.",
+ type: OptionType.SELECT,
+ options: [
+ {
+ "label": "Last.FM",
+ "value": "lastfm",
+ "default": true
+ },
+ {
+ "label": "ListenBrainz",
+ "value": "listenbrainz"
+ }
+ ] as const
+ },
apiKey: {
displayName: "API Key",
description: "Custom Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key",
type: OptionType.STRING,
},
username: {
- description: "Last.fm username",
+ description: "Username",
type: OptionType.STRING,
},
shareUsername: {
- description: "Show link to Last.fm profile",
+ description: "Show link to scrobbler profile",
type: OptionType.BOOLEAN,
default: false,
},
@@ -86,12 +113,12 @@ const settings = definePluginSettings({
default: true,
},
hideWithSpotify: {
- description: "Hide Last.fm presence if spotify is running",
+ description: "Hide presence if Spotify is running",
type: OptionType.BOOLEAN,
default: true,
},
hideWithActivity: {
- description: "Hide Last.fm presence if you have any other presence",
+ description: "Hide presence if you have any other presence",
type: OptionType.BOOLEAN,
default: false,
},
@@ -128,6 +155,10 @@ const settings = definePluginSettings({
value: NameFormat.StatusName,
default: true
},
+ {
+ label: "Use music service name (falls back to custom status text)",
+ value: NameFormat.ServiceName
+ },
{
label: "Use format 'artist - song'",
value: NameFormat.ArtistFirst
@@ -160,8 +191,8 @@ const settings = definePluginSettings({
type: OptionType.SELECT,
options: [
{
- label: "Use large Last.fm logo",
- value: "lastfmLogo",
+ label: "Use large scrobbler logo",
+ value: "logo",
default: true
},
{
@@ -170,30 +201,34 @@ const settings = definePluginSettings({
}
],
},
- showLastFmLogo: {
- displayName: "Show Last.fm Logo",
- description: "Show the Last.fm logo by the album cover",
+ showLogo: {
+ displayName: "Show Scrobbler Logo",
+ description: "Show the scrobbler service logo by the album cover",
type: OptionType.BOOLEAN,
default: true,
},
showAlbumCover: {
- description: "Show album cover. Disabling this will display a placeholder. Useful if your Music has inappropriate art",
+ description: "Show album cover. Disabling this will display a placeholder. Useful if your music has inappropriate art",
type: OptionType.BOOLEAN,
default: true,
}
});
+migratePluginSettings("MusicRichPresence", "LastFMRichPresence");
+migratePluginSetting("MusicRichPresence", "showLastFmLogo", "showLogo");
export default definePlugin({
- name: "LastFMRichPresence",
- description: "Little plugin for Last.fm rich presence",
+ name: "MusicRichPresence",
+ description: "Rich Presence for Last.FM/Listenbrainz",
tags: ["Activity", "Media"],
- authors: [Devs.dzshn, Devs.RuiNtD, Devs.blahajZip, Devs.archeruwu],
+ searchTerms: ["lastfm", "LastFMRichPresence"],
+ authors: [Devs.dzshn, Devs.RuiNtD, Devs.blahajZip, Devs.archeruwu, Devs.angelcube],
settings,
settingsAboutComponent() {
return (
+ Last.FM
How to create an API key
Set Application name and Application description to anything and leave the rest blank.
Create API Key
@@ -210,61 +245,22 @@ export default definePlugin({
clearInterval(this.updateInterval);
},
- async fetchTrackData(): Promise {
- if (!settings.store.username)
- return null;
-
- try {
- const params = new URLSearchParams({
- method: "user.getrecenttracks",
- api_key: settings.store.apiKey || API_KEY,
- user: settings.store.username,
- limit: "1",
- format: "json"
- });
-
- const res = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`);
- if (!res.ok) throw `${res.status} ${res.statusText}`;
-
- const json = await res.json();
- if (json.error) {
- logger.error("Error from Last.fm API", `${json.error}: ${json.message}`);
- return null;
- }
-
- const trackData = json.recenttracks?.track[0];
-
- if (!trackData?.["@attr"]?.nowplaying)
- return null;
-
- // why does the json api have xml structure
- return {
- name: trackData.name || "Unknown",
- album: trackData.album["#text"],
- artist: trackData.artist["#text"] || "Unknown",
- url: trackData.url,
- imageUrl: trackData.image?.find((x: any) => x.size === "large")?.["#text"]
- };
- } catch (e) {
- logger.error("Failed to query Last.fm API", e);
- // will clear the rich presence if API fails
- return null;
- }
- },
-
async updatePresence() {
setActivity(await this.getActivity());
},
getLargeImage(track: TrackData): string | undefined {
- if (settings.store.showAlbumCover && track.imageUrl && !track.imageUrl.includes(LASTFM_PLACEHOLDER_IMAGE_HASH))
- return track.imageUrl;
+ if (settings.store.showAlbumCover && track.imageURL && !track.imageURL.includes(LASTFM_PLACEHOLDER_IMAGE_HASH))
+ return track.imageURL;
if (settings.store.missingArt === "placeholder")
return "placeholder";
},
async getActivity(): Promise {
+ if (!settings.store.username)
+ return null;
+
if (settings.store.hideWithActivity) {
if (PresenceStore.getActivities(AuthenticationStore.getId()).some(a => a.application_id !== DISCORD_APP_ID && a.type !== ActivityType.CUSTOM_STATUS)) {
return null;
@@ -278,7 +274,9 @@ export default definePlugin({
}
}
- const trackData = await this.fetchTrackData();
+ const scrobbler = settings.store.scrobblerBackend === "lastfm" ? LastFMScrobbler : ListenBrainzScrobbler;
+
+ const trackData = await scrobbler.fetchTrackData(settings.store.username, settings.store.apiKey || LASTFM_API_KEY);
if (!trackData) return null;
const largeImage = this.getLargeImage(trackData);
@@ -286,22 +284,23 @@ export default definePlugin({
{
large_image: await getApplicationAsset(largeImage),
large_text: trackData.album || undefined,
- ...(settings.store.showLastFmLogo && {
- small_image: await getApplicationAsset("lastfm-small"),
- small_text: "Last.fm"
+ ...(settings.store.showLogo && {
+ small_image: await getApplicationAsset(`${scrobbler.id}-small`),
+ small_text: scrobbler.id
}),
} : {
- large_image: await getApplicationAsset("lastfm-large"),
+ large_image: await getApplicationAsset(`${scrobbler.id}-large`),
large_text: trackData.album || undefined,
};
const buttons: ActivityButton[] = [];
- if (settings.store.shareUsername)
+ if (settings.store.shareUsername) {
buttons.push({
- label: "Last.fm Profile",
- url: `https://www.last.fm/user/${settings.store.username}`,
+ label: `${scrobbler.name} Profile`,
+ url: scrobbler.getUserURL(settings.store.username!)
});
+ }
const statusName = (() => {
switch (settings.store.nameFormat) {
@@ -318,6 +317,11 @@ export default definePlugin({
.replaceAll("{artist}", trackData.artist || "")
.replaceAll("{album}", trackData.album || "")
.replaceAll("{title}", trackData.name || "");
+ case NameFormat.ServiceName:
+ return trackData.serviceName || settings.store.statusName
+ .replaceAll("{artist}", trackData.artist || "")
+ .replaceAll("{album}", trackData.album || "")
+ .replaceAll("{title}", trackData.name || "");
default:
return settings.store.statusName
.replaceAll("{artist}", trackData.artist || "")
@@ -350,11 +354,11 @@ export default definePlugin({
};
if (settings.store.clickableLinks) {
- activity.details_url = trackData.url;
- activity.state_url = `https://www.last.fm/music/${encodeURIComponent(trackData.artist)}`;
+ activity.details_url = trackData.trackURL;
+ activity.state_url = trackData.artistURL;
if (trackData.album) {
- activity.assets!.large_url = `https://www.last.fm/music/${encodeURIComponent(trackData.artist)}/${encodeURIComponent(trackData.album)}`;
+ activity.assets!.large_url = trackData.albumURL;
}
}
diff --git a/src/plugins/musicRichPresence/lastfm.ts b/src/plugins/musicRichPresence/lastfm.ts
new file mode 100644
index 000000000..dc58a1de6
--- /dev/null
+++ b/src/plugins/musicRichPresence/lastfm.ts
@@ -0,0 +1,63 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { Logger } from "@utils/Logger";
+
+import { ScrobblerBackend, TrackData } from ".";
+
+const logger = new Logger("MusicRichPresence/LastFM");
+
+const url = (path: string) => `https://www.last.fm${path}`;
+
+export const LastFMScrobbler: ScrobblerBackend = {
+ name: "Last.FM",
+ id: "lastfm",
+
+ async fetchTrackData(username: string, apiKey: string): Promise {
+ try {
+ const params = new URLSearchParams({
+ method: "user.getrecenttracks",
+ api_key: apiKey,
+ user: username,
+ limit: "1",
+ format: "json"
+ });
+
+ const res = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`);
+ if (!res.ok) throw `${res.status} ${res.statusText}`;
+
+ const json = await res.json();
+ if (json.error) {
+ logger.error("Error from Last.fm API", `${json.error}: ${json.message}`);
+ return null;
+ }
+
+ const trackData = json.recenttracks?.track[0];
+
+ if (!trackData?.["@attr"]?.nowplaying)
+ return null;
+
+ // why does the json api have xml structure
+ return {
+ name: trackData.name || "Unknown",
+ album: trackData.album["#text"],
+ artist: trackData.artist["#text"] || "Unknown",
+ trackURL: trackData.url,
+ artistURL: trackData.artist["#text"] ? url(`/music/${encodeURIComponent(trackData.artist["#text"])}`) : undefined,
+ albumURL: url(`/music/${encodeURIComponent(trackData.artist["#text"])}/${encodeURIComponent(trackData.album["#text"])}`),
+ imageURL: trackData.image?.find((x: any) => x.size === "large")?.["#text"]
+ } as TrackData;
+ } catch (e) {
+ logger.error("Failed to query Last.FM API", e);
+ // will clear the rich presence if API fails
+ return null;
+ }
+ },
+
+ getUserURL(username: string): string {
+ return url(`/user/${username}`);
+ }
+};
diff --git a/src/plugins/musicRichPresence/listenbrainz.ts b/src/plugins/musicRichPresence/listenbrainz.ts
new file mode 100644
index 000000000..6844b378e
--- /dev/null
+++ b/src/plugins/musicRichPresence/listenbrainz.ts
@@ -0,0 +1,105 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
+import { Logger } from "@utils/Logger";
+
+import { ScrobblerBackend, TrackData } from ".";
+
+const logger = new Logger("AudioScrobblerRichPresence/ListenBrainz");
+
+const url = (path: string) => `https://listenbrainz.org${path}`;
+
+async function fetchCoverArt(releaseGroupMBID: string) {
+ const res = await fetch(`https://coverartarchive.org/release-group/${releaseGroupMBID}`);
+ if (!res.ok) return null;
+ return res.json().then(json => json.images[0].thumbnails.large);
+}
+
+async function getUrls(additionalInfo: Record | undefined, trackName: string, artistName: string, releaseName: string): Promise> {
+ // Well tagged music will have MBIDs which we can use directly. These are optional but highly recommended in ListenBrainz scrobbles.
+ // If your music doesn't have these, it's highly recommended to use https://picard.musicbrainz.org/ to automatically add them
+ if (additionalInfo?.recording_mbid) {
+ const { release_group_mbid, release_mbid, recording_mbid, artist_mbids } = additionalInfo;
+
+ return {
+ imageURL: release_group_mbid ? await fetchCoverArt(release_group_mbid) : undefined,
+ trackURL: recording_mbid ? url(`/track/${recording_mbid}`) : undefined,
+ albumURL: release_group_mbid
+ ? url(`/release-group/${release_group_mbid}`)
+ : release_mbid
+ ? url(`/release/${release_mbid}`)
+ : undefined,
+ artistURL: artist_mbids?.length ? url(`/artist/${artist_mbids[0]}`) : undefined,
+ };
+ }
+
+ // If no MBIDs are present, try to search for the track on MusicBrainz
+
+ // this needs to be encoded separately—URLSearchParams encodes spaces as "+"
+ const query = encodeURIComponent(`artist:"${artistName}" AND recording:"${trackName}" AND album:${releaseName}`);
+
+ const params = new URLSearchParams({
+ fmt: "json",
+ limit: "1"
+ });
+
+ const metadata = await fetch("https://musicbrainz.org/ws/2/recording/?" + params + "&query=" + query, {
+ headers: { "User-Agent": VENCORD_USER_AGENT }
+ })
+ .then(res => res.ok ? res.json() : Promise.reject(new Error(`${res.status} ${res.statusText}`)))
+ .then(json => json.recordings?.[0]);
+
+ if (!metadata) {
+ return {};
+ }
+
+ const artist = metadata["artist-credit"]?.[0]?.artist;
+ const release = metadata.releases?.[0];
+
+ return {
+ imageURL: release?.["release-group"] ? await fetchCoverArt(release["release-group"].id) : undefined,
+ trackURL: url(`/track/${metadata.id}/`),
+ albumURL: release?.id ? url(`/release/${release.id}/`) : release?.["release-group"]?.id ? url(`/release-group/${release["release-group"].id}/`) : undefined,
+ artistURL: artist?.id ? url(`/artist/${artist.id}/`) : undefined,
+ };
+}
+
+export const ListenBrainzScrobbler: ScrobblerBackend = {
+ name: "ListenBrainz",
+ id: "listenbrainz",
+
+ async fetchTrackData(username: string, _apiKey?: string): Promise {
+ try {
+ const res = await fetch(`https://api.listenbrainz.org/1/user/${username}/playing-now`);
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+
+ const data = await res.json().then(json => json.payload?.listens[0]);
+ if (!data?.playing_now || !data?.track_metadata)
+ return null;
+
+ const { track_name, artist_name, release_name, additional_info } = data.track_metadata;
+
+ const trackData = {
+ name: track_name || "Unknown",
+ artist: artist_name,
+ album: release_name || "Unknown",
+ serviceName: additional_info?.music_service_name || additional_info?.submission_client,
+ ...await getUrls(additional_info, track_name, artist_name, release_name)
+ } as TrackData;
+
+ return trackData;
+ } catch (e) {
+ logger.error("Failed to query ListenBrainz API", e);
+ // will clear the rich presence if API fails
+ return null;
+ }
+ },
+
+ getUserURL(username: string): string {
+ return url(`/user/${username}`);
+ }
+};
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 85901da7f..b2255bb0f 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -646,6 +646,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "NightmareSan",
id: 304239816466235392n
},
+ angelcube: {
+ name: "angelcube",
+ id: 958505257288208446n
+ },
Lunascape: {
name: "Lunascape",
id: 383365021415243776n