diff --git a/plugins/Last.fm/src/constants.ts b/plugins/Last.fm/src/constants.ts index 199fc72..7fc897e 100644 --- a/plugins/Last.fm/src/constants.ts +++ b/plugins/Last.fm/src/constants.ts @@ -1,15 +1,32 @@ const Constants = { - DEFAULT_APP_NAME: "Music", - DEFAULT_TIME_INTERVAL: 5, - /** The application ID of the Discord's developer app */ - APPLICATION_ID: "1054951789318909972", - /** Last.fm api key */ - LFM_API_KEY: "014ffe8a614370f000d85d95ec30e1be", - /** These are the default album covers that are used by Last.fm */ - LFM_DEFAULT_COVER_HASHES: [ - "2a96cbd8b46e442fc41c2b86b821562f", - "c6f59c1e5e7240a4c0d427abd71f3dbb", - ] + DEFAULT_APP_NAME: "Music", + DEFAULT_TIME_INTERVAL: 5, + /** The application ID of the Discord's developer app */ + APPLICATION_ID: "1054951789318909972", + /** The minimum update interval in seconds to prevent rate limiting */ + MIN_UPDATE_INTERVAL: 3, + /** Maximum retry attempts for API calls */ + MAX_RETRY_ATTEMPTS: 3, + /** Retry delay in milliseconds */ + RETRY_DELAY: 5000, + /** Base URL for Last.fm API */ + LFM_API_BASE_URL: "https://ws.audioscrobbler.com/2.0", + /** These are the default album covers that are used by Last.fm */ + LFM_DEFAULT_COVER_HASHES: [ + "2a96cbd8b46e442fc41c2b86b821562f", + "c6f59c1e5e7240a4c0d427abd71f3dbb", + ], + /** Default settings */ + DEFAULT_SETTINGS: { + username: "", + apiKey: "", + appName: "Music", + timeInterval: 5, + showTimestamp: true, + listeningTo: true, + ignoreSpotify: true, + verboseLogging: false, + }, } as const; export default Constants; diff --git a/plugins/Last.fm/src/index.tsx b/plugins/Last.fm/src/index.tsx index 9ddf6dd..b92f2b0 100644 --- a/plugins/Last.fm/src/index.tsx +++ b/plugins/Last.fm/src/index.tsx @@ -1,121 +1,106 @@ import { plugin } from "@vendetta"; -import { FluxDispatcher, React } from "@vendetta/metro/common"; +import { FluxDispatcher } from "@vendetta/metro/common"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { showToast } from "@vendetta/ui/toasts"; -import { lazy, Suspense } from "react"; -import { Activity, LFMSettings } from "../../defs"; -import { flush, initialize } from "./manager"; +import { lazy } from "react"; +import { LFMSettings } from "../../defs"; +import Constants from "./constants"; +import { initialize, stop } from "./manager"; import { UserStore } from "./modules"; -import { Forms } from "@vendetta/ui/components"; -import { ScrollView } from "react-native"; -const { FormText } = Forms; +export const pluginState = { + pluginStopped: false, + lastActivity: undefined, + updateInterval: undefined, + lastTrackUrl: undefined, +} as { + pluginStopped: boolean; + lastActivity?: any; + updateInterval?: NodeJS.Timer; + lastTrackUrl?: string; +}; -// Error boundary component for settings -function SettingsErrorBoundary({ children }: { children: React.ReactNode }) { - const [hasError, setHasError] = React.useState(false); - - React.useEffect(() => { - const errorHandler = (error: any) => { - console.error("Settings component error:", error); - setHasError(true); - }; - - return () => { - setHasError(false); - }; - }, []); - - if (hasError) { - return ( - - - Failed to load Last.fm settings. Please try reloading the plugin. - - - ); - } - - return ( - - Loading Last.fm settings... - - }> - {children} - - ); +// Initialize default settings +const defaultSettings: LFMSettings = Constants.DEFAULT_SETTINGS; +for (const key in defaultSettings) { + plugin.storage[key] = + plugin.storage[key] ?? defaultSettings[key as keyof typeof defaultSettings]; } -// Lazy-loaded Settings component with error handling -const LazySettings = lazy(() => { - try { - return import("./ui/pages/Settings").catch((error) => { - console.error("Failed to load Settings component:", error); - // Return a fallback component - return { - default: () => ( - - - Error: Could not load Last.fm settings component. - {"\n"}Please check console for details. - - - ) - }; - }); - } catch (error) { - console.error("Critical error in lazy loading:", error); - // Return fallback component for critical errors - return Promise.resolve({ - default: () => ( - - - Critical error loading settings. Please restart the app. - - - ) - }); - } +export const currentSettings = new Proxy(plugin.storage, { + get(target, prop) { + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, }); -// Wrapped settings component -function WrappedSettings() { - return ( - - - - ); +const LazySettings = lazy(() => import("./ui/pages/Settings")); + +// Connection status tracking +let connectionAttempts = 0; +const MAX_CONNECTION_ATTEMPTS = 3; +const RECONNECT_DELAY = 5000; + +async function tryInitialize() { + try { + await initialize(); + connectionAttempts = 0; + } catch (error) { + console.error("[Last.fm] Initialization error:", error); + connectionAttempts++; + + if (connectionAttempts < MAX_CONNECTION_ATTEMPTS) { + showToast("Retrying connection...", getAssetIDByName("ic_clock")); + setTimeout(tryInitialize, RECONNECT_DELAY); + } else { + showToast("Failed to connect to Last.fm", getAssetIDByName("Small")); + } + } } -export const pluginState = {} as { - pluginStopped?: boolean, - lastActivity?: Activity, - updateInterval?: NodeJS.Timer, - lastTrackUrl?: string, -}; - -plugin.storage.ignoreSpotify ??= true; -export const currentSettings = { ...plugin.storage } as LFMSettings; - export default { - settings: WrappedSettings, - onLoad() { - pluginState.pluginStopped = false; + settings: LazySettings, + onLoad() { + pluginState.pluginStopped = false; - if (UserStore.getCurrentUser()) { - initialize().catch(console.error); - } else { - const callback = () => { - initialize().catch(console.error); - FluxDispatcher.unsubscribe(callback); - }; - - FluxDispatcher.subscribe("CONNECTION_OPEN", callback); - } - - }, - onUnload() { - pluginState.pluginStopped = true; - flush(); + if (!currentSettings.username || !currentSettings.apiKey) { + showToast( + "Please configure Last.fm username and API key in settings", + getAssetIDByName("ic_warning"), + ); + return; } + + if (UserStore.getCurrentUser()) { + tryInitialize(); + } else { + const waitForUser = () => { + if (UserStore.getCurrentUser()) { + tryInitialize(); + FluxDispatcher.unsubscribe("CONNECTION_OPEN", waitForUser); + } + }; + + FluxDispatcher.subscribe("CONNECTION_OPEN", waitForUser); + } + }, + onUnload() { + pluginState.pluginStopped = true; + stop(); + }, + // Handle settings updates + onSettingsUpdate(newSettings: any) { + Object.assign(currentSettings, newSettings); + tryInitialize(); + }, + // Handle Discord reconnection + onDiscordReconnect() { + if (!pluginState.pluginStopped) { + tryInitialize(); + } + }, }; diff --git a/plugins/Last.fm/src/manager.ts b/plugins/Last.fm/src/manager.ts index 7281bd9..39ba9a1 100644 --- a/plugins/Last.fm/src/manager.ts +++ b/plugins/Last.fm/src/manager.ts @@ -7,157 +7,217 @@ import Constants from "./constants"; import { SelfPresenceStore } from "./modules"; import { clearActivity, fetchAsset, sendRequest } from "./utils/activity"; import { setDebugInfo } from "./utils/debug"; -import { fetchLatestScrobble } from "./utils/lastfm"; +import { lastfmClient } from "./utils/lastfm"; enum ActivityType { - PLAYING = 0, - STREAMING = 1, - LISTENING = 2, - COMPETING = 5 + PLAYING = 0, + STREAMING = 1, + LISTENING = 2, + COMPETING = 5, } -const verboseLog = (...message: any) => currentSettings.verboseLogging && console.log(...message); +const verboseLog = (...message: any[]) => + currentSettings.verboseLogging && console.log("[Last.fm]", ...message); -/** - * Fetches the current scrobble and sets the activity. - * This function is actively called by the updateInterval. - */ -async function update() { +class PluginManager { + private static instance: PluginManager; + private updateTimer?: NodeJS.Timer; + private reconnectTimer?: NodeJS.Timer; + private consecutiveFailures: number = 0; + private isReconnecting: boolean = false; + + private constructor() {} + + public static getInstance(): PluginManager { + if (!PluginManager.instance) { + PluginManager.instance = new PluginManager(); + } + return PluginManager.instance; + } + + /** + * Updates the Discord activity with the current Last.fm track + */ + private async updateActivity() { if (pluginState.pluginStopped) { - verboseLog("--> Plugin is unloaded, aborting update()..."); - flush(); - return; + verboseLog("Plugin is stopped, skipping update"); + this.stopUpdates(); + return; } - verboseLog("--> Fetching last track..."); - - if (!currentSettings.username) { - showToast("Last.fm username is not set!", getAssetIDByName("Small")); - flush(); // Flush as we always need reinitialization - throw new Error("Username is not set"); - } - - if (currentSettings.ignoreSpotify) { - const spotifyActivity = SelfPresenceStore.findActivity(act => act.sync_id); - if (spotifyActivity) { - verboseLog("--> Spotify is currently playing, aborting..."); - setDebugInfo("isSpotifyIgnored", true); - - clearActivity(); - return; - } else setDebugInfo("isSpotifyIgnored", false); - } else { - setDebugInfo("isSpotifyIgnored", undefined); - } - - const lastTrack = await fetchLatestScrobble().catch(async (err) => { - verboseLog("--> An error occurred while fetching the last track, aborting..."); - clearActivity(); - throw err; - }); - - setDebugInfo("lastTrack", lastTrack); - - if (!lastTrack.nowPlaying) { - verboseLog("--> Last track is not currently playing, aborting..."); - clearActivity(); - return; - } - - verboseLog("--> Track fetched!"); - - if (pluginState.lastTrackUrl === lastTrack.url) { - verboseLog("--> Last track is the same as the previous one, aborting..."); - return; - } - - const activity = { - name: currentSettings.appName || Constants.DEFAULT_APP_NAME, - flags: 0, - type: currentSettings.listeningTo ? ActivityType.LISTENING : ActivityType.PLAYING, - details: lastTrack.name, - state: `${lastTrack.artist}`, - status_display_type: 1, - application_id: Constants.APPLICATION_ID, - } as Activity; - - pluginState.lastTrackUrl = lastTrack.url; - - if (activity.name.includes("{{")) { - for (const key in lastTrack) { - activity.name = activity.name.replace(`{{${key}}}`, lastTrack[key]); - } - } - - // Set timestamps - if (currentSettings.showTimestamp) { - activity.timestamps = { - start: Date.now()| 0 - }; - } - - if (lastTrack.album) { - const asset = await fetchAsset([lastTrack.albumArt]); - - activity.assets = { - large_image: asset[0], - large_text: `${lastTrack.album}` - }; - } - - verboseLog("--> Setting activity..."); - setDebugInfo("lastActivity", activity); - verboseLog(activity); + verboseLog("Fetching last track..."); try { - sendRequest(activity); - } catch (err) { - verboseLog("--> An error occurred while setting the activity"); + if (!currentSettings.username || !currentSettings.apiKey) { + throw new Error("Username or API key not set"); + } + + if (currentSettings.ignoreSpotify) { + const spotifyActivity = SelfPresenceStore.findActivity( + (act) => act.sync_id, + ); + if (spotifyActivity) { + verboseLog("Spotify is currently playing, clearing activity"); + setDebugInfo("isSpotifyIgnored", true); + clearActivity(); + return; + } + setDebugInfo("isSpotifyIgnored", false); + } + + const lastTrack = await lastfmClient.fetchLatestScrobble(); + setDebugInfo("lastTrack", lastTrack); + + if (!lastTrack.nowPlaying) { + verboseLog("Last track is not currently playing"); clearActivity(); - throw err; + return; + } + + if (pluginState.lastTrackUrl === lastTrack.url) { + verboseLog("Track hasn't changed"); + return; + } + + const activity: Activity = { + name: currentSettings.appName || Constants.DEFAULT_APP_NAME, + flags: 0, + type: currentSettings.listeningTo + ? ActivityType.LISTENING + : ActivityType.PLAYING, + details: lastTrack.name, + state: lastTrack.artist, + status_display_type: 1, + application_id: Constants.APPLICATION_ID, + }; + + // Handle dynamic app name + if (activity.name.includes("{{")) { + for (const key in lastTrack) { + activity.name = activity.name.replace( + `{{${key}}}`, + lastTrack[key as keyof typeof lastTrack] || "", + ); + } + } + + // Set timestamps if enabled + if (currentSettings.showTimestamp) { + activity.timestamps = { + start: Date.now() | 0, + }; + } + + // Handle album art + if (lastTrack.album) { + const asset = await fetchAsset([lastTrack.albumArt]); + if (asset[0]) { + activity.assets = { + large_image: asset[0], + large_text: lastTrack.album, + }; + } + } + + verboseLog("Setting activity:", activity); + setDebugInfo("lastActivity", activity); + + await sendRequest(activity); + pluginState.lastTrackUrl = lastTrack.url; + pluginState.lastActivity = activity; + this.consecutiveFailures = 0; + + verboseLog("Activity set successfully!"); + } catch (error) { + console.error("[Last.fm] Update error:", error); + this.handleError(error); } + } - verboseLog("--> Successfully set activity!"); -} + private handleError(error: Error) { + this.consecutiveFailures++; + setDebugInfo("lastError", error); -/** Stops and reset everything, can be started again with `initialize()` */ -export function flush(isClearActivity = false) { - pluginState.lastActivity = null; - pluginState.lastTrackUrl = null; + if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) { + verboseLog( + `Failed ${this.consecutiveFailures} times, initiating reconnection...`, + ); + this.startReconnection(); + } + } - pluginState.updateInterval && clearInterval(pluginState.updateInterval); - !isClearActivity && clearActivity(); -} + private startReconnection() { + if (this.isReconnecting) return; -/** Initializes the plugin */ -export async function initialize() { + this.isReconnecting = true; + this.stopUpdates(); + + verboseLog("Starting reconnection process"); + this.reconnectTimer = setInterval(() => { + verboseLog("Attempting to reconnect..."); + this.initialize() + .then(() => { + verboseLog("Reconnection successful"); + this.stopReconnection(); + }) + .catch((error) => { + verboseLog("Reconnection failed:", error); + }); + }, Constants.RETRY_DELAY); + } + + private stopReconnection() { + if (this.reconnectTimer) { + clearInterval(this.reconnectTimer); + this.reconnectTimer = undefined; + } + this.isReconnecting = false; + this.consecutiveFailures = 0; + } + + /** + * Stops all update and reconnection timers + */ + private stopUpdates() { + if (this.updateTimer) { + clearInterval(this.updateTimer); + this.updateTimer = undefined; + } + } + + /** + * Initializes the plugin and starts the update loop + */ + public async initialize() { if (pluginState.pluginStopped) { - throw new Error("Plugin is already stopped!"); + throw new Error("Plugin is stopped"); } - flush(); + this.stopUpdates(); + await this.updateActivity(); - let tries = 0; - - await update().catch((err) => { - console.error(err); - tries++; - }); - - // Periodically fetches the current scrobble and sets the activity - pluginState.updateInterval = setInterval( - () => update() - .then(() => { - tries = 0; - }) - .catch(err => { - console.error(err); - - if (++tries > 3) { - console.error("Failed to fetch/set activity 3 times, aborting..."); - flush(); - } - }), - (Number(currentSettings.timeInterval) || Constants.DEFAULT_TIME_INTERVAL) * 1000 + const interval = Math.max( + (Number(currentSettings.timeInterval) || + Constants.DEFAULT_SETTINGS.timeInterval) * 1000, + Constants.MIN_UPDATE_INTERVAL * 1000, ); + + this.updateTimer = setInterval(() => this.updateActivity(), interval); + verboseLog(`Update timer started with interval: ${interval}ms`); + } + + /** + * Stops the plugin and cleans up + */ + public stop() { + pluginState.pluginStopped = true; + this.stopUpdates(); + this.stopReconnection(); + clearActivity(); + } } + +// Export singleton instance methods +const manager = PluginManager.getInstance(); +export const initialize = () => manager.initialize(); +export const stop = () => manager.stop(); diff --git a/plugins/Last.fm/src/ui/pages/Settings.tsx b/plugins/Last.fm/src/ui/pages/Settings.tsx index bf62a28..e67ed9b 100644 --- a/plugins/Last.fm/src/ui/pages/Settings.tsx +++ b/plugins/Last.fm/src/ui/pages/Settings.tsx @@ -1,162 +1,192 @@ -import { NavigationNative, React } from "@vendetta/metro/common"; -import { storage } from "@vendetta/plugin"; +import { plugin } from "@vendetta"; +import { Forms } from "@vendetta/ui/components"; import { useProxy } from "@vendetta/storage"; import { getAssetIDByName } from "@vendetta/ui/assets"; -import { Forms } from "@vendetta/ui/components"; import { showToast } from "@vendetta/ui/toasts"; +import { React } from "@vendetta/metro/common"; +import { ScrollView } from "react-native"; -import { lazy, useEffect } from "react"; -import { ScrollView, Text, TouchableOpacity } from "react-native"; -import { currentSettings } from "../.."; -import { LFMSettings } from "../../../../defs"; +import { currentSettings, pluginState } from "../.."; import Constants from "../../constants"; import { initialize } from "../../manager"; -import { - logComponentMount, - logComponentError, - logNavigationError, - incrementSettingsLoad, -} from "../../utils/debug"; +import { lastfmClient } from "../../utils/lastfm"; -const { FormRow, FormInput, FormDivider, FormSwitchRow, FormText, FormIcon } = +const { FormInput, FormRow, FormSwitch, FormSection, FormDivider, FormText } = Forms; -function UpdateButton() { - async function onPressCallback() { - try { - incrementSettingsLoad(); - for (const key in storage) { - if (storage[key] != null) { - currentSettings[key] = storage[key]; - } - } +export default function Settings() { + useProxy(currentSettings); + const [loading, setLoading] = React.useState(false); + + const saveSettings = (key: string, value: any) => { + currentSettings[key] = value; + plugin.storage[key] = value; + }; + + const testConnection = async () => { + if (!currentSettings.username || !currentSettings.apiKey) { + showToast( + "Please enter both username and API key", + getAssetIDByName("Small"), + ); + return; + } + + setLoading(true); + try { + await lastfmClient.fetchLatestScrobble(); + showToast("Connection successful!", getAssetIDByName("Check")); + + // Reinitialize the plugin with new settings await initialize(); - showToast("Settings updated!", getAssetIDByName("Check")); } catch (error) { - logComponentError("UpdateButton", error); - showToast("Failed to update settings", getAssetIDByName("Small")); + showToast( + `Connection failed: ${error.message}`, + getAssetIDByName("Small"), + ); + } finally { + setLoading(false); } - } + }; return ( - - UPDATE - - ); -} + + + saveSettings("username", value)} + /> + + saveSettings("apiKey", value)} + secureTextEntry={true} + /> + + Get your API key at: https://www.last.fm/api/account/create + + } + trailing={loading ? : } + onPress={testConnection} + disabled={loading} + /> + -export default React.memo(function Settings() { - const settings = useProxy(storage) as LFMSettings; - const navigation = NavigationNative.useNavigation(); + + saveSettings("appName", value)} + /> + + { + const interval = Number(value); + if (interval >= Constants.MIN_UPDATE_INTERVAL) { + saveSettings("timeInterval", interval); + } else { + showToast( + `Minimum interval is ${Constants.MIN_UPDATE_INTERVAL} seconds`, + getAssetIDByName("Small"), + ); + } + }} + keyboardType="numeric" + /> + - // Log component mount - useEffect(() => { - logComponentMount("Settings"); - }, []); - - // Safety check to ensure settings is properly initialized - if (!settings) { - logComponentError("Settings", "Settings proxy is null or undefined"); - return ( - - Loading... - - ); - } - - useEffect(() => { - try { - navigation.setOptions({ - title: "Last.fm Configuration", - headerRight: () => , - }); - } catch (error) { - logComponentError("Settings-Navigation", error); - } - }, []); - - return ( - - (settings.appName = value.trim())} - title="Discord Application Name" - placeholder={Constants.DEFAULT_APP_NAME} - returnKeyType="done" - /> - - (settings.username = value.trim())} - title="Last.fm username" - helpText={ - !settings.username && ( - - {"This field is required!"} - - ) - } - placeholder="wumpus123" - returnKeyType="done" - /> - - (settings.timeInterval = value)} - title="Update interval (in seconds)" - placeholder={Constants.DEFAULT_TIME_INTERVAL.toString()} - keyboardType="numeric" - returnKeyType="done" - /> - - } - value={settings.showTimestamp} - onValueChange={(value: boolean) => (settings.showTimestamp = value)} - /> - - } - value={settings.listeningTo} - onValueChange={(value: boolean) => (settings.listeningTo = value)} - /> - - - } - value={settings.ignoreSpotify} - onValueChange={(value: boolean) => (settings.ignoreSpotify = value)} - /> - - } - trailing={FormRow.Arrow} - onPress={() => { - try { - navigation.push("VendettaCustomPage", { - title: "Debug", - render: lazy(() => import("./Debug")), - }); - } catch (error) { - logNavigationError(error); - console.error("Failed to navigate to Debug page:", error); - showToast("Failed to open debug page", getAssetIDByName("Small")); + + } + trailing={ + + saveSettings("listeningTo", value) + } + /> } - }} - /> + /> + + } + trailing={ + + saveSettings("showTimestamp", value) + } + /> + } + /> + + + } + trailing={ + + saveSettings("ignoreSpotify", value) + } + /> + } + /> + + + } + trailing={ + + saveSettings("verboseLogging", value) + } + /> + } + /> + + + + + Status: {pluginState.pluginStopped ? "Stopped" : "Running"} + {"\n"} + Last Update: {pluginState.lastTrackUrl ? "Success" : "No track data"} + + + + + Version 2.0.0 + ); -}); +} diff --git a/plugins/Last.fm/src/utils/activity.ts b/plugins/Last.fm/src/utils/activity.ts index 786bf08..96ed16d 100644 --- a/plugins/Last.fm/src/utils/activity.ts +++ b/plugins/Last.fm/src/utils/activity.ts @@ -3,34 +3,42 @@ import { FluxDispatcher } from "@vendetta/metro/common"; import { pluginState } from ".."; import { Activity } from "../../../defs"; import Constants from "../constants"; -import { flush } from "../manager"; +import { stop } from "../manager"; import { AssetManager } from "../modules"; /** Clears the user's activity */ export function clearActivity() { - return sendRequest(null); + return sendRequest(null); } /** Sends the activity details to Discord */ -export function sendRequest(activity: Activity) { - if (pluginState.pluginStopped) { - flush(true); - activity = null; - } +export function sendRequest(activity: Activity | null) { + if (pluginState.pluginStopped) { + stop(); + activity = null; + } - pluginState.lastActivity = activity; + pluginState.lastActivity = activity; - FluxDispatcher.dispatch({ - type: "LOCAL_ACTIVITY_UPDATE", - activity: activity, - pid: 2312, - socketId: "Last.fm@Vendetta" - }); + FluxDispatcher.dispatch({ + type: "LOCAL_ACTIVITY_UPDATE", + activity: activity, + pid: 2312, + socketId: "Last.fm@Vendetta", + }); } /** Fetches a Discord application's asset */ -export async function fetchAsset(asset: string[], appId: string = Constants.APPLICATION_ID): Promise { - if (!asset) return []; +export async function fetchAsset( + asset: string[], + appId: string = Constants.APPLICATION_ID, +): Promise { + if (!asset?.length) return []; + try { return await AssetManager.fetchAssetIds(appId, asset); + } catch (error) { + console.error("[Last.fm] Failed to fetch assets:", error); + return []; + } } diff --git a/plugins/Last.fm/src/utils/lastfm.ts b/plugins/Last.fm/src/utils/lastfm.ts index f46afc1..9fde87d 100644 --- a/plugins/Last.fm/src/utils/lastfm.ts +++ b/plugins/Last.fm/src/utils/lastfm.ts @@ -2,48 +2,190 @@ import { currentSettings } from ".."; import { Track } from "../../../defs"; import Constants from "../constants"; import { setDebugInfo } from "./debug"; +import { showToast } from "@vendetta/ui/toasts"; +import { getAssetIDByName } from "@vendetta/ui/assets"; -/** Fetches the latest user's scrobble */ -export 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" +interface LastFMError { + error: number; + message: string; +} + +interface LastFMResponse { + recenttracks?: { + track: LastFMTrack[]; + }; + error?: number; + message?: string; +} + +interface LastFMTrack { + name: string; + artist: { + name: string; + }; + album: { + "#text": string; + }; + image?: { + size: string; + "#text": string; + }[]; + url: string; + date?: { + "#text": string; + }; + "@attr"?: { + nowplaying: boolean; + }; + loved: string; +} + +class LastFMClient { + private static instance: LastFMClient; + private retryCount: number = 0; + private lastError: number = 0; + + private constructor() {} + + public static getInstance(): LastFMClient { + if (!LastFMClient.instance) { + LastFMClient.instance = new LastFMClient(); + } + return LastFMClient.instance; + } + + private validateSettings(): void { + if (!currentSettings.username) { + throw new Error("Last.fm username is not set"); + } + if (!currentSettings.apiKey) { + throw new Error("Last.fm API key is not set"); + } + } + + private async handleError(error: LastFMError): Promise { + this.lastError = error.error; + + const errorMessages: { [key: number]: string } = { + 2: "Invalid API key", + 6: "User not found", + 8: "Operation failed - Backend error", + 11: "Service temporarily unavailable", + 16: "Service temporarily unavailable", + 29: "Rate limit exceeded", + }; + + const message = + errorMessages[error.error] || error.message || "Unknown error"; + showToast(`Last.fm Error: ${message}`, getAssetIDByName("Small")); + + throw new Error(`Last.fm API Error ${error.error}: ${message}`); + } + + private async makeRequest( + params: Record, + ): Promise { + const queryParams = new URLSearchParams({ + ...params, + api_key: currentSettings.apiKey, + format: "json", }).toString(); - const result = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`); - if (!result.ok) throw new Error(`Failed to fetch the latest scrobble: ${result.statusText}`); + const response = await fetch( + `${Constants.LFM_API_BASE_URL}?${queryParams}`, + ); + const data: LastFMResponse = await response.json(); - const info = await result.json(); + if (!response.ok || data.error) { + await this.handleError(data as LastFMError); + } - const lastTrack = info?.recenttracks?.track?.[0]; - setDebugInfo("lastAPIResponse", lastTrack); + return data; + } - if (!lastTrack) throw info; + public async fetchLatestScrobble(): Promise { + try { + this.validateSettings(); - return { + const data = await this.makeRequest({ + method: "user.getrecenttracks", + user: currentSettings.username, + limit: "1", + extended: "1", + }); + + const lastTrack = data?.recenttracks?.track?.[0]; + setDebugInfo("lastAPIResponse", lastTrack); + + if (!lastTrack) { + throw new Error("No tracks found"); + } + + // Reset retry count on successful request + this.retryCount = 0; + + return { name: lastTrack.name, artist: lastTrack.artist.name, album: lastTrack.album["#text"], - albumArt: await handleAlbumCover(lastTrack.image?.find((x: any) => x.size === "large")?.["#text"]), + albumArt: await this.handleAlbumCover( + lastTrack.image?.find((x) => x.size === "large")?.["#text"], + ), url: lastTrack.url, date: lastTrack.date?.["#text"] ?? "now", nowPlaying: Boolean(lastTrack["@attr"]?.nowplaying), loved: lastTrack.loved === "1", - } as Track; + } as Track; + } catch (error) { + // Increment retry count and throw if we've exceeded max retries + this.retryCount++; + if (this.retryCount > Constants.MAX_RETRY_ATTEMPTS) { + this.retryCount = 0; + throw error; + } + + // Wait before retrying + await new Promise((resolve) => + setTimeout(resolve, Constants.RETRY_DELAY), + ); + return this.fetchLatestScrobble(); + } + } + + private async handleAlbumCover(cover?: string): Promise { + if (!cover) return null; + + // If the cover is a default one, return null + if ( + Constants.LFM_DEFAULT_COVER_HASHES.some((hash) => cover.includes(hash)) + ) { + return null; + } + + return cover; + } + + public getLastError(): number { + return this.lastError; + } + + public resetRetryCount(): void { + this.retryCount = 0; + } } -/** - * Currently ditches the default album covers - * @param cover The album cover given by Last.fm -*/ -export async function handleAlbumCover(cover: string): Promise { - // If the cover is a default one, return null (remove the cover) - if (Constants.LFM_DEFAULT_COVER_HASHES.some(x => cover.includes(x))) { - return null; - } - return cover; +// Export a singleton instance +export const lastfmClient = LastFMClient.getInstance(); + +/** Fetches the latest user's scrobble */ +export async function fetchLatestScrobble(): Promise { + return lastfmClient.fetchLatestScrobble(); +} + +/** + * Handles album cover processing + * @param cover The album cover given by Last.fm + */ +export async function handleAlbumCover(cover: string): Promise { + return lastfmClient.handleAlbumCover(cover); }