feat(multi-scrobbler): implement 60s rate limiting for Libre.fm

Enforces a minimum update interval of 60 seconds when Libre.fm is the active service, as requested by the Libre.fm team to reduce server load.

- Added LIBREFM_MIN_UPDATE_INTERVAL to Constants.
- Updated PluginManager to enforce the new minimum for Libre.fm.
- Disabled user-configurable update interval in UI when Libre.fm is active.

Closes #4
This commit is contained in:
kmmiio99o 2026-05-24 16:21:55 +02:00
parent 84c70a3c7b
commit 6b5ff87d66
No known key found for this signature in database
GPG key ID: AC9D7747D423B9BF
9 changed files with 42 additions and 562 deletions

View file

@ -1,178 +0,0 @@
import { after } from "@api/patcher";
import { findByNameLazy, findByProps, findByPropsLazy, findByStoreName, findByTypeDisplayName } from "@metro";
import { ReactNative } from "@metro/common";
import { definePlugin } from "@plugins";
import { Contributors } from "@rain/Developers";
import React, { useEffect, useRef } from "react";
import ChatboxAvatarSettings from "./settings";
import { useChatboxAvatarSettings } from "./storage";
const Flux = findByProps("useStateFromStores");
const ChatInputActions = findByTypeDisplayName("ChatInputActions");
const ChatInputSendButton = findByTypeDisplayName("ChatInputSendButton");
let hasText = false;
let sendBtnRef: { setHasText?: (hasText: boolean) => void } | undefined;
const { Pressable, View, Animated } = ReactNative;
const avatarCollapse = new Animated.Value(0);
const Avatar = findByPropsLazy("default", "AvatarSizes", "getStatusSize")?.default;
const UserStore = findByStoreName("UserStore");
const SelectedChannelStore = findByStoreName("SelectedChannelStore");
const ChannelStore = findByStoreName("ChannelStore");
const SelfPresenceStore = findByStoreName("SelfPresenceStore");
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
const showYouAccountActionSheetByProp = findByPropsLazy("showYouAccountActionSheet");
function AvatarAction() {
const [textState, setTextState] = React.useState(false);
const self = Flux?.useStateFromStores?.([UserStore], () => UserStore?.getCurrentUser?.());
const status = Flux?.useStateFromStores?.([SelfPresenceStore], () => SelfPresenceStore?.getStatus?.());
const settings = useChatboxAvatarSettings();
const channelId = Flux?.useStateFromStores?.([SelectedChannelStore], () => SelectedChannelStore?.getCurrentlySelectedChannelId?.());
const channel = Flux?.useStateFromStores?.([ChannelStore], () => ChannelStore?.getChannel?.(channelId), [channelId]);
const animated = useRef(avatarCollapse).current;
React.useEffect(() => {
const interval = setInterval(() => {
setTextState(hasText);
}, 100);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const shouldCollapse = settings.collapseWhileTyping && textState;
Animated.parallel([
Animated.timing(animated, {
toValue: shouldCollapse ? 1 : 0,
duration: 200,
useNativeDriver: false,
}),
]).start();
}, [textState, settings.collapseWhileTyping, animated]);
if (!self) return null;
const openAccountSheet = () => {
const fn = showYouAccountActionSheetByProp?.showYouAccountActionSheet;
if (typeof fn === "function") {
try {
fn(true, true);
return;
} catch (err) {
}
}
showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId });
};
const handlePress = () => {
switch (settings.pressAction) {
case "profile":
showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId });
break;
case "server":
openAccountSheet();
break;
default:
break;
}
};
const handleLongPress = () => {
switch (settings.longPressAction) {
case "profile":
showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId });
break;
case "server":
openAccountSheet();
break;
default:
break;
}
};
return (
<Animated.View
style={{
height: 40,
width: animated.interpolate({ inputRange: [0, 1], outputRange: [40, 0] }),
marginHorizontal: animated.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }),
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
overflow: settings.collapseWhileTyping ? "hidden" : "visible",
}}
>
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
>
{Avatar && (
<Avatar
user={self}
guildId={channel?.guild_id}
status={settings.showStatusCutout ? status : undefined}
avatarDecoration={self?.avatarDecoration}
animate={!settings.collapseWhileTyping || !textState}
/>
)}
</Pressable>
</Animated.View>
);
}
const unpatches: (() => void)[] = [];
export default definePlugin({
name: "ChatboxAvatar",
description: "Adds your avatar to the chatbox.",
author: [Contributors.LampDelivery],
id: "chatboxavatar",
version: "v1.0.0",
requiresRestart: true,
settings: ChatboxAvatarSettings,
async start() {
if (!ChatInputActions?.type || !ChatInputSendButton?.type) return;
unpatches.push(
after("render", ChatInputSendButton.type, (args, ret) => {
setImmediate(() => {
setImmediate(() => {
if (args?.[1]?.current) {
sendBtnRef = args[1].current;
const origSetHasText = sendBtnRef?.setHasText;
unpatches.push(() => { if (sendBtnRef && origSetHasText) sendBtnRef.setHasText = origSetHasText; });
if (sendBtnRef) {
sendBtnRef.setHasText = (hasText_: boolean) => {
hasText = hasText_;
if (origSetHasText) return origSetHasText.call(sendBtnRef, hasText_);
};
}
}
});
});
})
);
},
async eagerStart() {
if (!ChatInputActions?.type || !ChatInputSendButton?.type) return;
unpatches.push(
after("render", ChatInputActions.type, (args, ret) => {
return React.createElement(
View,
{ style: { flexDirection: "row", alignItems: "center" } },
ret,
React.createElement(AvatarAction)
);
})
);
},
stop() {
for (const unpatch of unpatches) unpatch?.();
unpatches.length = 0;
},
});

