From 85ef273cdefd25a9e69169d0cabe4974bd1af78a Mon Sep 17 00:00:00 2001 From: amsyarasyiq <82711525+amsyarasyiq@users.noreply.github.com> Date: Sun, 12 Feb 2023 20:31:51 +0800 Subject: [PATCH] Last.fm: Reorganize everything --- plugins/Last.fm/src/index.ts | 272 ++----------------------------- plugins/Last.fm/src/settings.tsx | 31 ++-- plugins/Last.fm/src/types.ts | 48 ++++++ plugins/Last.fm/src/utils.ts | 205 +++++++++++++++++++++++ 4 files changed, 292 insertions(+), 264 deletions(-) create mode 100644 plugins/Last.fm/src/utils.ts diff --git a/plugins/Last.fm/src/index.ts b/plugins/Last.fm/src/index.ts index 27a5789..af7c2e1 100644 --- a/plugins/Last.fm/src/index.ts +++ b/plugins/Last.fm/src/index.ts @@ -1,286 +1,50 @@ -import { patcher, plugin } from "@vendetta"; +import { plugin } from "@vendetta"; import { findByProps, findByStoreName } from '@vendetta/metro'; import { FluxDispatcher } from '@vendetta/metro/common'; -import Constants from "./constants"; -import Settings from "./settings"; -let stopped: Boolean, - lastActivity: Activity, - updateInterval: NodeJS.Timer, - lastTrackUrl: string, - activityTypeUnpatch: () => void; +import Settings from "./Settings"; +import { flush, initialize, patchActivityType } from "./utils"; -// Discord activity -type Activity = { - name: string; - type: number; - details: string; - state: string; - timestamps?: { - start: number; - end?: number; - }; - assets?: ActivityAssets; -} - -type ResultActivity = { - name: string; - type: number; - details: string; - state: string; - timestamps?: { - start: number; - end?: number; - }; - assets?: ActivityAssets; - application_id: string; -}; - -type ActivityAssets = { - large_image: string; - large_text?: string; -} | { - small_image: string; - small_text?: string; -}; - -type Track = { - name: string; - artist: string; - album: string; - albumArt: string; - url: string; - date: string; - nowPlaying: boolean; - loved: boolean; +export const global = {} as { + stopped?: Boolean, + lastActivity?: Activity, + updateInterval?: NodeJS.Timer, + lastTrackUrl?: string, + activityTypeUnpatch?: () => boolean } export const currentSettings = { ...plugin.storage } as PluginSettings; // Discord modules that we need -const UserStore = findByStoreName("UserStore") -const SET_ACTIVITY = findByProps('SET_ACTIVITY').SET_ACTIVITY; +export const UserStore = findByStoreName("UserStore") +export const SET_ACTIVITY = findByProps('SET_ACTIVITY').SET_ACTIVITY; -const verboseLog = (...message: any) => currentSettings.verboseLogging && console.log(...message); - -async function fetchCurrentScrobble(): Promise { - const params = new URLSearchParams({ - 'method': 'user.getrecenttracks', - 'user': currentSettings.username, - 'api_key': Constants.LFM_API_KEY, - 'format': 'json', - 'limit': '1', - 'extended': '1' - }).toString(); - - const info = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`).then(x => x.json()); - const lastTrack = info.recenttracks?.track?.[0] || Promise.reject(info); - return { - name: lastTrack.name, - artist: lastTrack.artist.name, - album: lastTrack.album['#text'], - albumArt: filterAlbumCover(lastTrack.image[3]['#text']), - url: lastTrack.url, - date: lastTrack.date?.['#text'] ?? 'now', - nowPlaying: Boolean(lastTrack['@attr']?.nowplaying), - loved: lastTrack.loved === '1', - } as Track; -} - -// Currently ditches the default album covers (they're ugly) -// Might as well use Youtube as fallback too (soontm) -function filterAlbumCover(cover: string): string { - if (Constants.DEFAULT_COVER_HASHES.some(x => cover.includes(x))) { - return null; - } - return cover; -} - -// Clears the activity -function clearActivity(): Promise { - lastActivity && verboseLog("--> Clearing activity..."); - return sendRequest(null); -} - -// Sends the activity details to Discord -async function sendRequest(activity: Activity): Promise { - if (stopped) { - console.log("--> Plugin is unloaded, aborting..."); - updateInterval && clearInterval(updateInterval); - activity = null; - } - - lastActivity = activity; - - return await SET_ACTIVITY.handler({ - isSocketConnected: () => true, - socket: { - id: 120, - application: { - id: Constants.APPLICATION_ID, - name: activity?.name || "RichPresence" - }, - transport: "ipc" - }, - args: { - pid: 120, - activity: activity && { ...activity } || null - } - }); -} - -// Fetches the current scrobble and sets the activity -// This is the main function that is called every 5 seconds (or whatever the user has set) -export async function update() { - verboseLog("--> Fetching last track..."); - const lastTrack = await fetchCurrentScrobble().catch((err) => { - verboseLog("--> An error occurred while fetching the last track, aborting..."); - clearActivity(); - throw err; - }); - - if (!lastTrack.nowPlaying) { - verboseLog("--> Last track is not currently playing, aborting..."); - return await clearActivity(); - } - - verboseLog("--> Setting activity..."); - - if (lastTrackUrl === lastTrack.url) { - verboseLog("--> Last track is the same as the previous one, aborting..."); - return; - } - - const activity = { - name: currentSettings.appName || "Music", - type: currentSettings.listeningTo ? 2 : 0, - details: lastTrack.name, - state: `by ${lastTrack.artist}`, - } as Activity; - - lastTrackUrl = lastTrack.url; - - if (currentSettings.showTimestamp) { - activity.timestamps = { - start: Date.now() / 1000 | 0 - }; - } - - if (lastTrack.albumArt) { - activity.assets = { - large_image: lastTrack.albumArt, - large_text: `on ${lastTrack.album}`, - } - } - - const response = await sendRequest(activity).catch((err) => { - verboseLog("--> An error occurred while setting the activity"); - clearActivity(); - throw err; - }); - - verboseLog("--> Successfully set activity!"); - verboseLog(response); - return response; -} - -// Stops everything -export function flush(): Promise { - console.log("--> Flushing..."); - lastActivity = null; - updateInterval && clearInterval(updateInterval); - - return clearActivity(); -} - -// Initializes the plugin -export async function initialize() { - console.log("--> Initializing..."); - - stopped = false; - lastActivity = null; - lastTrackUrl = null; - - if (updateInterval) clearInterval(updateInterval); - if (lastActivity) await clearActivity(); - - let tries = 0; - - update().catch((err) => { - console.error(err); - tries++; - }); - - - // Periodically fetches the current scrobble and sets the activity - updateInterval = setInterval( - () => update() - .then(() => tries = 0) - .catch(err => { - console.error(err); - - if (++tries > 3) { - console.error("Failed to fetch/set activity 3 times, aborting..."); - clearInterval(updateInterval); - stopped = true; - } - }), - currentSettings.timeInterval - ); -} - -// Discord doesn't allow us to set the activity type to "Listening to" so we have to patch it -function patchActivityType() { - if (activityTypeUnpatch) { - console.warn("This is weird... activityTypeUnpatch is already defined?"); - activityTypeUnpatch(); - } else { - console.log("Patching activity type..."); - } - - const nodes = Object.values(FluxDispatcher._actionHandlers._dependencyGraph.nodes); - const { actionHandler } = nodes.find((x: any) => x.name === "LocalActivityStore") as any; - - activityTypeUnpatch = patcher.before("LOCAL_ACTIVITY_UPDATE", actionHandler, ([{ activity }]) => { - if (activity.name === lastActivity.name) { - activity.type = lastActivity.type; - } - }); -} +export const verboseLog = (...message: any) => currentSettings.verboseLogging && console.log(...message); // Plugin entry point export default { onLoad: () => { console.log("Starting last.fm plugin.."); - patchActivityType(); + global.activityTypeUnpatch = patchActivityType(); + global.stopped = false; - // Options - // settings.appName = "Music"; - // settings.username = "slyde99"; - // settings.showTimestamp = true; - // settings.timeInterval = 5000; - // settings.listeningTo = true; - // settings.verboseLogging = false; - - // If the user is already logged in (brrr connection), initialize the plugin if (UserStore.getCurrentUser()) { console.log("User is already logged in, initializing..."); initialize().catch(console.error); } - // Just in case the user logs in before the plugin is loaded FluxDispatcher.subscribe("CONNECTION_OPEN", () => { - initialize().catch(console.error) + initialize().catch(console.error); }); }, onUnload: () => { console.log("Stopping last.fm..."); - stopped = true; + global.stopped = true; flush(); - activityTypeUnpatch?.(); - activityTypeUnpatch = null; + global.activityTypeUnpatch?.(); + global.activityTypeUnpatch = null; }, settings: Settings } diff --git a/plugins/Last.fm/src/settings.tsx b/plugins/Last.fm/src/settings.tsx index 40a7963..efb5f07 100644 --- a/plugins/Last.fm/src/settings.tsx +++ b/plugins/Last.fm/src/settings.tsx @@ -1,11 +1,14 @@ import { NavigationNative, React, ReactNative } from "@vendetta/metro/common"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; +import { getAssetIDByName } from "@vendetta/ui/assets"; import { Forms } from "@vendetta/ui/components"; -import { currentSettings, flush, initialize } from "."; -const { ScrollView, TouchableOpacity } = ReactNative; -const { FormInput, FormDivider, FormSwitchRow, FormText } = Forms; +import { currentSettings } from "."; +import { flush, initialize } from "./utils"; + +const { ScrollView, TouchableOpacity, Text } = ReactNative; +const { FormInput, FormDivider, FormSwitchRow, FormText, FormIcon, FormTextColors } = Forms; export default function Settings() { const settings = useProxy(storage) as PluginSettings; @@ -16,9 +19,13 @@ export default function Settings() { headerRight: () => ( { for (const key in storage) { - currentSettings[key] = settings[key]; + if (typeof storage[key] === "boolean" || storage[key]) { + currentSettings[key] = settings[key]; + } } - flush().then(() => initialize()).then(() => navigation.goBack()).catch(console.error); + + console.log("Applying settings..."); + flush().then(() => initialize()); }}> UPDATE @@ -29,17 +36,18 @@ export default function Settings() { return ( settings.appName = value.trim()} - title="Application Name" + title="Discord Application Name" placeholder="Music" returnKeyType="done" /> - settings.username = value.trim()} - title="Username" + title="Last.fm username" + helpText={!settings.username && {"This field is required!"}} placeholder="wumpus123" returnKeyType="done" /> @@ -47,6 +55,7 @@ export default function Settings() { } value={settings.showTimestamp} onValueChange={(value: boolean) => settings.showTimestamp = value} /> @@ -54,6 +63,7 @@ export default function Settings() { } value={settings.listeningTo} onValueChange={(value: boolean) => settings.listeningTo = value} /> @@ -61,6 +71,7 @@ export default function Settings() { } value={settings.verboseLogging} onValueChange={(value: boolean) => settings.verboseLogging = value} /> diff --git a/plugins/Last.fm/src/types.ts b/plugins/Last.fm/src/types.ts index ac88193..83cd605 100644 --- a/plugins/Last.fm/src/types.ts +++ b/plugins/Last.fm/src/types.ts @@ -12,3 +12,51 @@ type PluginSettings = { /** Whether or not to show verbose logging */ verboseLogging: boolean; }; + +// Discord activity, used for SET_ACTIVITY +type Activity = { + name: string; + type: number; + details: string; + state: string; + timestamps?: { + start: number; + end?: number; + }; + assets?: ActivityAssets; +} + +// A type of an object that is returned by the Discord API after setting the activity +type ResultActivity = { + name: string; + type: number; + details: string; + state: string; + timestamps?: { + start: number; + end?: number; + }; + assets?: ActivityAssets; + application_id: string; +}; + +// The assets of the activity +type ActivityAssets = { + large_image: string; + large_text?: string; +} | { + small_image: string; + small_text?: string; +}; + +// Last.fm track +type Track = { + name: string; + artist: string; + album: string; + albumArt: string; + url: string; + date: string; + nowPlaying: boolean; + loved: boolean; +} diff --git a/plugins/Last.fm/src/utils.ts b/plugins/Last.fm/src/utils.ts new file mode 100644 index 0000000..b865f7c --- /dev/null +++ b/plugins/Last.fm/src/utils.ts @@ -0,0 +1,205 @@ +import { patcher } from "@vendetta"; +import { FluxDispatcher } from "@vendetta/metro/common"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { showToast } from "@vendetta/ui/toasts"; +import { currentSettings, global, SET_ACTIVITY, verboseLog } from "."; +import Constants from "./constants"; + +/** Fetches the latest user's scrobble */ +async function fetchLatestScrobble(): Promise { + const params = new URLSearchParams({ + 'method': 'user.getrecenttracks', + 'user': currentSettings.username, + 'api_key': Constants.LFM_API_KEY, + 'format': 'json', + 'limit': '1', + 'extended': '1' + }).toString(); + + const info = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`).then(x => x.json()); + const lastTrack = info.recenttracks?.track?.[0] || Promise.reject(info); + return { + name: lastTrack.name, + artist: lastTrack.artist.name, + album: lastTrack.album['#text'], + albumArt: handleAlbumCover(lastTrack.image[3]['#text']), + url: lastTrack.url, + date: lastTrack.date?.['#text'] ?? 'now', + nowPlaying: Boolean(lastTrack['@attr']?.nowplaying), + loved: lastTrack.loved === '1', + } as Track; +} + +/** Currently ditches the default album covers + * @param cover The album cover given by Last.fm +*/ +function handleAlbumCover(cover: string): string { + // If the cover is a default one, return null (empty) + if (Constants.DEFAULT_COVER_HASHES.some(x => cover.includes(x))) { + return null; + } + return cover; +} + +/** Sends the activity details to Discord */ +async function sendRequest(activity: Activity): Promise { + if (global.stopped) { + console.log("--> Plugin is unloaded, aborting..."); + global.updateInterval && clearInterval(global.updateInterval); + activity = null; + } + + global.lastActivity = activity; + + return await SET_ACTIVITY.handler({ + isSocketConnected: () => true, + socket: { + id: 120, + application: { + id: Constants.APPLICATION_ID, + name: activity?.name || "RichPresence" + }, + transport: "ipc" + }, + args: { + pid: 120, + activity: activity && { ...activity } || null + } + }); +} + +/** Clears the user's activity */ +function clearActivity(): Promise { + global.lastActivity && verboseLog("--> Clearing activity..."); + return sendRequest(null); +} + +/** + * Fetches the current scrobble and sets the activity. + * This function is actively called by the updateInterval. + */ +export async function update() { + verboseLog("--> Fetching last track..."); + + if (!currentSettings.username) { + showToast("Last.fm username is not set!", getAssetIDByName("Small")); + await flush(); // Flush as we always needs reinitialization + throw new Error("Username is not set"); + } + + const lastTrack = await fetchLatestScrobble().catch(async (err) => { + verboseLog("--> An error occurred while fetching the last track, aborting..."); + await clearActivity().catch(); + throw err; + }); + + if (!lastTrack.nowPlaying) { + verboseLog("--> Last track is not currently playing, aborting..."); + return await clearActivity(); + } + + verboseLog("--> Setting activity..."); + + if (global.lastTrackUrl === lastTrack.url) { + verboseLog("--> Last track is the same as the previous one, aborting..."); + return; + } + + const activity = { + name: currentSettings.appName || "Music", + type: currentSettings.listeningTo ? 2 : 0, + details: lastTrack.name, + state: `by ${lastTrack.artist}`, + } as Activity; + + global.lastTrackUrl = lastTrack.url; + + // Set timestamps + if (currentSettings.showTimestamp) { + activity.timestamps = { + start: Date.now() / 1000 | 0 + }; + } + + if (lastTrack.albumArt) { + activity.assets = { + large_image: lastTrack.albumArt, + large_text: `on ${lastTrack.album}`, + } + } + + const response = await sendRequest(activity).catch(async (err) => { + verboseLog("--> An error occurred while setting the activity"); + await clearActivity().catch(); + throw err; + }); + + verboseLog("--> Successfully set activity!"); + verboseLog(response); + return response; +} + +/** Stops everything */ +export function flush(): Promise { + console.log("--> Flushing..."); + global.lastActivity = null; + global.updateInterval && clearInterval(global.updateInterval); + + return clearActivity(); +} + +/** Initializes the plugin */ +export async function initialize() { + console.log("--> Initializing..."); + + if (global.stopped) { + throw new Error("Plugin is already stopped!"); + } + + global.lastTrackUrl = null; + + global.updateInterval && clearInterval(global.updateInterval); + global.lastActivity && await clearActivity(); + + let tries = 0; + + update().catch((err) => { + console.error(err); + tries++; + }); + + // Periodically fetches the current scrobble and sets the activity + global.updateInterval = setInterval( + () => update() + .then(() => tries = 0) + .catch(err => { + console.error(err); + + if (++tries > 3) { + console.error("Failed to fetch/set activity 3 times, aborting..."); + clearInterval(global.updateInterval); + global.stopped = true; + } + }), + currentSettings.timeInterval + ); +} + +// Discord doesn't allow us to set the activity type to "Listening to" so we have to patch it +export function patchActivityType(): () => boolean { + if (global.activityTypeUnpatch) { + console.warn("This is weird... activity type override is already defined?"); + global.activityTypeUnpatch(); + } + + console.log("Patching activity type..."); + + const nodes = Object.values(FluxDispatcher._actionHandlers._dependencyGraph.nodes); + const { actionHandler } = nodes.find((x: any) => x.name === "LocalActivityStore") as any; + + return patcher.before("LOCAL_ACTIVITY_UPDATE", actionHandler, ([{ activity }]) => { + if (activity && activity.name === global.lastActivity.name) { + activity.type = global.lastActivity.type; + } + }); +}