Plugin

LastFMRichPresence

Little plugin for Last.fm rich presence

Activity Media
index.tsx
Download

Source

src/plugins/lastfmRichPresence/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { definePluginSettings } from "@api/Settings";
8import { LinkButton } from "@components/Button";
9import { Card } from "@components/Card";
10import { Heading } from "@components/Heading";
11import { Margins } from "@components/margins";
12import { Paragraph } from "@components/Paragraph";
13import { Devs } from "@utils/constants";
14import { Logger } from "@utils/Logger";
15import definePlugin, { OptionType } from "@utils/types";
16import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";
17import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord/discord-types/enums";
18import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common";
19
20interface TrackData {
21 name: string;
22 album: string;
23 artist: string;
24 url: string;
25 imageUrl?: string;
26}
27
28const 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.
38const API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
39const DISCORD_APP_ID = "1108588077900898414";
40const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f";
41
42const logger = new Logger("LastFMRichPresence");
43
44async function getApplicationAsset(key: string): Promise<string> {
45 return (await ApplicationAssetUtils.fetchAssetIds(DISCORD_APP_ID, [key]))[0];
46}
47
48function setActivity(activity: Activity | null) {
49 FluxDispatcher.dispatch({
50 type: "LOCAL_ACTIVITY_UPDATE",
51 activity,
52 socketId: "LastFM",
53 });
54}
55
56const 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: "Don&#039;t show (shows generic listening message)",
97 value: "off"
98 },
99 {
100 label: "Show artist name",
101 value: "artist",
102 default: true
103 },
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: true
118 },
119 {
120 label: "Use format &#039;artist - song&#039;",
121 value: NameFormat.ArtistFirst
122 },
123 {
124 label: "Use format &#039;song - artist&#039;",
125 value: NameFormat.SongFirst
126 },
127 {
128 label: "Use artist name only",
129 value: NameFormat.ArtistOnly
130 },
131 {
132 label: "Use song name only",
133 value: NameFormat.SongOnly
134 },
135 {
136 label: "Use album name (falls back to custom status text if song has no album)",
137 value: NameFormat.AlbumName
138 }
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: true
154 },
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
174export 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 structure
229 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 fails
239 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.statusName
306 .replaceAll("{artist}", trackData.artist || "")
307 .replaceAll("{album}", trackData.album || "")
308 .replaceAll("{title}", trackData.name || "");
309 default:
310 return settings.store.statusName
311 .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.DETAILS
327 }[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