View file

@ -1,58 +0,0 @@
import { Stack, TableRadioGroup, TableRadioRow } from "@metro/common/components";
import React from "react";
import { ChatboxAvatarSettings, useChatboxAvatarSettings } from "./storage";
export default function ChatboxAvatarSettings() {
const settings = useChatboxAvatarSettings();
const [, forceUpdate] = React.useReducer(x => ~x, 0);
const setPressAction = (value: string) => {
useChatboxAvatarSettings.getState().updateSettings?.({ pressAction: value as ChatboxAvatarSettings["pressAction"] });
forceUpdate();
};
const setLongPressAction = (value: string) => {
useChatboxAvatarSettings.getState().updateSettings?.({ longPressAction: value as ChatboxAvatarSettings["longPressAction"] });
forceUpdate();
};
return (
<Stack style={{ padding: 16 }} spacing={16}>
<TableRadioGroup
value={settings.pressAction}
onChange={setPressAction}
title="Avatar Press Action"
>
<TableRadioRow value="profile" label="Open Profile" />
<TableRadioRow value="server" label="Open Status Picker" />
</TableRadioGroup>
<TableRadioGroup
value={settings.longPressAction}
onChange={setLongPressAction}
title="Avatar Long-Press Action"
>
<TableRadioRow value="profile" label="Open Profile" />
<TableRadioRow value="server" label="Open Status Picker" />
</TableRadioGroup>
<TableRadioGroup
value={settings.showStatusCutout ? "true" : "false"}
onChange={v => {
useChatboxAvatarSettings.getState().updateSettings?.({ showStatusCutout: v === "true" });
forceUpdate();
}}
title="Status Icon"
>
<TableRadioRow value="true" label="Show" />
<TableRadioRow value="false" label="Hide" />
</TableRadioGroup>
<TableRadioGroup
value={settings.collapseWhileTyping ? "true" : "false"}
onChange={v => {
useChatboxAvatarSettings.getState().updateSettings?.({ collapseWhileTyping: v === "true" });
forceUpdate();
}}
title="Collapse While Typing"
>
<TableRadioRow value="true" label="Collapse avatar while typing" />
<TableRadioRow value="false" label="Always show avatar" />
</TableRadioGroup>
</Stack>
);
}

View file

@ -1,22 +0,0 @@
import { createPluginStore } from "@api/storage";
export interface ChatboxAvatarSettings {
replaceButton: "gallery" | "apps" | "gift";
pressAction: "profile" | "server";
longPressAction: "profile" | "server";
serverId?: string;
showStatusCutout?: boolean;
collapseWhileTyping?: boolean;
}
export const {
useStore: useChatboxAvatarSettings,
settings: chatboxAvatarSettings,
} = createPluginStore<ChatboxAvatarSettings>("chatboxavatar", {
replaceButton: "gallery",
pressAction: "profile",
longPressAction: "server",
serverId: "",
showStatusCutout: false,
collapseWhileTyping: false,
});

View file

@ -1,18 +0,0 @@
{
"name": "Chatbox Avatar",
"description": "Quickly view your profile from the chatbox.",
"authors": [
{
"name": "Cynosphere",
"id": "150745989836308480"
},
{
"name": "kmmiio99o",
"id": "879393496627306587"
}
],
"main": "src/index.tsx",
"vendetta": {
"icon": "UserSquareIcon"
}
}

View file

