LastFmRPC ~> MusicRPC: add ListenBrainz support (#4077)

Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
Angel Wang 2026-07-15 10:03:39 -06:00 • committed by GitHub
parent b8fbed9287
commit 9ffb2108e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 258 additions and 77 deletions

View file

@ -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

View file

@ -16,25 +16,38 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<TrackData | null>;
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<string> {
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 (
<Card>
<Heading tag="h2">Last.FM</Heading>
<Heading tag="h5">How to create an API key</Heading>
<Paragraph>Set <strong>Application name</strong> and <strong>Application description</strong> to anything and leave the rest blank.</Paragraph>
<LinkButton size="small" href="https://www.last.fm/api/account/create" className={Margins.top8}>Create API Key</LinkButton>
@ -210,61 +245,22 @@ export default definePlugin({
clearInterval(this.updateInterval);
},
async fetchTrackData(): Promise<TrackData | null> {
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<Activity | null> {
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;
}
}

View file

@ -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<TrackData | null> {
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}`);
}
};

View file

@ -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<string, string> | undefined, trackName: string, artistName: string, releaseName: string): Promise<Partial<TrackData>> {
// 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<TrackData | null> {
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}`);
}
};

View file

@ -646,6 +646,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "NightmareSan",
id: 304239816466235392n
},
angelcube: {
name: "angelcube",
id: 958505257288208446n
},
Lunascape: {
name: "Lunascape",
id: 383365021415243776n