MusicRRPC: add caching & custom listenbrainz instance support (#4421)
This commit is contained in:
parent
23c42a0d8e
commit
159698f6fc
4 changed files with 140 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
43
src/utils/TTLMap.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue