From 9ffb2108e856f194f0081cc99f54e5b8bf64e6ff Mon Sep 17 00:00:00 2001 From: Angel Wang Date: Wed, 15 Jul 2026 10:03:39 -0600 Subject: [PATCH] LastFmRPC ~> MusicRPC: add ListenBrainz support (#4077) Co-authored-by: V --- src/main/csp/index.ts | 5 + .../index.tsx | 158 +++++++++--------- src/plugins/musicRichPresence/lastfm.ts | 63 +++++++ src/plugins/musicRichPresence/listenbrainz.ts | 105 ++++++++++++ src/utils/constants.ts | 4 + 5 files changed, 258 insertions(+), 77 deletions(-) rename src/plugins/{lastfmRichPresence => musicRichPresence}/index.tsx (74%) create mode 100644 src/plugins/musicRichPresence/lastfm.ts create mode 100644 src/plugins/musicRichPresence/listenbrainz.ts 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