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
This commit is contained in:
parent
735f8aba4c
commit
d4f81b1668
4 changed files with 947 additions and 916 deletions
1
plugins/defs.d.ts
vendored
1
plugins/defs.d.ts
vendored
|
|
@ -37,6 +37,7 @@ export type LFMSettings = {
|
|||
timeInterval: number | string;
|
||||
listeningTo: boolean;
|
||||
ignoreSpotify: boolean;
|
||||
ignoreYouTubeMusic: boolean;
|
||||
verboseLogging: boolean;
|
||||
service: ServiceType | undefined;
|
||||
// Service-specific settings
|
||||
|
|
|
|||
|
|
@ -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<ServiceType, ServiceConfig>,
|
||||
|
||||
// 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<ServiceType, ServiceConfig>,
|
||||
|
||||
// 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<number, string>,
|
||||
// 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<number, string>,
|
||||
} as const;
|
||||
|
||||
export default Constants;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue