MusicRRPC: add caching & custom listenbrainz instance support (#4421)

This commit is contained in:
V 2026-07-21 16:52:19 +02:00 • committed by GitHub
parent 23c42a0d8e
commit 159698f6fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 140 additions and 27 deletions

View file

@ -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<TrackData | null>;
fetchTrackData(): Promise<TrackData | null>;
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 (
<Card>
<Heading tag="h2">Last.FM</Heading>
@ -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<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)) {
@ -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);

View file

@ -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<TrackData | null> {
async fetchTrackData(): Promise<TrackData | null> {
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"
});

View file

@ -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<string, string>(15 * 60 * 1000);
const metadataCache = new TTLMap<string, Partial<TrackData> | 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<string | undefined> {
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<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;
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<string, string> | 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<string, string> | 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<TrackData> = {
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<TrackData | null> {
async fetchTrackData(): Promise<TrackData | null> {
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]);

43
src/utils/TTLMap.ts Normal file
View file

@ -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<K, V> extends Map<K, V> {
private readonly _timers = new Map<K, NodeJS.Timeout>();
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();
}
}