From d4f81b16688b8ecccdf1b3cbb2ba77c6420e462d Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Sat, 4 Oct 2025 20:37:39 +0200 Subject: [PATCH] Add option to ignore YouTube Music activity in status - Add `ignoreYouTubeMusic` to settings and defaults - Skip updating Discord status if YouTube Music is playing and option is enabled - Add toggle to settings UI for ignoring YouTube Music --- plugins/defs.d.ts | 1 + plugins/multi-scrobbler/src/constants.ts | 141 +-- plugins/multi-scrobbler/src/manager.ts | 737 ++++++------- .../multi-scrobbler/src/ui/pages/Settings.tsx | 984 +++++++++--------- 4 files changed, 947 insertions(+), 916 deletions(-) diff --git a/plugins/defs.d.ts b/plugins/defs.d.ts index 7817f8b..fe5adc2 100644 --- a/plugins/defs.d.ts +++ b/plugins/defs.d.ts @@ -37,6 +37,7 @@ export type LFMSettings = { timeInterval: number | string; listeningTo: boolean; ignoreSpotify: boolean; + ignoreYouTubeMusic: boolean; verboseLogging: boolean; service: ServiceType | undefined; // Service-specific settings diff --git a/plugins/multi-scrobbler/src/constants.ts b/plugins/multi-scrobbler/src/constants.ts index b67f6af..b50b134 100644 --- a/plugins/multi-scrobbler/src/constants.ts +++ b/plugins/multi-scrobbler/src/constants.ts @@ -1,79 +1,80 @@ import { ServiceConfig, ServiceType } from "../../defs"; const Constants = { - DEFAULT_APP_NAME: "Music", - DEFAULT_TIME_INTERVAL: 5, - // Discord application ID - APPLICATION_ID: "1054951789318909972", - // Don't check more than once every 3 seconds to avoid getting rate limited - MIN_UPDATE_INTERVAL: 3, - // How many times to retry failed API calls - MAX_RETRY_ATTEMPTS: 3, - // Wait 5 seconds between retries - RETRY_DELAY: 5000, + DEFAULT_APP_NAME: "Music", + DEFAULT_TIME_INTERVAL: 5, + // Discord application ID + APPLICATION_ID: "1054951789318909972", + // Don't check more than once every 3 seconds to avoid getting rate limited + MIN_UPDATE_INTERVAL: 3, + // How many times to retry failed API calls + MAX_RETRY_ATTEMPTS: 3, + // Wait 5 seconds between retries + RETRY_DELAY: 5000, - // Configuration for each supported service - SERVICES: { - lastfm: { - name: "Last.fm", - baseUrl: "http://ws.audioscrobbler.com/2.0", - requiresApiKey: true, - requiresToken: false, - }, - librefm: { - name: "Libre.fm", - baseUrl: "http://libre.fm/api", - requiresApiKey: true, - requiresToken: false, - }, - listenbrainz: { - name: "ListenBrainz", - baseUrl: "http://api.listenbrainz.org/1", - requiresApiKey: false, - requiresToken: true, - }, - } as Record, - - // Last.fm/Libre.fm use these hashes for their generic album covers - DEFAULT_COVER_HASHES: [ - "2a96cbd8b46e442fc41c2b86b821562f", - "c6f59c1e5e7240a4c0d427abd71f3dbb", - ], - - // Plugin defaults - DEFAULT_SETTINGS: { - username: "", - apiKey: "", - appName: "Music", - timeInterval: 5, - showTimestamp: true, - listeningTo: true, - ignoreSpotify: true, - verboseLogging: false, - service: undefined as ServiceType | undefined, - librefmUsername: "", - librefmApiKey: "", - listenbrainzUsername: "", - listenbrainzToken: "", + // Configuration for each supported service + SERVICES: { + lastfm: { + name: "Last.fm", + baseUrl: "http://ws.audioscrobbler.com/2.0", + requiresApiKey: true, + requiresToken: false, }, + librefm: { + name: "Libre.fm", + baseUrl: "http://libre.fm/api", + requiresApiKey: true, + requiresToken: false, + }, + listenbrainz: { + name: "ListenBrainz", + baseUrl: "http://api.listenbrainz.org/1", + requiresApiKey: false, + requiresToken: true, + }, + } as Record, - // Last.fm/Libre.fm API error codes - API_ERROR_CODES: { - 2: "Invalid service", - 3: "Invalid method", - 4: "Invalid format", - 5: "Invalid parameters", - 6: "Invalid resource specified", - 7: "Invalid session key", - 8: "Invalid API key", - 9: "Invalid session", - 10: "Invalid API signature", - 11: "Service offline", - 13: "Invalid method signature supplied", - 16: "Service temporarily unavailable", - 26: "Suspended API key", - 29: "Rate limit exceeded", - } as Record, + // Last.fm/Libre.fm use these hashes for their generic album covers + DEFAULT_COVER_HASHES: [ + "2a96cbd8b46e442fc41c2b86b821562f", + "c6f59c1e5e7240a4c0d427abd71f3dbb", + ], + + // Plugin defaults + DEFAULT_SETTINGS: { + username: "", + apiKey: "", + appName: "Music", + timeInterval: 5, + showTimestamp: true, + listeningTo: true, + ignoreSpotify: true, + ignoreYouTubeMusic: true, + verboseLogging: false, + service: undefined as ServiceType | undefined, + librefmUsername: "", + librefmApiKey: "", + listenbrainzUsername: "", + listenbrainzToken: "", + }, + + // Last.fm/Libre.fm API error codes + API_ERROR_CODES: { + 2: "Invalid service", + 3: "Invalid method", + 4: "Invalid format", + 5: "Invalid parameters", + 6: "Invalid resource specified", + 7: "Invalid session key", + 8: "Invalid API key", + 9: "Invalid session", + 10: "Invalid API signature", + 11: "Service offline", + 13: "Invalid method signature supplied", + 16: "Service temporarily unavailable", + 26: "Suspended API key", + 29: "Rate limit exceeded", + } as Record, } as const; export default Constants; diff --git a/plugins/multi-scrobbler/src/manager.ts b/plugins/multi-scrobbler/src/manager.ts index 6d27700..57a0130 100644 --- a/plugins/multi-scrobbler/src/manager.ts +++ b/plugins/multi-scrobbler/src/manager.ts @@ -4,16 +4,16 @@ import Constants from "./constants"; import { SelfPresenceStore } from "./modules"; import { clearActivity, fetchAsset, sendRequest } from "./utils/activity"; import { - setDebugInfo, - recordSuccessfulUpdate, - recordServiceError, - incrementApiCall, + setDebugInfo, + recordSuccessfulUpdate, + recordServiceError, + incrementApiCall, } from "./utils/debug"; import { serviceFactory } from "./services/ServiceFactory"; import { - formatDuration, - validateTimestamps, - getCurrentTimestamp, + formatDuration, + validateTimestamps, + getCurrentTimestamp, } from "./utils/time"; enum ActivityType { @@ -25,382 +25,399 @@ enum ActivityType { const log = (...message: any[]) => console.log("[Scrobble Plugin]", ...message); const logError = (...message: any[]) => - console.error("[Scrobble Plugin] Error:", ...message); + console.error("[Scrobble Plugin] Error:", ...message); const logVerbose = (...message: any[]) => - currentSettings.verboseLogging && + currentSettings.verboseLogging && console.log("[Scrobble Plugin] Verbose:", ...message); class PluginManager { - private static instance: PluginManager; - private updateTimer?: any; - private reconnectTimer?: any; - private consecutiveFailures = 0; - private isReconnecting = false; - private currentActivity?: Activity; - private lastUpdateTime = 0; + private static instance: PluginManager; + private updateTimer?: any; + private reconnectTimer?: any; + private consecutiveFailures = 0; + private isReconnecting = false; + private currentActivity?: Activity; + private lastUpdateTime = 0; - private constructor() { + private constructor() { // singleton pattern + } + + public static getInstance(): PluginManager { + if (!PluginManager.instance) { + PluginManager.instance = new PluginManager(); + } + return PluginManager.instance; + } + + // Check for new tracks and update Discord status when something changes + private async updateActivity() { + if (pluginState.pluginStopped) { + logVerbose("Plugin is stopped, skipping update"); + this.stopUpdates(); + return; } - public static getInstance(): PluginManager { - if (!PluginManager.instance) { - PluginManager.instance = new PluginManager(); + const serviceName = serviceFactory.getCurrentService().getServiceName(); + logVerbose(`Fetching latest track from ${serviceName}...`); + + // keep track of whether we actually need to update Discord + let willUpdateRPC = false; + + try { + // skip if Spotify is playing and user wants to ignore it + if (currentSettings.ignoreSpotify) { + const spotifyActivity = SelfPresenceStore.findActivity( + (act) => act.sync_id, + ); + if (spotifyActivity) { + logVerbose("Spotify is currently playing, clearing activity"); + setDebugInfo("isSpotifyIgnored", true); + clearActivity(); + return; } - return PluginManager.instance; - } + setDebugInfo("isSpotifyIgnored", false); + } - // Check for new tracks and update Discord status when something changes - private async updateActivity() { - if (pluginState.pluginStopped) { - logVerbose("Plugin is stopped, skipping update"); - this.stopUpdates(); - return; + // skip if YouTube Music is playing and user wants to ignore it + if (currentSettings.ignoreYouTubeMusic) { + const youtubeActivity = SelfPresenceStore.findActivity( + (act) => + act.name === "YouTube Music" || + act.application_id === "463097721130188830" || + (act.name && act.name.toLowerCase().includes("youtube music")), + ); + if (youtubeActivity) { + logVerbose("YouTube Music is currently playing, clearing activity"); + setDebugInfo("isYouTubeMusicIgnored", true); + clearActivity(); + return; } + setDebugInfo("isYouTubeMusicIgnored", false); + } - const serviceName = serviceFactory.getCurrentService().getServiceName(); - logVerbose(`Fetching latest track from ${serviceName}...`); + incrementApiCall(); + const lastTrack = await serviceFactory + .getCurrentService() + .fetchLatestScrobble(); + setDebugInfo("lastTrack", lastTrack); - // keep track of whether we actually need to update Discord - let willUpdateRPC = false; + if (!lastTrack.nowPlaying) { + logVerbose("Track is not currently playing"); + clearActivity(); + return; + } - try { - // skip if Spotify is playing and user wants to ignore it - if (currentSettings.ignoreSpotify) { - const spotifyActivity = SelfPresenceStore.findActivity( - (act) => act.sync_id, - ); - if (spotifyActivity) { - logVerbose("Spotify is currently playing, clearing activity"); - setDebugInfo("isSpotifyIgnored", true); - clearActivity(); - return; - } - setDebugInfo("isSpotifyIgnored", false); - } + // use simple URL comparison like Last.fm plugin + if (pluginState.lastTrackUrl === lastTrack.url) { + logVerbose("Track hasn't changed"); + recordSuccessfulUpdate(); + this.consecutiveFailures = 0; + return; + } - incrementApiCall(); - const lastTrack = await serviceFactory - .getCurrentService() - .fetchLatestScrobble(); - setDebugInfo("lastTrack", lastTrack); + willUpdateRPC = true; - if (!lastTrack.nowPlaying) { - logVerbose("Track is not currently playing"); - clearActivity(); - return; - } + log(`🎵 Track changed: ${lastTrack.artist} - ${lastTrack.name}`); - // use simple URL comparison like Last.fm plugin - if (pluginState.lastTrackUrl === lastTrack.url) { - logVerbose("Track hasn't changed"); - recordSuccessfulUpdate(); - this.consecutiveFailures = 0; - return; - } - - willUpdateRPC = true; - - log(`🎵 Track changed: ${lastTrack.artist} - ${lastTrack.name}`); - - // set up timestamps for the track - let activityTimestamps; - if ( - lastTrack.nowPlaying && + // set up timestamps for the track + let activityTimestamps; + if ( + lastTrack.nowPlaying && currentSettings.showTimestamp && lastTrack.from - ) { - // figure out when this track actually started - const now = getCurrentTimestamp(); - let startTime = lastTrack.from; + ) { + // figure out when this track actually started + const now = getCurrentTimestamp(); + let startTime = lastTrack.from; - // if the timestamp is way old, estimate when it started - if (startTime < now - 3600) { - // more than an hour old - probably wrong - if (lastTrack.duration && lastTrack.duration > 0) { - // guess we're about 10% in or 30 seconds, whatever's smaller - const estimatedElapsed = Math.min(lastTrack.duration * 0.1, 30); - startTime = now - estimatedElapsed; - } else { - startTime = now; - } - logVerbose("had to estimate start time"); - } - - activityTimestamps = { - start: startTime * 1000, // Discord wants milliseconds - }; - - if (lastTrack.to) { - activityTimestamps.end = lastTrack.to * 1000; - } - } - - logVerbose( - `🎯 Preparing RPC update for: ${lastTrack.artist} - ${lastTrack.name}`, - ); - - const activity: Activity = { - name: currentSettings.appName || Constants.DEFAULT_APP_NAME, - flags: 0, - type: currentSettings.listeningTo - ? ActivityType.LISTENING - : ActivityType.PLAYING, - details: lastTrack.name, - state: `by ${lastTrack.artist}`, - status_display_type: 1, - application_id: Constants.APPLICATION_ID, - }; - - // replace template variables in app name if user is using them - if (activity.name.includes("{{")) { - const variables = { - artist: lastTrack.artist, - name: lastTrack.name, - album: lastTrack.album, - service: serviceName, - }; - - for (const [key, value] of Object.entries(variables)) { - activity.name = activity.name.replace( - new RegExp(`{{${key}}}`, "g"), - value || "", - ); - } - } - - // add the timestamps we figured out - if (activityTimestamps) { - activity.timestamps = activityTimestamps; - - logVerbose("Timestamps set:", { - start: new Date(activityTimestamps.start).toISOString(), - end: activityTimestamps.end - ? new Date(activityTimestamps.end).toISOString() - : "none", - duration: lastTrack.duration - ? formatDuration(lastTrack.duration) - : "unknown", - }); - } - - // set up album art and tooltip text - if (lastTrack.album || lastTrack.albumArt) { - const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : []; - const assets = await fetchAsset(assetUrls); - - if (assets[0]) { - const largeText = lastTrack.album - ? `on ${lastTrack.album}` - : `${lastTrack.artist} - ${lastTrack.name}`; - - // include track length in the tooltip if we have it - const durationText = - currentSettings.showTimestamp && lastTrack.duration - ? ` • ${formatDuration(lastTrack.duration)}` - : ""; - - activity.assets = { - large_image: assets[0], - large_text: largeText + durationText, - }; - - logVerbose("Album art set:", assets[0]); - } else if (lastTrack.album) { - // no album art but we can still show the album name - activity.assets = { - large_text: `on ${lastTrack.album}`, - }; - } - } - - logVerbose("Setting Discord activity:", activity); - setDebugInfo("lastActivity", activity); - - await sendRequest(activity); - pluginState.lastTrackUrl = lastTrack.url; - this.currentActivity = activity; - pluginState.lastActivity = activity; - this.consecutiveFailures = 0; - this.lastUpdateTime = getCurrentTimestamp(); - recordSuccessfulUpdate(); - - log( - `✅ RPC updated successfully: ${lastTrack.artist} - ${lastTrack.name}`, - ); - } catch (error) { - logError("Update failed:", error); - recordServiceError(currentSettings.service, (error as Error).message); - this.handleError(error as Error); - } - } - - private handleError(error: Error) { - this.consecutiveFailures++; - setDebugInfo("lastError", error); - - logError( - `Failure ${this.consecutiveFailures}/${Constants.MAX_RETRY_ATTEMPTS}:`, - error.message, - ); - - if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) { - logError("Max retry attempts reached, initiating reconnection..."); - this.startReconnection(); - } - } - - private startReconnection() { - if (this.isReconnecting) return; - - this.isReconnecting = true; - this.stopUpdates(); - - log("Starting reconnection process..."); - this.reconnectTimer = setInterval(() => { - log("Attempting to reconnect..."); - this.initialize() // don't reset state - .then(() => { - log("Reconnection successful!"); - this.stopReconnection(); - }) - .catch((error) => { - logError("Reconnection attempt failed:", error.message); - }); - }, Constants.RETRY_DELAY); - } - - private stopReconnection() { - try { - if (this.reconnectTimer) { - clearInterval(this.reconnectTimer); - this.reconnectTimer = undefined; - } - this.isReconnecting = false; - this.consecutiveFailures = 0; - } catch (error) { - // Silently handle reconnection cleanup errors - console.error("[Scrobble Plugin] Reconnection cleanup error:", error); - } - } - - // clean up all timers - private stopUpdates() { - try { - if (this.updateTimer) { - clearInterval(this.updateTimer); - this.updateTimer = undefined; - } - } catch (error) { - // Silently handle timer cleanup errors - console.error("[Scrobble Plugin] Timer cleanup error:", error); - } - } - - // start everything up - public async initialize() { - if (pluginState.pluginStopped) { - throw new Error("Plugin is stopped"); + // if the timestamp is way old, estimate when it started + if (startTime < now - 3600) { + // more than an hour old - probably wrong + if (lastTrack.duration && lastTrack.duration > 0) { + // guess we're about 10% in or 30 seconds, whatever's smaller + const estimatedElapsed = Math.min(lastTrack.duration * 0.1, 30); + startTime = now - estimatedElapsed; + } else { + startTime = now; + } + logVerbose("had to estimate start time"); } - const serviceName = serviceFactory.getCurrentService().getServiceName(); - log(`Initializing with ${serviceName}...`); - - // make sure credentials work before starting - try { - const isValid = await serviceFactory.validateCurrentService(); - if (!isValid) { - throw new Error(`Invalid credentials for ${serviceName}`); - } - log(`${serviceName} credentials validated successfully`); - } catch (error) { - logError(`Failed to validate ${serviceName} credentials:`, error); - throw error; - } - - this.stopUpdates(); - - // check right away in case something is already playing - log("🎵 checking for already playing songs..."); - // Don't reset track state - let updateActivity() handle whether to update - await this.updateActivity(); - - const interval = Math.max( - (Number(currentSettings.timeInterval) || - Constants.DEFAULT_SETTINGS.timeInterval) * 1000, - Constants.MIN_UPDATE_INTERVAL * 1000, - ); - - this.updateTimer = setInterval(() => this.updateActivity(), interval); - log( - `Update timer started with interval: ${interval}ms (${interval / 1000}s)`, - ); - } - - // stop everything and clean up - public stop() { - if (pluginState.pluginStopped) { - return; // already stopped - } - - log("Stopping plugin..."); - pluginState.pluginStopped = true; - - try { - this.stopUpdates(); - this.stopReconnection(); - clearActivity(); - // Don't reset track state on stop - only when actually switching services - log("Plugin stopped successfully"); - } catch (error) { - // don't let cleanup errors break things - console.error("[Scrobble Plugin] Stop error:", error); - } - } - - // change to a different scrobble service - public async switchService(newService: string) { - if (pluginState.pluginStopped) { - return; - } - - log(`Switching to ${newService}...`); - - // stop what we're doing first - const wasRunning = !pluginState.pluginStopped; - this.stop(); - - try { - // clear cache so we get fresh service instances - serviceFactory.clearCache(); - - // Reset track state when switching services - pluginState.lastTrackUrl = undefined; - this.currentActivity = undefined; - this.lastUpdateTime = 0; - - // start back up with the new service - if (wasRunning) { - pluginState.pluginStopped = false; - await this.initialize(); - } - } catch (error) { - logError("Failed to switch service:", error); - } - } - - // get info about what's currently happening - public getStatus() { - const serviceName = serviceFactory.getCurrentService().getServiceName(); - return { - running: !pluginState.pluginStopped, - service: serviceName, - consecutiveFailures: this.consecutiveFailures, - isReconnecting: this.isReconnecting, - lastTrackUrl: pluginState.lastTrackUrl, - updateInterval: this.updateTimer ? "Active" : "Inactive", + activityTimestamps = { + start: startTime * 1000, // Discord wants milliseconds }; + + if (lastTrack.to) { + activityTimestamps.end = lastTrack.to * 1000; + } + } + + logVerbose( + `🎯 Preparing RPC update for: ${lastTrack.artist} - ${lastTrack.name}`, + ); + + const activity: Activity = { + name: currentSettings.appName || Constants.DEFAULT_APP_NAME, + flags: 0, + type: currentSettings.listeningTo + ? ActivityType.LISTENING + : ActivityType.PLAYING, + details: lastTrack.name, + state: `by ${lastTrack.artist}`, + status_display_type: 1, + application_id: Constants.APPLICATION_ID, + }; + + // replace template variables in app name if user is using them + if (activity.name.includes("{{")) { + const variables = { + artist: lastTrack.artist, + name: lastTrack.name, + album: lastTrack.album, + service: serviceName, + }; + + for (const [key, value] of Object.entries(variables)) { + activity.name = activity.name.replace( + new RegExp(`{{${key}}}`, "g"), + value || "", + ); + } + } + + // add the timestamps we figured out + if (activityTimestamps) { + activity.timestamps = activityTimestamps; + + logVerbose("Timestamps set:", { + start: new Date(activityTimestamps.start).toISOString(), + end: activityTimestamps.end + ? new Date(activityTimestamps.end).toISOString() + : "none", + duration: lastTrack.duration + ? formatDuration(lastTrack.duration) + : "unknown", + }); + } + + // set up album art and tooltip text + if (lastTrack.album || lastTrack.albumArt) { + const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : []; + const assets = await fetchAsset(assetUrls); + + if (assets[0]) { + const largeText = lastTrack.album + ? `on ${lastTrack.album}` + : `${lastTrack.artist} - ${lastTrack.name}`; + + // include track length in the tooltip if we have it + const durationText = + currentSettings.showTimestamp && lastTrack.duration + ? ` • ${formatDuration(lastTrack.duration)}` + : ""; + + activity.assets = { + large_image: assets[0], + large_text: largeText + durationText, + }; + + logVerbose("Album art set:", assets[0]); + } else if (lastTrack.album) { + // no album art but we can still show the album name + activity.assets = { + large_text: `on ${lastTrack.album}`, + }; + } + } + + logVerbose("Setting Discord activity:", activity); + setDebugInfo("lastActivity", activity); + + await sendRequest(activity); + pluginState.lastTrackUrl = lastTrack.url; + this.currentActivity = activity; + pluginState.lastActivity = activity; + this.consecutiveFailures = 0; + this.lastUpdateTime = getCurrentTimestamp(); + recordSuccessfulUpdate(); + + log( + `✅ RPC updated successfully: ${lastTrack.artist} - ${lastTrack.name}`, + ); + } catch (error) { + logError("Update failed:", error); + recordServiceError(currentSettings.service, (error as Error).message); + this.handleError(error as Error); } + } + + private handleError(error: Error) { + this.consecutiveFailures++; + setDebugInfo("lastError", error); + + logError( + `Failure ${this.consecutiveFailures}/${Constants.MAX_RETRY_ATTEMPTS}:`, + error.message, + ); + + if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) { + logError("Max retry attempts reached, initiating reconnection..."); + this.startReconnection(); + } + } + + private startReconnection() { + if (this.isReconnecting) return; + + this.isReconnecting = true; + this.stopUpdates(); + + log("Starting reconnection process..."); + this.reconnectTimer = setInterval(() => { + log("Attempting to reconnect..."); + this.initialize() // don't reset state + .then(() => { + log("Reconnection successful!"); + this.stopReconnection(); + }) + .catch((error) => { + logError("Reconnection attempt failed:", error.message); + }); + }, Constants.RETRY_DELAY); + } + + private stopReconnection() { + try { + if (this.reconnectTimer) { + clearInterval(this.reconnectTimer); + this.reconnectTimer = undefined; + } + this.isReconnecting = false; + this.consecutiveFailures = 0; + } catch (error) { + // Silently handle reconnection cleanup errors + console.error("[Scrobble Plugin] Reconnection cleanup error:", error); + } + } + + // clean up all timers + private stopUpdates() { + try { + if (this.updateTimer) { + clearInterval(this.updateTimer); + this.updateTimer = undefined; + } + } catch (error) { + // Silently handle timer cleanup errors + console.error("[Scrobble Plugin] Timer cleanup error:", error); + } + } + + // start everything up + public async initialize() { + if (pluginState.pluginStopped) { + throw new Error("Plugin is stopped"); + } + + const serviceName = serviceFactory.getCurrentService().getServiceName(); + log(`Initializing with ${serviceName}...`); + + // make sure credentials work before starting + try { + const isValid = await serviceFactory.validateCurrentService(); + if (!isValid) { + throw new Error(`Invalid credentials for ${serviceName}`); + } + log(`${serviceName} credentials validated successfully`); + } catch (error) { + logError(`Failed to validate ${serviceName} credentials:`, error); + throw error; + } + + this.stopUpdates(); + + // check right away in case something is already playing + log("🎵 checking for already playing songs..."); + // Don't reset track state - let updateActivity() handle whether to update + await this.updateActivity(); + + const interval = Math.max( + (Number(currentSettings.timeInterval) || + Constants.DEFAULT_SETTINGS.timeInterval) * 1000, + Constants.MIN_UPDATE_INTERVAL * 1000, + ); + + this.updateTimer = setInterval(() => this.updateActivity(), interval); + log( + `Update timer started with interval: ${interval}ms (${interval / 1000}s)`, + ); + } + + // stop everything and clean up + public stop() { + if (pluginState.pluginStopped) { + return; // already stopped + } + + log("Stopping plugin..."); + pluginState.pluginStopped = true; + + try { + this.stopUpdates(); + this.stopReconnection(); + clearActivity(); + // Don't reset track state on stop - only when actually switching services + log("Plugin stopped successfully"); + } catch (error) { + // don't let cleanup errors break things + console.error("[Scrobble Plugin] Stop error:", error); + } + } + + // change to a different scrobble service + public async switchService(newService: string) { + if (pluginState.pluginStopped) { + return; + } + + log(`Switching to ${newService}...`); + + // stop what we're doing first + const wasRunning = !pluginState.pluginStopped; + this.stop(); + + try { + // clear cache so we get fresh service instances + serviceFactory.clearCache(); + + // Reset track state when switching services + pluginState.lastTrackUrl = undefined; + this.currentActivity = undefined; + this.lastUpdateTime = 0; + + // start back up with the new service + if (wasRunning) { + pluginState.pluginStopped = false; + await this.initialize(); + } + } catch (error) { + logError("Failed to switch service:", error); + } + } + + // get info about what's currently happening + public getStatus() { + const serviceName = serviceFactory.getCurrentService().getServiceName(); + return { + running: !pluginState.pluginStopped, + service: serviceName, + consecutiveFailures: this.consecutiveFailures, + isReconnecting: this.isReconnecting, + lastTrackUrl: pluginState.lastTrackUrl, + updateInterval: this.updateTimer ? "Active" : "Inactive", + }; + } } // expose the manager functions @@ -408,5 +425,5 @@ const manager = PluginManager.getInstance(); export const initialize = () => manager.initialize(); export const stop = () => manager.stop(); export const switchService = (service: string) => - manager.switchService(service); + manager.switchService(service); export const getStatus = () => manager.getStatus(); diff --git a/plugins/multi-scrobbler/src/ui/pages/Settings.tsx b/plugins/multi-scrobbler/src/ui/pages/Settings.tsx index e1cf815..e485a8f 100644 --- a/plugins/multi-scrobbler/src/ui/pages/Settings.tsx +++ b/plugins/multi-scrobbler/src/ui/pages/Settings.tsx @@ -9,10 +9,10 @@ import { getAssetIDByName } from "@vendetta/ui/assets"; const { ScrollView } = findByProps("ScrollView"); const { TableRowGroup, TableSwitchRow, Stack, TableRow } = findByProps( - "TableSwitchRow", - "TableRowGroup", - "Stack", - "TableRow", + "TableSwitchRow", + "TableRowGroup", + "Stack", + "TableRow", ); const { TextInput } = findByProps("TextInput"); @@ -40,526 +40,538 @@ const set = (k: string, v: any) => (plugin.storage[k] = v); // Last.fm Settings Page function LastFmSettingsPage() { - useProxy(plugin.storage); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - const testConnection = async () => { - showToast("Testing Last.fm connection...", getAssetIDByName("ClockIcon")); - try { - const isValid = await serviceFactory.testService("lastfm"); - if (isValid) { + const testConnection = async () => { + showToast("Testing Last.fm connection...", getAssetIDByName("ClockIcon")); + try { + const isValid = await serviceFactory.testService("lastfm"); + if (isValid) { + showToast( + "✅ Last.fm connection successful!", + getAssetIDByName("CheckIcon"), + ); + } else { + showToast("❌ Last.fm connection failed", getAssetIDByName("XIcon")); + } + } catch (error) { + showToast("❌ Connection error", getAssetIDByName("XIcon")); + } + }; + + return ( + + + + + { + set("username", v); + forceUpdate(); + }} + isClearable + /> + { + set("apiKey", v); + forceUpdate(); + }} + secureTextEntry={true} + isClearable + /> + + + + + } + onPress={testConnection} + /> + } + onPress={async () => { + try { + await Linking.openURL("https://www.last.fm/api/account/create"); + } catch (error) { + console.error("Failed to open Last.fm API URL:", error); showToast( - "✅ Last.fm connection successful!", - getAssetIDByName("CheckIcon"), + "Failed to open web browser. Please visit: https://www.last.fm/api/account/create", + getAssetIDByName("XIcon"), ); - } else { - showToast("❌ Last.fm connection failed", getAssetIDByName("XIcon")); - } - } catch (error) { - showToast("❌ Connection error", getAssetIDByName("XIcon")); - } - }; - - return ( - - - - - { - set("username", v); - forceUpdate(); - }} - isClearable - /> - { - set("apiKey", v); - forceUpdate(); - }} - secureTextEntry={true} - isClearable - /> - - - - - } - onPress={testConnection} - /> - } - onPress={async () => { - try { - await Linking.openURL("https://www.last.fm/api/account/create"); - } catch (error) { - console.error("Failed to open Last.fm API URL:", error); - showToast( - "Failed to open web browser. Please visit: https://www.last.fm/api/account/create", - getAssetIDByName("XIcon"), - ); - } - }} - /> - - - - - ); + } + }} + /> + + + + + ); } // Libre.fm Settings Page function LibreFmSettingsPage() { - useProxy(plugin.storage); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - const testConnection = async () => { - showToast("Testing Libre.fm connection...", getAssetIDByName("ClockIcon")); - try { - const isValid = await serviceFactory.testService("librefm"); - if (isValid) { + const testConnection = async () => { + showToast("Testing Libre.fm connection...", getAssetIDByName("ClockIcon")); + try { + const isValid = await serviceFactory.testService("librefm"); + if (isValid) { + showToast( + "✅ Libre.fm connection successful!", + getAssetIDByName("CheckIcon"), + ); + } else { + showToast("❌ Libre.fm connection failed", getAssetIDByName("XIcon")); + } + } catch (error) { + showToast("❌ Connection error", getAssetIDByName("XIcon")); + } + }; + + return ( + + + + + { + set("librefmUsername", v); + forceUpdate(); + }} + isClearable + /> + { + set("librefmApiKey", v); + forceUpdate(); + }} + secureTextEntry={true} + isClearable + /> + + + + + } + onPress={testConnection} + /> + } + onPress={async () => { + try { + await Linking.openURL("https://www.last.fm/api/account/create"); + } catch (error) { + console.error("Failed to open Last.fm API URL:", error); showToast( - "✅ Libre.fm connection successful!", - getAssetIDByName("CheckIcon"), + "Failed to open web browser. Please visit: https://www.last.fm/api/account/create", + getAssetIDByName("XIcon"), ); - } else { - showToast("❌ Libre.fm connection failed", getAssetIDByName("XIcon")); - } - } catch (error) { - showToast("❌ Connection error", getAssetIDByName("XIcon")); - } - }; - - return ( - - - - - { - set("librefmUsername", v); - forceUpdate(); - }} - isClearable - /> - { - set("librefmApiKey", v); - forceUpdate(); - }} - secureTextEntry={true} - isClearable - /> - - - - - } - onPress={testConnection} - /> - } - onPress={async () => { - try { - await Linking.openURL("https://www.last.fm/api/account/create"); - } catch (error) { - console.error("Failed to open Last.fm API URL:", error); - showToast( - "Failed to open web browser. Please visit: https://www.last.fm/api/account/create", - getAssetIDByName("XIcon"), - ); - } - }} - /> - - - - - ); + } + }} + /> + + + + + ); } // ListenBrainz Settings Page function ListenBrainzSettingsPage() { - useProxy(plugin.storage); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - const testConnection = async () => { - showToast( - "Testing ListenBrainz connection...", - getAssetIDByName("ClockIcon"), - ); - try { - const isValid = await serviceFactory.testService("listenbrainz"); - if (isValid) { - showToast( - "✅ ListenBrainz connection successful!", - getAssetIDByName("CheckIcon"), - ); - } else { - showToast( - "❌ ListenBrainz connection failed", - getAssetIDByName("XIcon"), - ); - } - } catch (error) { - showToast("❌ Connection error", getAssetIDByName("XIcon")); - } - }; - - return ( - - - - - { - set("listenbrainzUsername", v); - forceUpdate(); - }} - isClearable - /> - { - set("listenbrainzToken", v); - forceUpdate(); - }} - secureTextEntry={true} - isClearable - /> - - - - - } - onPress={testConnection} - /> - } - onPress={async () => { - try { - await Linking.openURL("https://listenbrainz.org/settings/"); - } catch (error) { - console.error( - "Failed to open ListenBrainz settings URL:", - error, - ); - showToast( - "Failed to open web browser. Please visit: https://listenbrainz.org/settings/", - getAssetIDByName("XIcon"), - ); - } - }} - /> - - - - + const testConnection = async () => { + showToast( + "Testing ListenBrainz connection...", + getAssetIDByName("ClockIcon"), ); + try { + const isValid = await serviceFactory.testService("listenbrainz"); + if (isValid) { + showToast( + "✅ ListenBrainz connection successful!", + getAssetIDByName("CheckIcon"), + ); + } else { + showToast( + "❌ ListenBrainz connection failed", + getAssetIDByName("XIcon"), + ); + } + } catch (error) { + showToast("❌ Connection error", getAssetIDByName("XIcon")); + } + }; + + return ( + + + + + { + set("listenbrainzUsername", v); + forceUpdate(); + }} + isClearable + /> + { + set("listenbrainzToken", v); + forceUpdate(); + }} + secureTextEntry={true} + isClearable + /> + + + + + } + onPress={testConnection} + /> + } + onPress={async () => { + try { + await Linking.openURL("https://listenbrainz.org/settings/"); + } catch (error) { + console.error( + "Failed to open ListenBrainz settings URL:", + error, + ); + showToast( + "Failed to open web browser. Please visit: https://listenbrainz.org/settings/", + getAssetIDByName("XIcon"), + ); + } + }} + /> + + + + + ); } // Display Settings Page function DisplaySettingsPage() { - useProxy(plugin.storage); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - return ( - - - - - { - set("appName", v); - forceUpdate(); - }} - isClearable - /> - { - const interval = Number(v); - if (interval >= Constants.MIN_UPDATE_INTERVAL) { - set("timeInterval", interval); - forceUpdate(); - } - }} - keyboardType="numeric" - isClearable - /> - - + return ( + + + + + { + set("appName", v); + forceUpdate(); + }} + isClearable + /> + { + const interval = Number(v); + if (interval >= Constants.MIN_UPDATE_INTERVAL) { + set("timeInterval", interval); + forceUpdate(); + } + }} + keyboardType="numeric" + isClearable + /> + + - - { - set("listeningTo", value); - forceUpdate(); - }} - /> - { - set("showTimestamp", value); - forceUpdate(); - }} - /> - { - set("ignoreSpotify", value); - forceUpdate(); - }} - /> - - - - - ); + + { + set("listeningTo", value); + forceUpdate(); + }} + /> + { + set("showTimestamp", value); + forceUpdate(); + }} + /> + { + set("ignoreSpotify", value); + forceUpdate(); + }} + /> + { + set("ignoreYouTubeMusic", value); + forceUpdate(); + }} + /> + + + + + ); } // Logging Settings Page function LoggingSettingsPage() { - useProxy(plugin.storage); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - return ( - - - - { - set("verboseLogging", value); - forceUpdate(); - }} - /> - + return ( + + + + { + set("verboseLogging", value); + forceUpdate(); + }} + /> + - - - - + + + + - - - - - - - - - ); + + + + + + + + + ); } // Main Settings Page export default function Settings() { - useProxy(plugin.storage); - const navigation = NavigationNative.useNavigation(); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + useProxy(plugin.storage); + const navigation = NavigationNative.useNavigation(); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - const currentService = get("service") as ServiceType; + const currentService = get("service") as ServiceType; - const getCredentialStatus = (service: ServiceType) => { - switch (service) { - case "lastfm": { - return get("username") && get("apiKey") - ? "✅ Configured" - : "❌ Missing credentials"; + const getCredentialStatus = (service: ServiceType) => { + switch (service) { + case "lastfm": { + return get("username") && get("apiKey") + ? "✅ Configured" + : "❌ Missing credentials"; + } + case "librefm": { + return get("librefmUsername") && get("librefmApiKey") + ? "✅ Configured" + : "❌ Missing credentials"; + } + case "listenbrainz": { + return get("listenbrainzUsername") + ? "✅ Configured" + : "❌ Missing username"; + } + default: + return "❓ Unknown"; + } + }; + + return ( + + + {/* Service Selection */} + + + {(["lastfm", "librefm", "listenbrainz"] as ServiceType[]).map( + (service) => ( + { + if (value && service !== currentService) { + set("service", service); + forceUpdate(); + } + }} + /> + ), + )} + + + {/* Service Configuration */} + + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Last.fm Settings", + render: LastFmSettingsPage, + }) } - case "listenbrainz": { - return get("listenbrainzUsername") - ? "✅ Configured" - : "❌ Missing username"; + /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Libre.fm Settings", + render: LibreFmSettingsPage, + }) } - default: - return "❓ Unknown"; - } - }; + /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "ListenBrainz Settings", + render: ListenBrainzSettingsPage, + }) + } + /> + - return ( - - - {/* Service Selection */} - - - {(["lastfm", "librefm", "listenbrainz"] as ServiceType[]).map( - (service) => ( - { - if (value && service !== currentService) { - set("service", service); - forceUpdate(); - } - }} - /> - ), - )} - + {/* Plugin Configuration */} + + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Display Settings", + render: DisplaySettingsPage, + }) + } + /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Logging Settings", + render: LoggingSettingsPage, + }) + } + /> + { + set("addToSidebar", value); + forceUpdate(); + }} + /> + - {/* Service Configuration */} - - } - onPress={() => - navigation.push("VendettaCustomPage", { - title: "Last.fm Settings", - render: LastFmSettingsPage, - }) - } - /> - } - onPress={() => - navigation.push("VendettaCustomPage", { - title: "Libre.fm Settings", - render: LibreFmSettingsPage, - }) - } - /> - } - onPress={() => - navigation.push("VendettaCustomPage", { - title: "ListenBrainz Settings", - render: ListenBrainzSettingsPage, - }) - } - /> - - - {/* Plugin Configuration */} - - } - onPress={() => - navigation.push("VendettaCustomPage", { - title: "Display Settings", - render: DisplaySettingsPage, - }) - } - /> - } - onPress={() => - navigation.push("VendettaCustomPage", { - title: "Logging Settings", - render: LoggingSettingsPage, - }) - } - /> - { - set("addToSidebar", value); - forceUpdate(); - }} - /> - - - {/* About */} - - - - - - - - - ); + {/* About */} + + + + + + + + + ); }