Enforces a minimum update interval of 60 seconds when Libre.fm is the active service, as requested by the Libre.fm team to reduce server load. - Added LIBREFM_MIN_UPDATE_INTERVAL to Constants. - Updated PluginManager to enforce the new minimum for Libre.fm. - Disabled user-configurable update interval in UI when Libre.fm is active. Closes #4
476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
import { currentSettings, pluginState } from ".";
|
|
import { Activity } from "../../defs";
|
|
import Constants from "./constants";
|
|
import { SelfPresenceStore } from "./modules";
|
|
import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
|
|
import {
|
|
setDebugInfo,
|
|
recordSuccessfulUpdate,
|
|
recordServiceError,
|
|
incrementApiCall,
|
|
} from "./utils/debug";
|
|
import { serviceFactory } from "./services/ServiceFactory";
|
|
import {
|
|
formatDuration,
|
|
getCurrentTimestamp,
|
|
} from "./utils/time";
|
|
|
|
enum ActivityType {
|
|
PLAYING = 0,
|
|
STREAMING = 1,
|
|
LISTENING = 2,
|
|
COMPETING = 5,
|
|
}
|
|
|
|
const log = (...message: any[]) => console.log("[Scrobble Plugin]", ...message);
|
|
const logError = (...message: any[]) =>
|
|
console.error("[Scrobble Plugin] Error:", ...message);
|
|
const logVerbose = (...message: any[]) =>
|
|
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 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) {
|
|
log("Plugin is stopped; skipping activity updates and clearing timers");
|
|
logVerbose("Plugin is stopped, skipping update");
|
|
try {
|
|
this.stopUpdates();
|
|
logVerbose("Update timers cleared due to stopped plugin");
|
|
} catch (e) {
|
|
logError("Error while stopping updates for stopped plugin:", e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 {
|
|
// Check if any ignored app is active
|
|
if (currentSettings.ignoreList && currentSettings.ignoreList.length > 0) {
|
|
const ignoredActivity = SelfPresenceStore.findActivity((act) => {
|
|
if (!act.name) return false;
|
|
return currentSettings.ignoreList.some((ignoredApp: string) =>
|
|
act.name.toLowerCase().includes(ignoredApp.toLowerCase()),
|
|
);
|
|
});
|
|
|
|
if (ignoredActivity) {
|
|
log(`Ignored app (${ignoredActivity.name}) is currently active; clearing activity and skipping updates`);
|
|
logVerbose(
|
|
`Ignored app (${ignoredActivity.name}) is currently playing, clearing activity`,
|
|
);
|
|
try {
|
|
// Provide a little debug context so we can see why updates are skipped
|
|
setDebugInfo("ignoredActivity", ignoredActivity.name);
|
|
} catch (e) {
|
|
logVerbose("Failed to set debug info for ignored activity:", e);
|
|
}
|
|
clearActivity();
|
|
return;
|
|
}
|
|
}
|
|
|
|
incrementApiCall();
|
|
const lastTrack = await serviceFactory
|
|
.getCurrentService()
|
|
.fetchLatestScrobble();
|
|
setDebugInfo("lastTrack", lastTrack);
|
|
|
|
if (!lastTrack.nowPlaying) {
|
|
log("No currently playing track reported by service; clearing activity and skipping RPC update");
|
|
logVerbose("Track is not currently playing");
|
|
try {
|
|
setDebugInfo("lastTrack_nowPlaying", false);
|
|
} catch (e) {
|
|
logVerbose("Failed to set debug info for nowPlaying:", e);
|
|
}
|
|
clearActivity();
|
|
return;
|
|
}
|
|
|
|
// use simple URL comparison like Last.fm plugin
|
|
if (pluginState.lastTrackUrl === lastTrack.url) {
|
|
log("Track unchanged; skipping Discord RPC update");
|
|
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 &&
|
|
currentSettings.showTimestamp &&
|
|
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,
|
|
};
|
|
|
|
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: `${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 - SIMPLIFIED VERSION (no fallback image)
|
|
if (lastTrack.album || lastTrack.albumArt) {
|
|
const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : [];
|
|
const assets = await fetchAsset(assetUrls);
|
|
|
|
// Only use album art if available
|
|
let largeImageAsset = assets[0];
|
|
|
|
if (largeImageAsset) {
|
|
activity.assets = {
|
|
large_image: largeImageAsset,
|
|
};
|
|
|
|
// Build tooltip text based on settings
|
|
if (currentSettings.showLargeText) {
|
|
let largeText = "";
|
|
|
|
if (currentSettings.showAlbumInTooltip && lastTrack.album) {
|
|
largeText += `on ${lastTrack.album}`;
|
|
}
|
|
|
|
if (currentSettings.showDurationInTooltip && lastTrack.duration) {
|
|
const durationText = formatDuration(lastTrack.duration);
|
|
if (largeText) {
|
|
largeText += ` • ${durationText}`;
|
|
} else {
|
|
largeText = durationText;
|
|
}
|
|
}
|
|
|
|
if (largeText) {
|
|
activity.assets.large_text = largeText;
|
|
}
|
|
}
|
|
|
|
logVerbose("Album art set:", largeImageAsset);
|
|
if (activity.assets.large_text) {
|
|
logVerbose("Tooltip text set:", activity.assets.large_text);
|
|
}
|
|
} else if (
|
|
lastTrack.album &&
|
|
currentSettings.showLargeText &&
|
|
currentSettings.showAlbumInTooltip
|
|
) {
|
|
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);
|
|
try {
|
|
recordServiceError(currentSettings.service, (error as Error).message);
|
|
} catch (e) {
|
|
logError("Failed to record service error:", e);
|
|
}
|
|
try {
|
|
setDebugInfo("lastUpdateError", {
|
|
message: (error as Error).message,
|
|
service: currentSettings.service,
|
|
lastTrackUrl: pluginState.lastTrackUrl,
|
|
});
|
|
} catch (e) {
|
|
logVerbose("Failed to set debug info for last update error:", e);
|
|
}
|
|
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 currentService = currentSettings.service;
|
|
let minInterval: number = Constants.MIN_UPDATE_INTERVAL;
|
|
|
|
if (currentService === "librefm") {
|
|
minInterval = Constants.LIBREFM_MIN_UPDATE_INTERVAL;
|
|
log(`Libre.fm active: enforcing ${minInterval}s minimum update interval`);
|
|
}
|
|
|
|
const interval = Math.max(
|
|
(Number(currentSettings.timeInterval) ||
|
|
Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
|
|
minInterval * 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
|
|
const manager = PluginManager.getInstance();
|
|
export const initialize = () => manager.initialize();
|
|
export const stop = () => manager.stop();
|
|
export const switchService = (service: string) =>
|
|
manager.switchService(service);
|
|
export const getStatus = () => manager.getStatus();
|