@ -1,165 +0,0 @@
import { after } from "@vendetta/patcher";
import { findByName, findByProps, findByStoreName, findByDisplayName } from "@vendetta/metro";
import { ReactNative } from "@vendetta/metro/common";
import { plugin } from "@vendetta";
import React, { useEffect, useRef } from "react";
import ChatboxAvatarSettings from "./settings";
const Flux = findByProps("useStateFromStores");
const ChatInputActions = findByDisplayName("ChatInputActions");
const ChatInputSendButton = findByDisplayName("ChatInputSendButton");
let hasText = false;
let sendBtnRef: { setHasText?: (hasText: boolean) => void } | undefined;
const { Pressable, View, Animated } = ReactNative;
const avatarCollapse = new Animated.Value(0);
const Avatar = findByProps("default", "AvatarSizes", "getStatusSize")?.default;
const UserStore = findByStoreName("UserStore");
const SelectedChannelStore = findByStoreName("SelectedChannelStore");
const ChannelStore = findByStoreName("ChannelStore");
const SelfPresenceStore = findByStoreName("SelfPresenceStore");
const showUserProfileActionSheet = findByName("showUserProfileActionSheet");
const showYouAccountActionSheetByProp = findByProps("showYouAccountActionSheet");
function AvatarAction() {
const [textState, setTextState] = React.useState(false);
const self = Flux?.useStateFromStores?.([UserStore], () => UserStore?.getCurrentUser?.());
const status = Flux?.useStateFromStores?.([SelfPresenceStore], () => SelfPresenceStore?.getStatus?.());
const settings = plugin.storage;
const channelId = Flux?.useStateFromStores?.([SelectedChannelStore], () => SelectedChannelStore?.getCurrentlySelectedChannelId?.());
const channel = Flux?.useStateFromStores?.([ChannelStore], () => ChannelStore?.getChannel?.(channelId), [channelId]);
const animated = useRef(avatarCollapse).current;
React.useEffect(() => {
const interval = setInterval(() => {
setTextState(hasText);
}, 100);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const shouldCollapse = settings.collapseWhileTyping && textState;
Animated.parallel([
Animated.timing(animated, {
toValue: shouldCollapse ? 1 : 0,
duration: 200,
useNativeDriver: false,
}),
]).start();
}, [textState, settings.collapseWhileTyping, animated]);
if (!self) return null;
const openAccountSheet = () => {
const fn = showYouAccountActionSheetByProp?.showYouAccountActionSheet;
if (typeof fn === "function") {
try {
fn(true, true);
return;
} catch {}
}
showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId });
};
const handlePress = () => {
switch (settings.pressAction) {
case "profile":
showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId });
break;
case "server":
openAccountSheet();
break;
default:
break;
}
};
const handleLongPress = () => {
switch (settings.longPressAction) {
case "profile":
showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId });
break;
case "server":
openAccountSheet();
break;
default:
break;
}
};
return (
<Animated.View
style={{
height: 40,
width: animated.interpolate({ inputRange: [0, 1], outputRange: [40, 0] }),
marginHorizontal: animated.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }),
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
overflow: settings.collapseWhileTyping ? "hidden" : "visible",
}}
>
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
>
{Avatar && (
<Avatar
user={self}
guildId={channel?.guild_id}
status={settings.showStatusCutout ? status : undefined}
avatarDecoration={self?.avatarDecoration}
animate={!settings.collapseWhileTyping || !textState}
/>
)}
</Pressable>
</Animated.View>
);
}
const unpatches: (() => void)[] = [];
export default {
onLoad() {
if (!ChatInputActions?.prototype?.render || !ChatInputSendButton?.prototype?.render) return;
unpatches.push(
after("render", ChatInputActions.prototype, (args, ret) => {
return React.createElement(
View,
{ style: { flexDirection: "row", alignItems: "center" } },
ret,
React.createElement(AvatarAction)
);
})
);
unpatches.push(
after("render", ChatInputSendButton.prototype, (args, ret) => {
setImmediate(() => {
setImmediate(() => {
if (args?.[1]?.current) {
sendBtnRef = args[1].current;
const origSetHasText = sendBtnRef?.setHasText;
unpatches.push(() => { if (sendBtnRef && origSetHasText) sendBtnRef.setHasText = origSetHasText; });
if (sendBtnRef) {
sendBtnRef.setHasText = (hasText_: boolean) => {
hasText = hasText_;
if (origSetHasText) return origSetHasText.call(sendBtnRef, hasText_);
};
}
}
});
});
})
);
},
onUnload() {
for (const unpatch of unpatches) unpatch?.();
unpatches.length = 0;
},
settings: ChatboxAvatarSettings,
};

View file

