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