From 159698f6fc09e3f0c540948f759df59908b1ed9f Mon Sep 17 00:00:00 2001 From: V Date: Tue, 21 Jul 2026 16:52:19 +0200 Subject: [PATCH] MusicRRPC: add caching & custom listenbrainz instance support (#4421) --- src/plugins/musicRichPresence/index.tsx | 42 ++++++++--- src/plugins/musicRichPresence/lastfm.ts | 11 +-- src/plugins/musicRichPresence/listenbrainz.ts | 71 +++++++++++++++---- src/utils/TTLMap.ts | 43 +++++++++++ 4 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 src/utils/TTLMap.ts diff --git a/src/plugins/musicRichPresence/index.tsx b/src/plugins/musicRichPresence/index.tsx index 9ba35e29a..c10dd2c6c 100644 --- a/src/plugins/musicRichPresence/index.tsx +++ b/src/plugins/musicRichPresence/index.tsx @@ -29,7 +29,7 @@ import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common"; import { LastFMScrobbler } from "./lastfm"; -import { ListenBrainzScrobbler } from "./listenbrainz"; +import { invalidateListenBrainzCache, ListenBrainzScrobbler } from "./listenbrainz"; export interface TrackData { name: string; @@ -46,7 +46,7 @@ export interface ScrobblerBackend { name: string, id: string, - fetchTrackData(username: string, apiKey?: string): Promise; + fetchTrackData(): Promise; getUserURL(username: string): string; } @@ -60,8 +60,6 @@ const enum NameFormat { 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 LASTFM_API_KEY = "790c37d90400163a5a5fe00d6ca32ef0"; const DISCORD_APP_ID = "1108588077900898414"; const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f"; @@ -77,7 +75,7 @@ function setActivity(activity: Activity | null) { }); } -const settings = definePluginSettings({ +export const settings = definePluginSettings({ scrobblerBackend: { description: "The scrobbler backend to use.", type: OptionType.SELECT, @@ -90,12 +88,28 @@ const settings = definePluginSettings({ { "label": "ListenBrainz", "value": "listenbrainz" + }, + { + "label": "ListenBrainz Compatible (self-hosted)", + "value": "listenbrainz-compatible" } ] as const }, + instanceBaseURL: { + description: "The base url of your ListenBrainz instance.", + type: OptionType.STRING, + placeholder: "https://example.org", + onChange: invalidateListenBrainzCache + }, + instanceAPIBaseUrl: { + description: "The base url of your ListenBrainz API.", + type: OptionType.STRING, + placeholder: "https://api.example.org", + onChange: invalidateListenBrainzCache + }, apiKey: { displayName: "API Key", - description: "Custom Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key", + description: "Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key", type: OptionType.STRING, }, username: { @@ -212,6 +226,10 @@ const settings = definePluginSettings({ type: OptionType.BOOLEAN, default: true, } +}, { + apiKey: { hidden() { return this.store.scrobblerBackend !== "lastfm"; } }, + instanceBaseURL: { hidden() { return this.store.scrobblerBackend !== "listenbrainz-compatible"; } }, + instanceAPIBaseUrl: { hidden() { return this.store.scrobblerBackend !== "listenbrainz-compatible"; } }, }); migratePluginSettings("MusicRichPresence", "LastFMRichPresence"); @@ -226,6 +244,9 @@ export default definePlugin({ settings, settingsAboutComponent() { + if (settings.store.scrobblerBackend !== "lastfm") + return null; + return ( Last.FM @@ -246,6 +267,11 @@ export default definePlugin({ }, async updatePresence() { + const { username, scrobblerBackend, instanceAPIBaseUrl, instanceBaseURL } = settings.store; + + if (!username) return; + if (scrobblerBackend === "listenbrainz-compatible" && (!instanceAPIBaseUrl || !instanceBaseURL)) return; + setActivity(await this.getActivity()); }, @@ -258,8 +284,6 @@ export default definePlugin({ }, 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)) { @@ -276,7 +300,7 @@ export default definePlugin({ const scrobbler = settings.store.scrobblerBackend === "lastfm" ? LastFMScrobbler : ListenBrainzScrobbler; - const trackData = await scrobbler.fetchTrackData(settings.store.username, settings.store.apiKey || LASTFM_API_KEY); + const trackData = await scrobbler.fetchTrackData(); if (!trackData) return null; const largeImage = this.getLargeImage(trackData); diff --git a/src/plugins/musicRichPresence/lastfm.ts b/src/plugins/musicRichPresence/lastfm.ts index dc58a1de6..0ad7492a7 100644 --- a/src/plugins/musicRichPresence/lastfm.ts +++ b/src/plugins/musicRichPresence/lastfm.ts @@ -6,7 +6,10 @@ import { Logger } from "@utils/Logger"; -import { ScrobblerBackend, TrackData } from "."; +import { ScrobblerBackend, settings, TrackData } from "."; + +// Last.fm API keys are essentially public information and have no access to your account, so including one here is fine. +const LASTFM_API_KEY = "790c37d90400163a5a5fe00d6ca32ef0"; const logger = new Logger("MusicRichPresence/LastFM"); @@ -16,12 +19,12 @@ export const LastFMScrobbler: ScrobblerBackend = { name: "Last.FM", id: "lastfm", - async fetchTrackData(username: string, apiKey: string): Promise { + async fetchTrackData(): Promise { try { const params = new URLSearchParams({ method: "user.getrecenttracks", - api_key: apiKey, - user: username, + api_key: settings.store.apiKey || LASTFM_API_KEY, + user: settings.store.username!, limit: "1", format: "json" }); diff --git a/src/plugins/musicRichPresence/listenbrainz.ts b/src/plugins/musicRichPresence/listenbrainz.ts index 6844b378e..e19148620 100644 --- a/src/plugins/musicRichPresence/listenbrainz.ts +++ b/src/plugins/musicRichPresence/listenbrainz.ts @@ -6,27 +6,59 @@ import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent"; import { Logger } from "@utils/Logger"; +import { TTLMap } from "@utils/TTLMap"; -import { ScrobblerBackend, TrackData } from "."; +import { ScrobblerBackend, settings, TrackData } from "."; const logger = new Logger("AudioScrobblerRichPresence/ListenBrainz"); -const url = (path: string) => `https://listenbrainz.org${path}`; +// 15 minutes +const coverArtCache = new TTLMap(15 * 60 * 1000); +const metadataCache = new TTLMap | null>(15 * 60 * 1000); + +const isCustomInstance = () => settings.store.scrobblerBackend === "listenbrainz-compatible"; +const url = (path: string) => `${isCustomInstance() ? settings.store.instanceBaseURL : "https://listenbrainz.org"}${path}`; +const apiUrl = (path: string) => `${isCustomInstance() ? settings.store.instanceAPIBaseUrl : "https://api.listenbrainz.org"}${path}`; + +export function invalidateListenBrainzCache() { + coverArtCache.clear(); + metadataCache.clear(); +} + +const YoutubeVideoURLRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(?:-nocookie)?\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|live\/|v\/)?)([\w-]+)(\S+)?$/; + +function fallbackToYoutubeThumbnail(originUrl: string | undefined): string | undefined { + if (!originUrl) return undefined; + + const match = originUrl.match(YoutubeVideoURLRegex); + return match ? `https://i.ytimg.com/vi/${match[5]}/maxresdefault.jpg` : undefined; +} + +async function fetchCoverArt(releaseGroupMBID: string, originUrl?: string): Promise { + if (!releaseGroupMBID) return fallbackToYoutubeThumbnail(originUrl); + + if (coverArtCache.has(releaseGroupMBID)) { + return coverArtCache.get(releaseGroupMBID); + } -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); + if (!res.ok) return fallbackToYoutubeThumbnail(originUrl); + + const url = await res.json() + .then(json => json.images[0].thumbnails.large ?? fallbackToYoutubeThumbnail(originUrl)); + coverArtCache.set(releaseGroupMBID, url); + + return url; } 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; + const { release_group_mbid, release_mbid, recording_mbid, artist_mbids, origin_url } = additionalInfo; return { - imageURL: release_group_mbid ? await fetchCoverArt(release_group_mbid) : undefined, + imageURL: await fetchCoverArt(release_group_mbid, origin_url), trackURL: recording_mbid ? url(`/track/${recording_mbid}`) : undefined, albumURL: release_group_mbid ? url(`/release-group/${release_group_mbid}`) @@ -39,8 +71,14 @@ async function getUrls(additionalInfo: Record | undefined, track // 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}`); + let rawQuery = `artist:"${artistName}" AND recording:"${trackName}"`; + if (releaseName) + rawQuery += ` AND album:"${releaseName}"`; + const query = encodeURIComponent(rawQuery); + + if (metadataCache.has(query)) { + return metadataCache.get(query) ?? {}; + } const params = new URLSearchParams({ fmt: "json", @@ -54,27 +92,32 @@ async function getUrls(additionalInfo: Record | undefined, track .then(json => json.recordings?.[0]); if (!metadata) { - return {}; + const data = additionalInfo?.origin_url ? { imageURL: fallbackToYoutubeThumbnail(additionalInfo.origin_url) } : {}; + metadataCache.set(query, data); + return data; } const artist = metadata["artist-credit"]?.[0]?.artist; const release = metadata.releases?.[0]; - return { - imageURL: release?.["release-group"] ? await fetchCoverArt(release["release-group"].id) : undefined, + const data: Partial = { + imageURL: await fetchCoverArt(release?.["release-group"]?.id, additionalInfo?.origin_url), 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, }; + metadataCache.set(query, data); + + return data; } export const ListenBrainzScrobbler: ScrobblerBackend = { name: "ListenBrainz", id: "listenbrainz", - async fetchTrackData(username: string, _apiKey?: string): Promise { + async fetchTrackData(): Promise { try { - const res = await fetch(`https://api.listenbrainz.org/1/user/${username}/playing-now`); + const res = await fetch(apiUrl(`/1/user/${settings.store.username}/playing-now`)); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const data = await res.json().then(json => json.payload?.listens[0]); diff --git a/src/utils/TTLMap.ts b/src/utils/TTLMap.ts new file mode 100644 index 000000000..2217fb477 --- /dev/null +++ b/src/utils/TTLMap.ts @@ -0,0 +1,43 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * A Map whose entries expire after a given amount of time. When an entry expires, it is automatically removed from the map and an optional callback is called. + */ +export class TTLMap extends Map { + private readonly _timers = new Map(); + + public constructor(public readonly expiryMs: number, private readonly onExpire?: (key: K, value: V) => void) { + super(); + } + + public set(key: K, value: V) { + const timeoutId = setTimeout(() => { + this.delete(key); + this.onExpire?.(key, value); + }, this.expiryMs); + this._timers.set(key, timeoutId); + + return super.set(key, value); + } + + public delete(key: K) { + if (this._timers.has(key)) { + clearTimeout(this._timers.get(key)); + this._timers.delete(key); + } + + return super.delete(key); + } + + clear(): void { + for (const timeoutId of this._timers.values()) + clearTimeout(timeoutId); + + this._timers.clear(); + return super.clear(); + } +}