@ -1,101 +0,0 @@
import { plugin } from "@vendetta";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative } from "@vendetta/metro/common";
import { findByProps } from "@vendetta/metro";
const { ScrollView } = ReactNative;
const { TableRowGroup, TableCheckboxRow, Stack } = findByProps(
"TableCheckboxRow", "TableRowGroup", "Stack"
);
plugin.storage.pressAction ??= "profile";
plugin.storage.longPressAction ??= "server";
plugin.storage.showStatusCutout ??= false;
plugin.storage.collapseWhileTyping ??= false;
export default function ChatboxAvatarSettings() {
useProxy(plugin.storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
return (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
<Stack spacing={8}>
<TableRowGroup title="Avatar Press Action">
<TableCheckboxRow
label="Open Profile"
checked={plugin.storage.pressAction === "profile"}
onPress={() => {
plugin.storage.pressAction = "profile";
forceUpdate();
}}
/>
<TableCheckboxRow
label="Open Status Picker"
checked={plugin.storage.pressAction === "server"}
onPress={() => {
plugin.storage.pressAction = "server";
forceUpdate();
}}
/>
</TableRowGroup>
<TableRowGroup title="Avatar Long-Press Action">
<TableCheckboxRow
label="Open Profile"
checked={plugin.storage.longPressAction === "profile"}
onPress={() => {
plugin.storage.longPressAction = "profile";
forceUpdate();
}}
/>
<TableCheckboxRow
label="Open Status Picker"
checked={plugin.storage.longPressAction === "server"}
onPress={() => {
plugin.storage.longPressAction = "server";
forceUpdate();
}}
/>
</TableRowGroup>
<TableRowGroup title="Status Icon">
<TableCheckboxRow
label="Show"
checked={!!plugin.storage.showStatusCutout}
onPress={() => {
plugin.storage.showStatusCutout = true;
forceUpdate();
}}
/>
<TableCheckboxRow
label="Hide"
checked={!plugin.storage.showStatusCutout}
onPress={() => {
plugin.storage.showStatusCutout = false;
forceUpdate();
}}
/>
</TableRowGroup>
<TableRowGroup title="Collapse While Typing">
<TableCheckboxRow
label="Collapse avatar while typing"
checked={!!plugin.storage.collapseWhileTyping}
onPress={() => {
plugin.storage.collapseWhileTyping = true;
forceUpdate();
}}
/>
<TableCheckboxRow
label="Always show avatar"
checked={!plugin.storage.collapseWhileTyping}
onPress={() => {
plugin.storage.collapseWhileTyping = false;
forceUpdate();
}}
/>
</TableRowGroup>
</Stack>
</ScrollView>
);
}

View file

@ -7,6 +7,8 @@ const Constants = {
APPLICATION_ID: "1368513179272871956",
// Don't check more than once every 3 seconds to avoid getting rate limited
MIN_UPDATE_INTERVAL: 3,
// Libre.fm specifically requested a 1-minute minimum to reduce load
LIBREFM_MIN_UPDATE_INTERVAL: 60,
// How many times to retry failed API calls
MAX_RETRY_ATTEMPTS: 3,
// Wait 5 seconds between retries

View file

@ -381,10 +381,18 @@ class PluginManager {
// Don't reset track state - let updateActivity() handle whether to update
await this.updateActivity();
const currentService = currentSettings.service;
let minInterval: number = Constants.MIN_UPDATE_INTERVAL;
if (currentService === "librefm") {
minInterval = Constants.LIBREFM_MIN_UPDATE_INTERVAL;
log(`Libre.fm active: enforcing ${minInterval}s minimum update interval`);
}
const interval = Math.max(
(Number(currentSettings.timeInterval) ||
Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
Constants.MIN_UPDATE_INTERVAL * 1000,
Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
minInterval * 1000,
);
this.updateTimer = setInterval(() => this.updateActivity(), interval);

View file

@ -15,6 +15,9 @@ export default function DisplaySettingsPage() {
useProxy(plugin.storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const currentService = getStorage("service");
const isLibreFm = currentService === "librefm";
return (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
<Stack spacing={8}>
@ -29,24 +32,33 @@ export default function DisplaySettingsPage() {
}}
isClearable
/>
<TextInput
placeholder={`Update Interval (Default: ${Constants.DEFAULT_SETTINGS.timeInterval}s)`}
value={String(
getStorage(
"timeInterval",
Constants.DEFAULT_SETTINGS.timeInterval,
),
)}
onChange={(v: string) => {
const interval = Number(v);
if (interval >= Constants.MIN_UPDATE_INTERVAL) {
setStorage("timeInterval", interval);
forceUpdate();
}
}}
keyboardType="numeric"
isClearable
/>
{isLibreFm ? (
<TableRow
label="Update Interval Disabled"
subLabel="Libre.fm requires a fixed 60s update interval to prevent rate limiting"
disabled={true}
dimmed={true}
/>
) : (
<TextInput
placeholder={`Update Interval (Default: ${Constants.DEFAULT_SETTINGS.timeInterval}s)`}
value={String(
getStorage(
"timeInterval",
Constants.DEFAULT_SETTINGS.timeInterval,
),
)}
onChange={(v: string) => {
const interval = Number(v);
if (interval >= Constants.MIN_UPDATE_INTERVAL) {
setStorage("timeInterval", interval);
forceUpdate();
}
}}
keyboardType="numeric"
isClearable
/>
)}
</Stack>
</TableRowGroup>