Plugin
LastFMRichPresence
Little plugin for Last.fm rich presence
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { definePluginSettings } from "@api/Settings";8
import { LinkButton } from "@components/Button";9
import { Card } from "@components/Card";10
import { Heading } from "@components/Heading";11
import { Margins } from "@components/margins";12
import { Paragraph } from "@components/Paragraph";13
import { Devs } from "@utils/constants";14
import { Logger } from "@utils/Logger";15
import definePlugin, { OptionType } from "@utils/types";16
import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";17
import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord/discord-types/enums";18
import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common";19
20
interface TrackData {21
name: string;22
album: string;23
artist: string;24
url: string;25
imageUrl?: string;26
}27
28
const enum NameFormat {29
StatusName = "status-name",30
ArtistFirst = "artist-first",31
SongFirst = "song-first",32
ArtistOnly = "artist",33
SongOnly = "song",34
AlbumName = "album"35
}36
37
// Last.fm API keys are essentially public information and have no access to your account, so including one here is fine.38
const API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";39
const DISCORD_APP_ID = "1108588077900898414";40
const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f";41
42
const logger = new Logger("LastFMRichPresence");43
44
async function getApplicationAsset(key: string): Promise<string> {45
return (await ApplicationAssetUtils.fetchAssetIds(DISCORD_APP_ID, [key]))[0];46
}47
48
function setActivity(activity: Activity | null) {49
FluxDispatcher.dispatch({50
type: "LOCAL_ACTIVITY_UPDATE",51
activity,52
socketId: "LastFM",53
});54
}55
56
const settings = definePluginSettings({57
apiKey: {58
displayName: "API Key",59
description: "Custom Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key",60
type: OptionType.STRING,61
},62
username: {63
description: "Last.fm username",64
type: OptionType.STRING,65
},66
shareUsername: {67
description: "Show link to Last.fm profile",68
type: OptionType.BOOLEAN,69
default: false,70
},71
clickableLinks: {72
description: "Make track, artist and album names clickable links",73
type: OptionType.BOOLEAN,74
default: true,75
},76
hideWithSpotify: {77
description: "Hide Last.fm presence if spotify is running",78
type: OptionType.BOOLEAN,79
default: true,80
},81
hideWithActivity: {82
description: "Hide Last.fm presence if you have any other presence",83
type: OptionType.BOOLEAN,84
default: false,85
},86
statusName: {87
description: "Custom status text. You can use the following variables: {artist} | {album} | {title}",88
type: OptionType.STRING,89
default: "some music",90
},91
statusDisplayType: {92
description: "Show the track / artist name in the member list",93
type: OptionType.SELECT,94
options: [95
{96
label: "Don039;t show (shows generic listening message)",97
value: "off"98
},99
{100
label: "Show artist name",101
value: "artist",102
default: true103
},104
{105
label: "Show track name",106
value: "track"107
}108
]109
},110
nameFormat: {111
description: "Show name of song and artist in status name",112
type: OptionType.SELECT,113
options: [114
{115
label: "Use custom status name",116
value: NameFormat.StatusName,117
default: true118
},119
{120
label: "Use format 039;artist - song039;",121
value: NameFormat.ArtistFirst122
},123
{124
label: "Use format 039;song - artist039;",125
value: NameFormat.SongFirst126
},127
{128
label: "Use artist name only",129
value: NameFormat.ArtistOnly130
},131
{132
label: "Use song name only",133
value: NameFormat.SongOnly134
},135
{136
label: "Use album name (falls back to custom status text if song has no album)",137
value: NameFormat.AlbumName138
}139
],140
},141
useListeningStatus: {142
description: 039;Show "Listening to" status instead of "Playing"039;,143
type: OptionType.BOOLEAN,144
default: false,145
},146
missingArt: {147
description: "When album or album art is missing",148
type: OptionType.SELECT,149
options: [150
{151
label: "Use large Last.fm logo",152
value: "lastfmLogo",153
default: true154
},155
{156
label: "Use generic placeholder",157
value: "placeholder"158
}159
],160
},161
showLastFmLogo: {162
displayName: "Show Last.fm Logo",163
description: "Show the Last.fm logo by the album cover",164
type: OptionType.BOOLEAN,165
default: true,166
},167
showAlbumCover: {168
description: "Show album cover. Disabling this will display a placeholder. Useful if your Music has inappropriate art",169
type: OptionType.BOOLEAN,170
default: true,171
}172
});173
174
export default definePlugin({175
name: "LastFMRichPresence",176
description: "Little plugin for Last.fm rich presence",177
tags: ["Activity", "Media"],178
authors: [Devs.dzshn, Devs.RuiNtD, Devs.blahajZip, Devs.archeruwu],179
180
settings,181
182
settingsAboutComponent() {183
return (184
<Card>185
<Heading tag="h5">How to create an API key</Heading>186
<Paragraph>Set <strong>Application name</strong> and <strong>Application description</strong> to anything and leave the rest blank.</Paragraph>187
<LinkButton size="small" href="https:class="ts-cmt">//www.last.fm/api/account/create" className={Margins.top8}>Create API Key</LinkButton>188
</Card>189
);190
},191
192
start() {193
this.updatePresence();194
this.updateInterval = setInterval(() => { this.updatePresence(); }, 16000);195
},196
197
stop() {198
clearInterval(this.updateInterval);199
},200
201
async fetchTrackData(): Promise<TrackData | null> {202
if (!settings.store.username)203
return null;204
205
try {206
const params = new URLSearchParams({207
method: "user.getrecenttracks",208
api_key: settings.store.apiKey || API_KEY,209
user: settings.store.username,210
limit: "1",211
format: "json"212
});213
214
const res = await fetch(`https:class="ts-cmt">//ws.audioscrobbler.com/2.0/?${params}`);215
if (!res.ok) throw `${res.status} ${res.statusText}`;216
217
const json = await res.json();218
if (json.error) {219
logger.error("Error from Last.fm API", `${json.error}: ${json.message}`);220
return null;221
}222
223
const trackData = json.recenttracks?.track[0];224
225
if (!trackData?.["@attr"]?.nowplaying)226
return null;227
228
// why does the json api have xml structure229
return {230
name: trackData.name || "Unknown",231
album: trackData.album["#text"],232
artist: trackData.artist["#text"] || "Unknown",233
url: trackData.url,234
imageUrl: trackData.image?.find((x: any) => x.size === "large")?.["#text"]235
};236
} catch (e) {237
logger.error("Failed to query Last.fm API", e);238
// will clear the rich presence if API fails239
return null;240
}241
},242
243
async updatePresence() {244
setActivity(await this.getActivity());245
},246
247
getLargeImage(track: TrackData): string | undefined {248
if (settings.store.showAlbumCover && track.imageUrl && !track.imageUrl.includes(LASTFM_PLACEHOLDER_IMAGE_HASH))249
return track.imageUrl;250
251
if (settings.store.missingArt === "placeholder")252
return "placeholder";253
},254
255
async getActivity(): Promise<Activity | null> {256
if (settings.store.hideWithActivity) {257
if (PresenceStore.getActivities(AuthenticationStore.getId()).some(a => a.application_id !== DISCORD_APP_ID && a.type !== ActivityType.CUSTOM_STATUS)) {258
return null;259
}260
}261
262
if (settings.store.hideWithSpotify) {263
if (PresenceStore.getActivities(AuthenticationStore.getId()).some(a => a.type === ActivityType.LISTENING && a.application_id !== DISCORD_APP_ID)) {264
// there is already music status because of Spotify or richerCider (probably more)265
return null;266
}267
}268
269
const trackData = await this.fetchTrackData();270
if (!trackData) return null;271
272
const largeImage = this.getLargeImage(trackData);273
const assets: ActivityAssets = largeImage ?274
{275
large_image: await getApplicationAsset(largeImage),276
large_text: trackData.album || undefined,277
...(settings.store.showLastFmLogo && {278
small_image: await getApplicationAsset("lastfm-small"),279
small_text: "Last.fm"280
}),281
} : {282
large_image: await getApplicationAsset("lastfm-large"),283
large_text: trackData.album || undefined,284
};285
286
const buttons: ActivityButton[] = [];287
288
if (settings.store.shareUsername)289
buttons.push({290
label: "Last.fm Profile",291
url: `https:class="ts-cmt">//www.last.fm/user/${settings.store.username}`,292
});293
294
const statusName = (() => {295
switch (settings.store.nameFormat) {296
case NameFormat.ArtistFirst:297
return trackData.artist + " - " + trackData.name;298
case NameFormat.SongFirst:299
return trackData.name + " - " + trackData.artist;300
case NameFormat.ArtistOnly:301
return trackData.artist;302
case NameFormat.SongOnly:303
return trackData.name;304
case NameFormat.AlbumName:305
return trackData.album || settings.store.statusName306
.replaceAll("{artist}", trackData.artist || "")307
.replaceAll("{album}", trackData.album || "")308
.replaceAll("{title}", trackData.name || "");309
default:310
return settings.store.statusName311
.replaceAll("{artist}", trackData.artist || "")312
.replaceAll("{album}", trackData.album || "")313
.replaceAll("{title}", trackData.name || "");314
}315
})();316
317
const activity: Activity = {318
application_id: DISCORD_APP_ID,319
name: statusName,320
321
details: trackData.name,322
state: trackData.artist,323
status_display_type: {324
"off": ActivityStatusDisplayType.NAME,325
"artist": ActivityStatusDisplayType.STATE,326
"track": ActivityStatusDisplayType.DETAILS327
}[settings.store.statusDisplayType],328
329
assets,330
331
buttons: buttons.length ? buttons.map(v => v.label) : undefined,332
metadata: {333
button_urls: buttons.map(v => v.url),334
},335
336
type: settings.store.useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING,337
flags: ActivityFlags.INSTANCE,338
};339
340
if (settings.store.clickableLinks) {341
activity.details_url = trackData.url;342
activity.state_url = `https:class="ts-cmt">//www.last.fm/music/${encodeURIComponent(trackData.artist)}`;343
344
if (trackData.album) {345
activity.assets!.large_url = `https:class="ts-cmt">//www.last.fm/music/${encodeURIComponent(trackData.artist)}/${encodeURIComponent(trackData.album)}`;346
}347
}348
349
return activity;350
}351
});352