Plugin

LastFMRichPresence

Little plugin for Last.fm rich presence

Activity Media
index.tsx
Download

Source

src/plugins/lastfmRichPresence/index.tsx
1import { definePluginSettings } from "@api/Settings";
2import { LinkButton } from "@components/Button";
3import { Card } from "@components/Card";
4import { Heading } from "@components/Heading";
5import { Margins } from "@components/margins";
6import { Paragraph } from "@components/Paragraph";
7import { Devs } from "@utils/constants";
8import { Logger } from "@utils/Logger";
9import definePlugin, { OptionType } from "@utils/types";
10import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";
11import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord/discord-types/enums";
12import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common";
13
14interface TrackData {
15 name: string;
16 album: string;
17 artist: string;
18 url: string;
19 imageUrl?: string;
20}
21
22const 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.
32const API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
33const DISCORD_APP_ID = "1108588077900898414";
34const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f";
35
36const logger = new Logger("LastFMRichPresence");
37
38async function getApplicationAsset(key: string): Promise<string> {
39 return (await ApplicationAssetUtils.fetchAssetIds(DISCORD_APP_ID, [key]))[0];
40}
41
42function setActivity(activity: Activity | null) {
43 FluxDispatcher.dispatch({
44 type: "LOCAL_ACTIVITY_UPDATE",
45 activity,
46 socketId: "LastFM",
47 });
48}
49
50const 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: "Don&#039;t show (shows generic listening message)",
91 value: "off"
92 },
93 {
94 label: "Show artist name",
95 value: "artist",
96 default: true
97 },
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: true
112 },
113 {
114 label: "Use format &#039;artist - song&#039;",
115 value: NameFormat.ArtistFirst
116 },
117 {
118 label: "Use format &#039;song - artist&#039;",
119 value: NameFormat.SongFirst
120 },
121 {
122 label: "Use artist name only",
123 value: NameFormat.ArtistOnly
124 },
125 {
126 label: "Use song name only",
127 value: NameFormat.SongOnly
128 },
129 {
130 label: "Use album name (falls back to custom status text if song has no album)",
131 value: NameFormat.AlbumName
132 }
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: true
148 },
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
168export 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 structure
223 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 fails
233 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.statusName
300 .replaceAll("{artist}", trackData.artist || "")
301 .replaceAll("{album}", trackData.album || "")
302 .replaceAll("{title}", trackData.name || "");
303 default:
304 return settings.store.statusName
305 .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.DETAILS
321 }[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