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;
|
timeInterval: number | string;
|
||||||
listeningTo: boolean;
|
listeningTo: boolean;
|
||||||
ignoreSpotify: boolean;
|
ignoreSpotify: boolean;
|
||||||
|
ignoreYouTubeMusic: boolean;
|
||||||
verboseLogging: boolean;
|
verboseLogging: boolean;
|
||||||
service: ServiceType | undefined;
|
service: ServiceType | undefined;
|
||||||
// Service-specific settings
|
// Service-specific settings
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,80 @@
|
||||||
import { ServiceConfig, ServiceType } from "../../defs";
|
import { ServiceConfig, ServiceType } from "../../defs";
|
||||||
|
|
||||||
const Constants = {
|
const Constants = {
|
||||||
DEFAULT_APP_NAME: "Music",
|
DEFAULT_APP_NAME: "Music",
|
||||||
DEFAULT_TIME_INTERVAL: 5,
|
DEFAULT_TIME_INTERVAL: 5,
|
||||||
// Discord application ID
|
// Discord application ID
|
||||||
APPLICATION_ID: "1054951789318909972",
|
APPLICATION_ID: "1054951789318909972",
|
||||||
// Don't check more than once every 3 seconds to avoid getting rate limited
|
// Don't check more than once every 3 seconds to avoid getting rate limited
|
||||||
MIN_UPDATE_INTERVAL: 3,
|
MIN_UPDATE_INTERVAL: 3,
|
||||||
// How many times to retry failed API calls
|
// How many times to retry failed API calls
|
||||||
MAX_RETRY_ATTEMPTS: 3,
|
MAX_RETRY_ATTEMPTS: 3,
|
||||||
// Wait 5 seconds between retries
|
// Wait 5 seconds between retries
|
||||||
RETRY_DELAY: 5000,
|
RETRY_DELAY: 5000,
|
||||||
|
|
||||||
// Configuration for each supported service
|
// Configuration for each supported service
|
||||||
SERVICES: {
|
SERVICES: {
|
||||||
lastfm: {
|
lastfm: {
|
||||||
name: "Last.fm",
|
name: "Last.fm",
|
||||||
baseUrl: "http://ws.audioscrobbler.com/2.0",
|
baseUrl: "http://ws.audioscrobbler.com/2.0",
|
||||||
requiresApiKey: true,
|
requiresApiKey: true,
|
||||||
requiresToken: false,
|
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: "",
|
|
||||||
},
|
},
|
||||||
|
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
|
// Last.fm/Libre.fm use these hashes for their generic album covers
|
||||||
API_ERROR_CODES: {
|
DEFAULT_COVER_HASHES: [
|
||||||
2: "Invalid service",
|
"2a96cbd8b46e442fc41c2b86b821562f",
|
||||||
3: "Invalid method",
|
"c6f59c1e5e7240a4c0d427abd71f3dbb",
|
||||||
4: "Invalid format",
|
],
|
||||||
5: "Invalid parameters",
|
|
||||||
6: "Invalid resource specified",
|
// Plugin defaults
|
||||||
7: "Invalid session key",
|
DEFAULT_SETTINGS: {
|
||||||
8: "Invalid API key",
|
username: "",
|
||||||
9: "Invalid session",
|
apiKey: "",
|
||||||
10: "Invalid API signature",
|
appName: "Music",
|
||||||
11: "Service offline",
|
timeInterval: 5,
|
||||||
13: "Invalid method signature supplied",
|
showTimestamp: true,
|
||||||
16: "Service temporarily unavailable",
|
listeningTo: true,
|
||||||
26: "Suspended API key",
|
ignoreSpotify: true,
|
||||||
29: "Rate limit exceeded",
|
ignoreYouTubeMusic: true,
|
||||||
} as Record<number, string>,
|
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;
|
} as const;
|
||||||
|
|
||||||
export default Constants;
|
export default Constants;
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ import Constants from "./constants";
|
||||||
import { SelfPresenceStore } from "./modules";
|
import { SelfPresenceStore } from "./modules";
|
||||||
import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
|
import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
|
||||||
import {
|
import {
|
||||||
setDebugInfo,
|
setDebugInfo,
|
||||||
recordSuccessfulUpdate,
|
recordSuccessfulUpdate,
|
||||||
recordServiceError,
|
recordServiceError,
|
||||||
incrementApiCall,
|
incrementApiCall,
|
||||||
} from "./utils/debug";
|
} from "./utils/debug";
|
||||||
import { serviceFactory } from "./services/ServiceFactory";
|
import { serviceFactory } from "./services/ServiceFactory";
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
validateTimestamps,
|
validateTimestamps,
|
||||||
getCurrentTimestamp,
|
getCurrentTimestamp,
|
||||||
} from "./utils/time";
|
} from "./utils/time";
|
||||||
|
|
||||||
enum ActivityType {
|
enum ActivityType {
|
||||||
|
|
@ -25,382 +25,399 @@ enum ActivityType {
|
||||||
|
|
||||||
const log = (...message: any[]) => console.log("[Scrobble Plugin]", ...message);
|
const log = (...message: any[]) => console.log("[Scrobble Plugin]", ...message);
|
||||||
const logError = (...message: any[]) =>
|
const logError = (...message: any[]) =>
|
||||||
console.error("[Scrobble Plugin] Error:", ...message);
|
console.error("[Scrobble Plugin] Error:", ...message);
|
||||||
const logVerbose = (...message: any[]) =>
|
const logVerbose = (...message: any[]) =>
|
||||||
currentSettings.verboseLogging &&
|
currentSettings.verboseLogging &&
|
||||||
console.log("[Scrobble Plugin] Verbose:", ...message);
|
console.log("[Scrobble Plugin] Verbose:", ...message);
|
||||||
|
|
||||||
class PluginManager {
|
class PluginManager {
|
||||||
private static instance: PluginManager;
|
private static instance: PluginManager;
|
||||||
private updateTimer?: any;
|
private updateTimer?: any;
|
||||||
private reconnectTimer?: any;
|
private reconnectTimer?: any;
|
||||||
private consecutiveFailures = 0;
|
private consecutiveFailures = 0;
|
||||||
private isReconnecting = false;
|
private isReconnecting = false;
|
||||||
private currentActivity?: Activity;
|
private currentActivity?: Activity;
|
||||||
private lastUpdateTime = 0;
|
private lastUpdateTime = 0;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// singleton pattern
|
// 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 {
|
const serviceName = serviceFactory.getCurrentService().getServiceName();
|
||||||
if (!PluginManager.instance) {
|
logVerbose(`Fetching latest track from ${serviceName}...`);
|
||||||
PluginManager.instance = new PluginManager();
|
|
||||||
|
// 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
|
// skip if YouTube Music is playing and user wants to ignore it
|
||||||
private async updateActivity() {
|
if (currentSettings.ignoreYouTubeMusic) {
|
||||||
if (pluginState.pluginStopped) {
|
const youtubeActivity = SelfPresenceStore.findActivity(
|
||||||
logVerbose("Plugin is stopped, skipping update");
|
(act) =>
|
||||||
this.stopUpdates();
|
act.name === "YouTube Music" ||
|
||||||
return;
|
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();
|
incrementApiCall();
|
||||||
logVerbose(`Fetching latest track from ${serviceName}...`);
|
const lastTrack = await serviceFactory
|
||||||
|
.getCurrentService()
|
||||||
|
.fetchLatestScrobble();
|
||||||
|
setDebugInfo("lastTrack", lastTrack);
|
||||||
|
|
||||||
// keep track of whether we actually need to update Discord
|
if (!lastTrack.nowPlaying) {
|
||||||
let willUpdateRPC = false;
|
logVerbose("Track is not currently playing");
|
||||||
|
clearActivity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// use simple URL comparison like Last.fm plugin
|
||||||
// skip if Spotify is playing and user wants to ignore it
|
if (pluginState.lastTrackUrl === lastTrack.url) {
|
||||||
if (currentSettings.ignoreSpotify) {
|
logVerbose("Track hasn't changed");
|
||||||
const spotifyActivity = SelfPresenceStore.findActivity(
|
recordSuccessfulUpdate();
|
||||||
(act) => act.sync_id,
|
this.consecutiveFailures = 0;
|
||||||
);
|
return;
|
||||||
if (spotifyActivity) {
|
}
|
||||||
logVerbose("Spotify is currently playing, clearing activity");
|
|
||||||
setDebugInfo("isSpotifyIgnored", true);
|
|
||||||
clearActivity();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDebugInfo("isSpotifyIgnored", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
incrementApiCall();
|
willUpdateRPC = true;
|
||||||
const lastTrack = await serviceFactory
|
|
||||||
.getCurrentService()
|
|
||||||
.fetchLatestScrobble();
|
|
||||||
setDebugInfo("lastTrack", lastTrack);
|
|
||||||
|
|
||||||
if (!lastTrack.nowPlaying) {
|
log(`🎵 Track changed: ${lastTrack.artist} - ${lastTrack.name}`);
|
||||||
logVerbose("Track is not currently playing");
|
|
||||||
clearActivity();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// use simple URL comparison like Last.fm plugin
|
// set up timestamps for the track
|
||||||
if (pluginState.lastTrackUrl === lastTrack.url) {
|
let activityTimestamps;
|
||||||
logVerbose("Track hasn't changed");
|
if (
|
||||||
recordSuccessfulUpdate();
|
lastTrack.nowPlaying &&
|
||||||
this.consecutiveFailures = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
willUpdateRPC = true;
|
|
||||||
|
|
||||||
log(`🎵 Track changed: ${lastTrack.artist} - ${lastTrack.name}`);
|
|
||||||
|
|
||||||
// set up timestamps for the track
|
|
||||||
let activityTimestamps;
|
|
||||||
if (
|
|
||||||
lastTrack.nowPlaying &&
|
|
||||||
currentSettings.showTimestamp &&
|
currentSettings.showTimestamp &&
|
||||||
lastTrack.from
|
lastTrack.from
|
||||||
) {
|
) {
|
||||||
// figure out when this track actually started
|
// figure out when this track actually started
|
||||||
const now = getCurrentTimestamp();
|
const now = getCurrentTimestamp();
|
||||||
let startTime = lastTrack.from;
|
let startTime = lastTrack.from;
|
||||||
|
|
||||||
// if the timestamp is way old, estimate when it started
|
// if the timestamp is way old, estimate when it started
|
||||||
if (startTime < now - 3600) {
|
if (startTime < now - 3600) {
|
||||||
// more than an hour old - probably wrong
|
// more than an hour old - probably wrong
|
||||||
if (lastTrack.duration && lastTrack.duration > 0) {
|
if (lastTrack.duration && lastTrack.duration > 0) {
|
||||||
// guess we're about 10% in or 30 seconds, whatever's smaller
|
// guess we're about 10% in or 30 seconds, whatever's smaller
|
||||||
const estimatedElapsed = Math.min(lastTrack.duration * 0.1, 30);
|
const estimatedElapsed = Math.min(lastTrack.duration * 0.1, 30);
|
||||||
startTime = now - estimatedElapsed;
|
startTime = now - estimatedElapsed;
|
||||||
} else {
|
} else {
|
||||||
startTime = now;
|
startTime = now;
|
||||||
}
|
}
|
||||||
logVerbose("had to estimate start time");
|
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceName = serviceFactory.getCurrentService().getServiceName();
|
activityTimestamps = {
|
||||||
log(`Initializing with ${serviceName}...`);
|
start: startTime * 1000, // Discord wants milliseconds
|
||||||
|
|
||||||
// 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",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
// expose the manager functions
|
||||||
|
|
@ -408,5 +425,5 @@ const manager = PluginManager.getInstance();
|
||||||
export const initialize = () => manager.initialize();
|
export const initialize = () => manager.initialize();
|
||||||
export const stop = () => manager.stop();
|
export const stop = () => manager.stop();
|
||||||
export const switchService = (service: string) =>
|
export const switchService = (service: string) =>
|
||||||
manager.switchService(service);
|
manager.switchService(service);
|
||||||
export const getStatus = () => manager.getStatus();
|
export const getStatus = () => manager.getStatus();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue