Last.fm: Reorganize everything

This commit is contained in:
amsyarasyiq 2023-02-12 20:31:51 +08:00
parent 92d511c7b2
commit 85ef273cde
4 changed files with 292 additions and 264 deletions

View file

@ -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<Track> {
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<ResultActivity> {
lastActivity && verboseLog("--> Clearing activity...");
return sendRequest(null);
}
// Sends the activity details to Discord
async function sendRequest(activity: Activity): Promise<ResultActivity> {
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<ResultActivity> {
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
}

View file

@ -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: () => (
<TouchableOpacity onPress={() => {
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());
}}>
<FormText style={{ marginRight: 12 }}>UPDATE</FormText>
</TouchableOpacity>
@ -29,17 +36,18 @@ export default function Settings() {
return (
<ScrollView>
<FormInput
value={settings.appName}
value={settings.appName || undefined}
onChangeText={(value: string) => settings.appName = value.trim()}
title="Application Name"
title="Discord Application Name"
placeholder="Music"
returnKeyType="done"
/>
<FormDivider />
<FormInput
value={settings.username}
<FormInput required
value={settings.username || undefined}
onChangeText={(value: string) => settings.username = value.trim()}
title="Username"
title="Last.fm username"
helpText={!settings.username && <Text style={{ color: "#FF0000" }}>{"This field is required!"}</Text>}
placeholder="wumpus123"
returnKeyType="done"
/>
@ -47,6 +55,7 @@ export default function Settings() {
<FormSwitchRow
label="Show time elapsed"
subLabel="Show the time elapsed since the song started playing"
leading={<FormIcon source={getAssetIDByName("clock")} />}
value={settings.showTimestamp}
onValueChange={(value: boolean) => settings.showTimestamp = value}
/>
@ -54,6 +63,7 @@ export default function Settings() {
<FormSwitchRow
label="Set status as listening"
subLabel='Set your status as "Listening to" instead of "Playing"'
leading={<FormIcon source={getAssetIDByName("ic_headset_neutral")} />}
value={settings.listeningTo}
onValueChange={(value: boolean) => settings.listeningTo = value}
/>
@ -61,6 +71,7 @@ export default function Settings() {
<FormSwitchRow
label="Verbose logging"
subLabel="Log more information to the console for debugging purposes"
leading={<FormIcon source={getAssetIDByName("icon-qs-files")} />}
value={settings.verboseLogging}
onValueChange={(value: boolean) => settings.verboseLogging = value}
/>

View file

@ -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;
}

View file

@ -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<Track> {
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<ResultActivity> {
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<ResultActivity> {
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<ResultActivity> {
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;
}
});
}