LastFmRPC ~> MusicRPC: add ListenBrainz support (#4077)
Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
parent
b8fbed9287
commit
9ffb2108e8
5 changed files with 258 additions and 77 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
63
src/plugins/musicRichPresence/lastfm.ts
Normal file
63
src/plugins/musicRichPresence/lastfm.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
105
src/plugins/musicRichPresence/listenbrainz.ts
Normal file
105
src/plugins/musicRichPresence/listenbrainz.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -646,6 +646,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
|||
name: "NightmareSan",
|
||||
id: 304239816466235392n
|
||||
},
|
||||
angelcube: {
|
||||
name: "angelcube",
|
||||
id: 958505257288208446n
|
||||
},
|
||||
Lunascape: {
|
||||
name: "Lunascape",
|
||||
id: 383365021415243776n
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue