fix lint and most of errors in terminal

This commit is contained in:
kmmiio99o 2025-10-04 20:14:14 +02:00
parent 511d9f8e93
commit 735f8aba4c
18 changed files with 3441 additions and 3439 deletions

View file

@ -1,44 +1,36 @@
{
"env": {
"es2021": true,
"node": true
"env": {
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
4,
{ "SwitchCase": 1 }
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"react/react-in-jsx-scope": "off",
"eol-last": "error"
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint"],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"indent": ["error", 4, { "SwitchCase": 1 }],
"quotes": ["error", "double"],
"semi": ["error", "always"],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"react/react-in-jsx-scope": "off",
"eol-last": "error"
}
}

View file

@ -7,23 +7,26 @@
"serve": "node serve.mjs"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.1.0",
"@rollup/plugin-node-resolve": "^15.0.2",
"@swc/core": "^1.3.51",
"@rollup/plugin-commonjs": "^28.0.6",
"@rollup/plugin-node-resolve": "^16.0.2",
"@swc/core": "^1.13.5",
"@types/react": "^18.0.37",
"@types/react-native": "^0.70.13",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"esbuild": "^0.17.17",
"eslint": "^8.38.0",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"esbuild": "^0.25.10",
"eslint": "^9.37.0",
"eslint-plugin-react": "^7.32.2",
"rollup": "^3.20.6",
"rollup-plugin-esbuild": "^5.0.0",
"typescript": "^4.9.5",
"vendetta-types": "^2.4.18"
"rollup": "^4.24.4",
"rollup-plugin-esbuild": "^6.1.1",
"typescript": "^5.7.2",
"vendetta-types": "^2.4.21"
},
"dependencies": {
"@swc/helpers": "^0.5.0",
"@swc/helpers": "^0.5.17",
"serve": "^14.2.5"
},
"overrides": {
"@types/react": "^18.0.37"
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,166 +8,166 @@ const BotMessage = findByProps("createBotMessage");
const Avatars = findByProps("BOT_AVATARS");
function sendReply(channelID, content, embed) {
const channel = channelID ?? Channels?.getChannelId?.();
const msg = BotMessage.createBotMessage({
channelId: channel,
content: "",
embeds: embed,
});
msg.author.username = "Astolfo";
msg.author.avatar = "Astolfo";
Avatars.BOT_AVATARS.Astolfo =
const channel = channelID ?? Channels?.getChannelId?.();
const msg = BotMessage.createBotMessage({
channelId: channel,
content: "",
embeds: embed,
});
msg.author.username = "Astolfo";
msg.author.avatar = "Astolfo";
Avatars.BOT_AVATARS.Astolfo =
"https://i.pinimg.com/736x/50/77/1f/50771f45b1c015cfbb8b0853ba7b8521.jpg";
if (typeof content === "string") {
msg.content = content;
} else {
Object.assign(msg, content);
}
MessageActions.receiveMessage(channel, msg);
if (typeof content === "string") {
msg.content = content;
} else {
Object.assign(msg, content);
}
MessageActions.receiveMessage(channel, msg);
}
export const lovefemboysCommand = {
name: "lovefemboys",
displayName: "lovefemboys",
description: "Get an image of a femboy",
displayDescription: "Get an image of a femboy",
options: [
{
name: "nsfw",
displayName: "nsfw",
description: "Get the result from r/femboys instead of r/femboy (NSFW)",
displayDescription:
name: "lovefemboys",
displayName: "lovefemboys",
description: "Get an image of a femboy",
displayDescription: "Get an image of a femboy",
options: [
{
name: "nsfw",
displayName: "nsfw",
description: "Get the result from r/femboys instead of r/femboy (NSFW)",
displayDescription:
"Get the result from r/femboys instead of r/femboy (NSFW)",
required: false,
type: 5,
},
{
name: "sort",
displayName: "sort",
description: "Changes the way reddit sorts.",
displayDescription: "Changes the way reddit sorts",
required: false,
type: 3,
},
{
name: "silent",
displayName: "silent",
description: "Makes it so only you can see the message.",
displayDescription: "Makes it so only you can see the message.",
required: false,
type: 5,
},
],
applicationId: "-1",
inputType: 1,
type: 1,
execute: async (args, ctx) => {
try {
// Some code taken from enmity gotfemboys plugin by spinfal & was modified with the help of meqativ
let nsfw = args.find((arg) => arg.name === "nsfw")?.value;
let sort = args.find((arg) => arg.name === "sort")?.value;
let silent = args.find((arg) => arg.name === "silent")?.value;
required: false,
type: 5,
},
{
name: "sort",
displayName: "sort",
description: "Changes the way reddit sorts.",
displayDescription: "Changes the way reddit sorts",
required: false,
type: 3,
},
{
name: "silent",
displayName: "silent",
description: "Makes it so only you can see the message.",
displayDescription: "Makes it so only you can see the message.",
required: false,
type: 5,
},
],
applicationId: "-1",
inputType: 1,
type: 1,
execute: async (args, ctx) => {
try {
// Some code taken from enmity gotfemboys plugin by spinfal & was modified with the help of meqativ
const nsfw = args.find((arg) => arg.name === "nsfw")?.value;
let sort = args.find((arg) => arg.name === "sort")?.value;
const silent = args.find((arg) => arg.name === "silent")?.value;
if (typeof sort === "undefined") sort = storage.sortdefs;
if (
!["best", "hot", "new", "rising", "top", "controversial"].includes(sort)
) {
sendReply(
ctx.channel.id,
"Incorrect sorting type. Valid options are\n`best`, `hot`, `new`, `rising`, `top`, `controversial`.",
[],
);
return;
}
if (typeof sort === "undefined") sort = storage.sortdefs;
if (
!["best", "hot", "new", "rising", "top", "controversial"].includes(sort)
) {
sendReply(
ctx.channel.id,
"Incorrect sorting type. Valid options are\n`best`, `hot`, `new`, `rising`, `top`, `controversial`.",
[],
);
return;
}
// Show NSFW warning popup only when nsfw option is selected
if (nsfw) {
const shouldProceed = await new Promise((resolve) => {
alerts.showConfirmationAlert({
title: "⚠️ NSFW Content Warning",
content:
// Show NSFW warning popup only when nsfw option is selected
if (nsfw) {
const shouldProceed = await new Promise((resolve) => {
alerts.showConfirmationAlert({
title: "⚠️ NSFW Content Warning",
content:
"This command will send NSFW (Not Safe For Work) content from r/femboys. Are you sure you want to continue?",
confirmText: "Yes, Continue",
onConfirm: () => resolve(true),
cancelText: "Cancel",
onCancel: () => resolve(false),
});
});
confirmText: "Yes, Continue",
onConfirm: () => resolve(true),
cancelText: "Cancel",
onCancel: () => resolve(false),
});
});
if (!shouldProceed) {
return { type: 4 };
}
}
if (!shouldProceed) {
return { type: 4 };
}
}
let response = await fetch(
`https://www.reddit.com/r/femboy/${sort}.json?limit=100`,
).then((res) => res.json());
let response = await fetch(
`https://www.reddit.com/r/femboy/${sort}.json?limit=100`,
).then((res) => res.json());
if (!ctx.channel.nsfw_ && nsfw && storage.nsfwwarn && !(silent ?? true)) {
sendReply(
ctx.channel.id,
"This channel is not marked as NSFW\n(You can disable this check in plugin settings)",
[],
);
return;
}
if (!ctx.channel.nsfw_ && nsfw && storage.nsfwwarn && !(silent ?? true)) {
sendReply(
ctx.channel.id,
"This channel is not marked as NSFW\n(You can disable this check in plugin settings)",
[],
);
return;
}
if (nsfw) {
response = await fetch(
`https://www.reddit.com/r/femboys/${sort}.json?limit=100`,
).then((res) => res.json());
}
if (nsfw) {
response = await fetch(
`https://www.reddit.com/r/femboys/${sort}.json?limit=100`,
).then((res) => res.json());
}
response =
response =
response.data?.children?.[
Math.floor(Math.random() * response.data?.children?.length)
Math.floor(Math.random() * response.data?.children?.length)
]?.data;
let author = await fetch(
`https://www.reddit.com/u/${response?.author}/about.json`,
).then((res) => res.json());
const author = await fetch(
`https://www.reddit.com/u/${response?.author}/about.json`,
).then((res) => res.json());
if (silent ?? true) {
sendReply(ctx.channel.id, "", [
{
type: "rich",
title: response?.title,
url: `https://reddit.com${response?.permalink}`,
author: {
name: `u/${response?.author} • r/${response?.subreddit}`,
proxy_icon_url: author?.data.icon_img.split("?")[0],
icon_url: author?.data.icon_img.split("?")[0],
},
image: {
proxy_url:
if (silent ?? true) {
sendReply(ctx.channel.id, "", [
{
type: "rich",
title: response?.title,
url: `https://reddit.com${response?.permalink}`,
author: {
name: `u/${response?.author} • r/${response?.subreddit}`,
proxy_icon_url: author?.data.icon_img.split("?")[0],
icon_url: author?.data.icon_img.split("?")[0],
},
image: {
proxy_url:
response?.url_overridden_by_dest?.replace(/.gifv$/g, ".gif") ??
response?.url.replace(/.gifv$/g, ".gif"),
url:
url:
response?.url_overridden_by_dest?.replace(/.gifv$/g, ".gif") ??
response?.url?.replace(/.gifv$/g, ".gif"),
width: response?.preview?.images?.[0]?.source?.width,
height: response?.preview?.images?.[0]?.source?.height,
},
color: "0xf4b8e4",
},
]);
} else {
const fixNonce = Date.now().toString();
MessageActions.sendMessage(
ctx.channel.id,
{
content: response?.url_overridden_by_dest ?? response?.url,
},
void 0,
{ nonce: fixNonce },
);
}
} catch (err) {
console.error("[LoveFemboys] Error:", err);
sendReply(
ctx.channel.id,
"ERROR !!!!!!!!!!!! 😭😭😭 Check debug logs!! 🥺🥺🥺",
[],
);
}
},
width: response?.preview?.images?.[0]?.source?.width,
height: response?.preview?.images?.[0]?.source?.height,
},
color: "0xf4b8e4",
},
]);
} else {
const fixNonce = Date.now().toString();
MessageActions.sendMessage(
ctx.channel.id,
{
content: response?.url_overridden_by_dest ?? response?.url,
},
void 0,
{ nonce: fixNonce },
);
}
} catch (err) {
console.error("[LoveFemboys] Error:", err);
sendReply(
ctx.channel.id,
"ERROR !!!!!!!!!!!! 😭😭😭 Check debug logs!! 🥺🥺🥺",
[],
);
}
},
};

View file

@ -2,64 +2,71 @@
// Facts APIs
export const uselessFact = async () => {
const response = await fetch("https://uselessfacts.jsph.pl/api/v2/facts/random");
const resp = await response.json();
return {
text: resp["text"],
source: resp["source"],
language: resp["language"],
};
const response = await fetch(
"https://uselessfacts.jsph.pl/api/v2/facts/random",
);
const resp = await response.json();
return {
text: resp["text"],
source: resp["source"],
language: resp["language"],
};
};
export const dogFact = async () => {
const response = await fetch("https://dogapi.dog/api/v2/facts?limit=1");
const resp = await response.json();
return {
text: resp["data"]["0"]["attributes"]["body"],
};
const response = await fetch("https://dogapi.dog/api/v2/facts?limit=1");
const resp = await response.json();
return {
text: resp["data"]["0"]["attributes"]["body"],
};
};
export const catFact = async () => {
const response = await fetch("https://catfact.ninja/fact");
const resp = await response.json();
return {
text: resp["fact"],
length: resp["length"],
};
const response = await fetch("https://catfact.ninja/fact");
const resp = await response.json();
return {
text: resp["fact"],
length: resp["length"],
};
};
// Image APIs
export const getPetPetData = async (image: string) => {
const data = await fetch(
`https://api.obamabot.me/v2/image/petpet?image=${image.replace("webp", "png")}`
);
const body = await data.json();
return body;
const data = await fetch(
`https://api.obamabot.me/v2/image/petpet?image=${image.replace("webp", "png")}`,
);
const body = await data.json();
return body;
};
// Gary API function
export const getGaryUrl = async (source = "gary"): Promise<string | null> => {
try {
switch (source) {
case "gary":
const response = await fetch("https://api.garythe.cat/gary");
const json = await response.json();
return json.url;
case "goober":
const gooberResponse = await fetch("https://api.garythe.cat/goober");
const gooberJson = await gooberResponse.json();
return gooberJson.url;
case "catapi":
const catResponse = await fetch("https://api.thecatapi.com/v1/images/search");
const catJson = await catResponse.json();
return catJson[0].url;
case "minker":
return "https://minky.materii.dev/";
default:
throw new Error("Invalid Gary image source value");
}
} catch (error) {
console.error(`[Gary API] Error fetching image from ${source}:`, error);
return null;
try {
switch (source) {
case "gary": {
const response = await fetch("https://api.garythe.cat/gary");
const json = await response.json();
return json.url;
}
case "goober": {
const gooberResponse = await fetch("https://api.garythe.cat/goober");
const gooberJson = await gooberResponse.json();
return gooberJson.url;
}
case "catapi": {
const catResponse = await fetch(
"https://api.thecatapi.com/v1/images/search",
);
const catJson = await catResponse.json();
return catJson[0].url;
}
case "minker":
return "https://minky.materii.dev/";
default:
throw new Error("Invalid Gary image source value");
}
} catch (error) {
console.error(`[Gary API] Error fetching image from ${source}:`, error);
return null;
}
};

View file

@ -1,79 +1,79 @@
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,
// 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: "",
},
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: "",
},
// 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 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;

View file

@ -11,10 +11,10 @@ import Settings from "./ui/pages/Settings";
import patchSidebar from "./sidebar";
export const pluginState = {
pluginStopped: false,
lastActivity: undefined,
updateInterval: undefined,
lastTrackUrl: undefined,
pluginStopped: false,
lastActivity: undefined,
updateInterval: undefined,
lastTrackUrl: undefined,
} as {
pluginStopped: boolean;
lastActivity?: any;
@ -28,20 +28,20 @@ let sidebarUnpatch: (() => void) | undefined;
// Set up default plugin settings
const defaultSettings: LFMSettings = Constants.DEFAULT_SETTINGS;
for (const key of Object.keys(defaultSettings)) {
plugin.storage[key] =
plugin.storage[key] =
plugin.storage[key] ?? defaultSettings[key as keyof typeof defaultSettings];
}
plugin.storage.addToSidebar ??= false;
export const currentSettings = new Proxy(plugin.storage, {
get(target, prop) {
return target[prop];
},
set(target, prop, value) {
target[prop] = value;
return true;
},
get(target, prop) {
return target[prop];
},
set(target, prop, value) {
target[prop] = value;
return true;
},
});
// Keep track of connection attempts
@ -51,182 +51,182 @@ const RECONNECT_DELAY = 5000;
const log = (...args: any[]) => console.log("[Scrobble Plugin]", ...args);
const logError = (...args: any[]) =>
console.error("[Scrobble Plugin] Error:", ...args);
console.error("[Scrobble Plugin] Error:", ...args);
async function tryInitialize() {
try {
await initialize();
connectionAttempts = 0;
log("Plugin initialized successfully");
} catch (error) {
logError("Initialization failed:", error);
connectionAttempts++;
try {
await initialize();
connectionAttempts = 0;
log("Plugin initialized successfully");
} catch (error) {
logError("Initialization failed:", error);
connectionAttempts++;
if (connectionAttempts < MAX_CONNECTION_ATTEMPTS) {
log(
`Retrying connection (${connectionAttempts}/${MAX_CONNECTION_ATTEMPTS})...`,
);
setTimeout(tryInitialize, RECONNECT_DELAY);
} else {
logError(`Failed to connect after ${MAX_CONNECTION_ATTEMPTS} attempts`);
if (connectionAttempts < MAX_CONNECTION_ATTEMPTS) {
log(
`Retrying connection (${connectionAttempts}/${MAX_CONNECTION_ATTEMPTS})...`,
);
setTimeout(tryInitialize, RECONNECT_DELAY);
} else {
logError(`Failed to connect after ${MAX_CONNECTION_ATTEMPTS} attempts`);
}
}
}
}
async function validateAndInitialize() {
// Check if user has selected a service first
if (!currentSettings.service) {
log("No service selected. Please configure a service in plugin settings.");
return;
}
// Check if user has selected a service first
if (!currentSettings.service) {
log("No service selected. Please configure a service in plugin settings.");
return;
}
const serviceName = serviceFactory.getCurrentService().getServiceName();
const serviceName = serviceFactory.getCurrentService().getServiceName();
// Make sure we have credentials for whatever service is selected
const service = currentSettings.service;
let hasCredentials = false;
// Make sure we have credentials for whatever service is selected
const service = currentSettings.service;
let hasCredentials = false;
switch (service) {
case "lastfm":
hasCredentials = !!(currentSettings.username && currentSettings.apiKey);
break;
case "librefm":
hasCredentials = !!(
currentSettings.librefmUsername && currentSettings.librefmApiKey
);
break;
case "listenbrainz":
hasCredentials = !!currentSettings.listenbrainzUsername;
break;
}
switch (service) {
case "lastfm":
hasCredentials = !!(currentSettings.username && currentSettings.apiKey);
break;
case "librefm":
hasCredentials = !!(
currentSettings.librefmUsername && currentSettings.librefmApiKey
);
break;
case "listenbrainz":
hasCredentials = !!currentSettings.listenbrainzUsername;
break;
}
if (!hasCredentials) {
logError(
`Missing credentials for ${serviceName}. Please configure in plugin settings.`,
);
return;
}
if (!hasCredentials) {
logError(
`Missing credentials for ${serviceName}. Please configure in plugin settings.`,
);
return;
}
log(`Starting with ${serviceName}...`);
log(`Starting with ${serviceName}...`);
if (UserStore.getCurrentUser()) {
log("Discord is already connected, initializing immediately...");
await tryInitialize();
} else {
log("Waiting for Discord connection...");
const waitForUser = () => {
if (UserStore.getCurrentUser()) {
log("Discord connection established");
tryInitialize();
FluxDispatcher.unsubscribe("CONNECTION_OPEN", waitForUser);
}
};
if (UserStore.getCurrentUser()) {
log("Discord is already connected, initializing immediately...");
await tryInitialize();
} else {
log("Waiting for Discord connection...");
const waitForUser = () => {
if (UserStore.getCurrentUser()) {
log("Discord connection established");
tryInitialize();
FluxDispatcher.unsubscribe("CONNECTION_OPEN", waitForUser);
}
};
FluxDispatcher.subscribe("CONNECTION_OPEN", waitForUser);
}
FluxDispatcher.subscribe("CONNECTION_OPEN", waitForUser);
}
}
export default {
onLoad() {
log("Plugin loading...");
pluginState.pluginStopped = false;
onLoad() {
log("Plugin loading...");
pluginState.pluginStopped = false;
// Check if service is configured
if (!currentSettings.service) {
log("No service configured. Please select a service in plugin settings.");
} else {
// Show what we're starting with
const serviceName = serviceFactory.getCurrentService().getServiceName();
log(
`Configuration: Service=${serviceName}, Update Interval=${currentSettings.timeInterval}s, Verbose=${currentSettings.verboseLogging}`,
);
}
// Add to sidebar if user wants it
try {
sidebarUnpatch = patchSidebar();
cleanupFunctions.push(() => {
if (sidebarUnpatch) {
sidebarUnpatch();
sidebarUnpatch = undefined;
// Check if service is configured
if (!currentSettings.service) {
log("No service configured. Please select a service in plugin settings.");
} else {
// Show what we're starting with
const serviceName = serviceFactory.getCurrentService().getServiceName();
log(
`Configuration: Service=${serviceName}, Update Interval=${currentSettings.timeInterval}s, Verbose=${currentSettings.verboseLogging}`,
);
}
});
} catch (error) {
log("Sidebar setup failed:", error);
}
validateAndInitialize();
},
onUnload() {
log("Plugin unloading...");
pluginState.pluginStopped = true;
// Run all cleanup functions
cleanupFunctions.forEach((cleanup) => {
try {
cleanup();
} catch (error) {
// ignore cleanup errors
}
});
cleanupFunctions.length = 0;
stop();
log("Plugin unloaded");
},
// When user changes settings
async onSettingsUpdate(newSettings: any) {
const oldService = currentSettings.service;
const newService = newSettings.service;
const oldSidebar = currentSettings.addToSidebar;
const newSidebar = newSettings.addToSidebar;
// Apply the new settings
Object.assign(currentSettings, newSettings);
log("Settings updated:", Object.keys(newSettings));
// Check if sidebar setting changed
if (oldSidebar !== newSidebar) {
if (newSidebar) {
// Add to sidebar if user wants it
try {
sidebarUnpatch = patchSidebar();
log("Sidebar enabled");
sidebarUnpatch = patchSidebar();
cleanupFunctions.push(() => {
if (sidebarUnpatch) {
sidebarUnpatch();
sidebarUnpatch = undefined;
}
});
} catch (error) {
log("Failed to enable sidebar:", error);
log("Sidebar setup failed:", error);
}
} else {
if (sidebarUnpatch) {
sidebarUnpatch();
sidebarUnpatch = undefined;
log("Sidebar disabled");
validateAndInitialize();
},
onUnload() {
log("Plugin unloading...");
pluginState.pluginStopped = true;
// Run all cleanup functions
cleanupFunctions.forEach((cleanup) => {
try {
cleanup();
} catch (error) {
// ignore cleanup errors
}
});
cleanupFunctions.length = 0;
stop();
log("Plugin unloaded");
},
// When user changes settings
async onSettingsUpdate(newSettings: any) {
const oldService = currentSettings.service;
const newService = newSettings.service;
const oldSidebar = currentSettings.addToSidebar;
const newSidebar = newSettings.addToSidebar;
// Apply the new settings
Object.assign(currentSettings, newSettings);
log("Settings updated:", Object.keys(newSettings));
// Check if sidebar setting changed
if (oldSidebar !== newSidebar) {
if (newSidebar) {
try {
sidebarUnpatch = patchSidebar();
log("Sidebar enabled");
} catch (error) {
log("Failed to enable sidebar:", error);
}
} else {
if (sidebarUnpatch) {
sidebarUnpatch();
sidebarUnpatch = undefined;
log("Sidebar disabled");
}
}
}
}
}
// Switch services if needed
if (oldService !== newService && newService) {
log(`Service changed from ${oldService || "none"} to ${newService}`);
await switchService(newService);
} else if (!pluginState.pluginStopped && currentSettings.service) {
// Just restart with the updated settings (only if service is selected)
log("Restarting with updated settings...");
await tryInitialize();
} else if (!currentSettings.service) {
// Service was unselected, stop the plugin
log("Service unselected, stopping plugin...");
stop();
}
},
// Switch services if needed
if (oldService !== newService && newService) {
log(`Service changed from ${oldService || "none"} to ${newService}`);
await switchService(newService);
} else if (!pluginState.pluginStopped && currentSettings.service) {
// Just restart with the updated settings (only if service is selected)
log("Restarting with updated settings...");
await tryInitialize();
} else if (!currentSettings.service) {
// Service was unselected, stop the plugin
log("Service unselected, stopping plugin...");
stop();
}
},
onDiscordReconnect() {
if (!pluginState.pluginStopped) {
log("Discord reconnected, reinitializing plugin...");
tryInitialize();
}
},
onDiscordReconnect() {
if (!pluginState.pluginStopped) {
log("Discord reconnected, reinitializing plugin...");
tryInitialize();
}
},
settings: Settings,
settings: Settings,
};

View file

@ -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,382 @@ 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;
}
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;
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
setDebugInfo("isSpotifyIgnored", false);
}
return PluginManager.instance;
}
incrementApiCall();
const lastTrack = await serviceFactory
.getCurrentService()
.fetchLatestScrobble();
setDebugInfo("lastTrack", lastTrack);
// 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;
}
if (!lastTrack.nowPlaying) {
logVerbose("Track is not currently playing");
clearActivity();
return;
}
const serviceName = serviceFactory.getCurrentService().getServiceName();
logVerbose(`Fetching latest track from ${serviceName}...`);
// use simple URL comparison like Last.fm plugin
if (pluginState.lastTrackUrl === lastTrack.url) {
logVerbose("Track hasn't changed");
recordSuccessfulUpdate();
this.consecutiveFailures = 0;
return;
}
// keep track of whether we actually need to update Discord
let willUpdateRPC = false;
willUpdateRPC = true;
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);
}
log(`🎵 Track changed: ${lastTrack.artist} - ${lastTrack.name}`);
incrementApiCall();
const lastTrack = await serviceFactory
.getCurrentService()
.fetchLatestScrobble();
setDebugInfo("lastTrack", lastTrack);
// set up timestamps for the track
let activityTimestamps;
if (
lastTrack.nowPlaying &&
if (!lastTrack.nowPlaying) {
logVerbose("Track is not currently playing");
clearActivity();
return;
}
// 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 &&
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");
}
// 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
};
activityTimestamps = {
start: startTime * 1000, // Discord wants milliseconds
};
if (lastTrack.to) {
activityTimestamps.end = lastTrack.to * 1000;
}
}
if (lastTrack.to) {
activityTimestamps.end = lastTrack.to * 1000;
}
}
logVerbose(
`🎯 Preparing RPC update for: ${lastTrack.artist} - ${lastTrack.name}`,
);
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,
};
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,
};
// 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 || "",
);
}
}
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;
// 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",
});
}
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);
// 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}`;
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 =
// include track length in the tooltip if we have it
const durationText =
currentSettings.showTimestamp && lastTrack.duration
? ` • ${formatDuration(lastTrack.duration)}`
: "";
? ` • ${formatDuration(lastTrack.duration)}`
: "";
activity.assets = {
large_image: assets[0],
large_text: largeText + durationText,
};
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("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);
}
}
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}...`);
private handleError(error: Error) {
this.consecutiveFailures++;
setDebugInfo("lastError", error);
// 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;
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();
}
}
this.stopUpdates();
private startReconnection() {
if (this.isReconnecting) return;
// 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();
this.isReconnecting = true;
this.stopUpdates();
const interval = Math.max(
(Number(currentSettings.timeInterval) ||
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,
);
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
this.updateTimer = setInterval(() => this.updateActivity(), interval);
log(
`Update timer started with interval: ${interval}ms (${interval / 1000}s)`,
);
}
log("Stopping plugin...");
pluginState.pluginStopped = true;
// stop everything and clean up
public stop() {
if (pluginState.pluginStopped) {
return; // already stopped
}
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);
}
}
log("Stopping plugin...");
pluginState.pluginStopped = true;
// change to a different scrobble service
public async switchService(newService: string) {
if (pluginState.pluginStopped) {
return;
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);
}
}
log(`Switching to ${newService}...`);
// change to a different scrobble service
public async switchService(newService: string) {
if (pluginState.pluginStopped) {
return;
}
// stop what we're doing first
const wasRunning = !pluginState.pluginStopped;
this.stop();
log(`Switching to ${newService}...`);
try {
// clear cache so we get fresh service instances
serviceFactory.clearCache();
// stop what we're doing first
const wasRunning = !pluginState.pluginStopped;
this.stop();
// Reset track state when switching services
pluginState.lastTrackUrl = undefined;
this.currentActivity = undefined;
this.lastUpdateTime = 0;
try {
// clear cache so we get fresh service instances
serviceFactory.clearCache();
// start back up with the new service
if (wasRunning) {
pluginState.pluginStopped = false;
await this.initialize();
}
} catch (error) {
logError("Failed to switch service:", error);
// 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",
};
}
// 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 +408,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();

View file

@ -3,119 +3,119 @@ import Constants from "../constants";
import { incrementApiCall, recordServiceError } from "../utils/debug";
export abstract class BaseService implements ServiceClient {
protected retryCount = 0;
protected lastError = 0;
protected retryCount = 0;
protected lastError = 0;
abstract fetchLatestScrobble(): Promise<Track>;
abstract validateCredentials(): Promise<boolean>;
abstract getServiceName(): string;
protected log(...args: any[]): void {
console.log(`[${this.getServiceName()}]`, ...args);
console.log(`[${this.getServiceName()}]`, ...args);
}
protected logError(...args: any[]): void {
console.error(`[${this.getServiceName()}] Error:`, ...args);
console.error(`[${this.getServiceName()}] Error:`, ...args);
}
protected logVerbose(...args: any[]): void {
// subclasses will check if verbose logging is enabled
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
// subclasses will check if verbose logging is enabled
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
}
protected async handleError(error: any): Promise<never> {
this.lastError = error.error || 0;
this.lastError = error.error || 0;
const errorMessage = this.getErrorMessage(error);
this.logError(errorMessage);
recordServiceError(
const errorMessage = this.getErrorMessage(error);
this.logError(errorMessage);
recordServiceError(
this.getServiceName().toLowerCase() as any,
errorMessage,
);
);
throw new Error(`${this.getServiceName()} API Error: ${errorMessage}`);
throw new Error(`${this.getServiceName()} API Error: ${errorMessage}`);
}
protected getErrorMessage(error: any): string {
if (error.error && Constants.API_ERROR_CODES[error.error]) {
return Constants.API_ERROR_CODES[error.error];
}
return error.message || error.toString() || "Unknown error";
if (error.error && Constants.API_ERROR_CODES[error.error]) {
return Constants.API_ERROR_CODES[error.error];
}
return error.message || error.toString() || "Unknown error";
}
protected async makeRequest(
url: string,
options: RequestInit = {},
url: string,
options: RequestInit = {},
): Promise<any> {
try {
this.logVerbose(`Making request to: ${url}`);
incrementApiCall();
try {
this.logVerbose(`Making request to: ${url}`);
incrementApiCall();
const response = await fetch(url, {
...options,
headers: {
"User-Agent": "Vendetta Multi-Service Scrobbler/3.0.0",
...options.headers,
},
});
const response = await fetch(url, {
...options,
headers: {
"User-Agent": "Vendetta Multi-Service Scrobbler/3.0.0",
...options.headers,
},
});
if (!response.ok) {
const error = new Error(
`HTTP ${response.status}: ${response.statusText}`,
);
recordServiceError(
if (!response.ok) {
const error = new Error(
`HTTP ${response.status}: ${response.statusText}`,
);
recordServiceError(
this.getServiceName().toLowerCase() as any,
error.message,
);
throw error;
}
);
throw error;
}
const data = await response.json();
const data = await response.json();
if (data.error) {
await this.handleError(data);
}
if (data.error) {
await this.handleError(data);
}
this.retryCount = 0; // worked, reset retry counter
return data;
} catch (error) {
this.retryCount++;
if (this.retryCount > Constants.MAX_RETRY_ATTEMPTS) {
this.retryCount = 0;
recordServiceError(
this.retryCount = 0; // worked, reset retry counter
return data;
} catch (error) {
this.retryCount++;
if (this.retryCount > Constants.MAX_RETRY_ATTEMPTS) {
this.retryCount = 0;
recordServiceError(
this.getServiceName().toLowerCase() as any,
`Max retries exceeded: ${(error as Error).message}`,
);
throw error;
}
);
throw error;
}
this.logVerbose(
`Request failed, retrying (${this.retryCount}/${Constants.MAX_RETRY_ATTEMPTS})`,
);
await new Promise((resolve) =>
setTimeout(resolve, Constants.RETRY_DELAY),
);
return this.makeRequest(url, options);
}
this.logVerbose(
`Request failed, retrying (${this.retryCount}/${Constants.MAX_RETRY_ATTEMPTS})`,
);
await new Promise((resolve) =>
setTimeout(resolve, Constants.RETRY_DELAY),
);
return this.makeRequest(url, options);
}
}
protected isDefaultCover(cover?: string): boolean {
if (!cover) return true;
return Constants.DEFAULT_COVER_HASHES.some((hash) => cover.includes(hash));
if (!cover) return true;
return Constants.DEFAULT_COVER_HASHES.some((hash) => cover.includes(hash));
}
protected processAlbumArt(cover?: string): string | null {
if (!cover || this.isDefaultCover(cover)) {
return null;
}
return cover;
if (!cover || this.isDefaultCover(cover)) {
return null;
}
return cover;
}
public getLastError(): number {
return this.lastError;
return this.lastError;
}
public resetRetryCount(): void {
this.retryCount = 0;
this.retryCount = 0;
}
}

View file

@ -38,134 +38,134 @@ interface LastFMTrack {
}
export class LastFmService extends BaseService {
getServiceName(): string {
return "Last.fm";
}
protected logVerbose(...args: any[]): void {
if (currentSettings.verboseLogging) {
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
getServiceName(): string {
return "Last.fm";
}
}
async validateCredentials(): Promise<boolean> {
try {
if (!currentSettings.username || !currentSettings.apiKey) {
throw new Error("Username or API key not set");
}
const params = new URLSearchParams({
method: "user.getinfo",
user: currentSettings.username,
api_key: currentSettings.apiKey,
format: "json",
});
const url = `${Constants.SERVICES.lastfm.baseUrl}?${params}`;
await this.makeRequest(url);
this.log("Credentials validation successful");
return true;
} catch (error) {
this.logError("Credentials validation failed:", error);
return false;
protected logVerbose(...args: any[]): void {
if (currentSettings.verboseLogging) {
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
}
}
}
async fetchLatestScrobble(): Promise<Track> {
try {
if (!currentSettings.username || !currentSettings.apiKey) {
throw new Error("Username or API key not set");
}
this.logVerbose(
"Fetching latest scrobble for user:",
currentSettings.username,
);
const params = new URLSearchParams({
method: "user.getrecenttracks",
user: currentSettings.username,
api_key: currentSettings.apiKey,
limit: "1",
extended: "1",
format: "json",
});
const url = `${Constants.SERVICES.lastfm.baseUrl}?${params}`;
const data: LastFMResponse = await this.makeRequest(url);
const lastTrack = data?.recenttracks?.track?.[0];
if (!lastTrack) {
throw new Error("No tracks found");
}
this.logVerbose("Raw track data:", lastTrack);
const isNowPlaying = Boolean(lastTrack["@attr"]?.nowplaying);
const trackTimestamp = lastTrack.date?.uts
? parseInt(lastTrack.date.uts)
: Math.floor(Date.now() / 1000);
let duration: number | null = null;
let endTime: number | null = null;
// get track length if it's currently playing
if (isNowPlaying) {
async validateCredentials(): Promise<boolean> {
try {
const trackInfoParams = new URLSearchParams({
method: "track.getInfo",
track: lastTrack.name,
artist: lastTrack.artist.name,
api_key: currentSettings.apiKey,
format: "json",
});
if (!currentSettings.username || !currentSettings.apiKey) {
throw new Error("Username or API key not set");
}
const trackInfoUrl = `${Constants.SERVICES.lastfm.baseUrl}?${trackInfoParams}`;
const trackInfo: LastFMResponse =
const params = new URLSearchParams({
method: "user.getinfo",
user: currentSettings.username,
api_key: currentSettings.apiKey,
format: "json",
});
const url = `${Constants.SERVICES.lastfm.baseUrl}?${params}`;
await this.makeRequest(url);
this.log("Credentials validation successful");
return true;
} catch (error) {
this.logError("Credentials validation failed:", error);
return false;
}
}
async fetchLatestScrobble(): Promise<Track> {
try {
if (!currentSettings.username || !currentSettings.apiKey) {
throw new Error("Username or API key not set");
}
this.logVerbose(
"Fetching latest scrobble for user:",
currentSettings.username,
);
const params = new URLSearchParams({
method: "user.getrecenttracks",
user: currentSettings.username,
api_key: currentSettings.apiKey,
limit: "1",
extended: "1",
format: "json",
});
const url = `${Constants.SERVICES.lastfm.baseUrl}?${params}`;
const data: LastFMResponse = await this.makeRequest(url);
const lastTrack = data?.recenttracks?.track?.[0];
if (!lastTrack) {
throw new Error("No tracks found");
}
this.logVerbose("Raw track data:", lastTrack);
const isNowPlaying = Boolean(lastTrack["@attr"]?.nowplaying);
const trackTimestamp = lastTrack.date?.uts
? parseInt(lastTrack.date.uts)
: Math.floor(Date.now() / 1000);
let duration: number | null = null;
let endTime: number | null = null;
// get track length if it's currently playing
if (isNowPlaying) {
try {
const trackInfoParams = new URLSearchParams({
method: "track.getInfo",
track: lastTrack.name,
artist: lastTrack.artist.name,
api_key: currentSettings.apiKey,
format: "json",
});
const trackInfoUrl = `${Constants.SERVICES.lastfm.baseUrl}?${trackInfoParams}`;
const trackInfo: LastFMResponse =
await this.makeRequest(trackInfoUrl);
if (trackInfo?.track?.duration) {
duration = parseInt(trackInfo.track.duration);
if (duration > 0) {
// Last.fm gives duration in milliseconds, we want seconds
duration = Math.floor(duration / 1000);
endTime = trackTimestamp + duration;
if (trackInfo?.track?.duration) {
duration = parseInt(trackInfo.track.duration);
if (duration > 0) {
// Last.fm gives duration in milliseconds, we want seconds
duration = Math.floor(duration / 1000);
endTime = trackTimestamp + duration;
}
}
} catch (error) {
this.logVerbose("Failed to fetch track duration:", error);
}
}
}
const albumArt = this.processAlbumArt(
lastTrack.image?.find((img) => img.size === "large")?.["#text"],
);
const track: Track = {
name: lastTrack.name,
artist: lastTrack.artist.name,
album: lastTrack.album["#text"],
albumArt,
url: lastTrack.url,
date: lastTrack.date?.["#text"] ?? "now",
nowPlaying: isNowPlaying,
loved: lastTrack.loved === "1",
from: trackTimestamp,
to: endTime,
duration,
};
this.logVerbose("Processed track:", track);
this.log(
`${isNowPlaying ? "Now playing" : "Last played"}:`,
`${track.artist} - ${track.name}`,
);
return track;
} catch (error) {
this.logVerbose("Failed to fetch track duration:", error);
this.logError("Failed to fetch latest scrobble:", error);
throw error;
}
}
const albumArt = this.processAlbumArt(
lastTrack.image?.find((img) => img.size === "large")?.["#text"],
);
const track: Track = {
name: lastTrack.name,
artist: lastTrack.artist.name,
album: lastTrack.album["#text"],
albumArt,
url: lastTrack.url,
date: lastTrack.date?.["#text"] ?? "now",
nowPlaying: isNowPlaying,
loved: lastTrack.loved === "1",
from: trackTimestamp,
to: endTime,
duration,
};
this.logVerbose("Processed track:", track);
this.log(
`${isNowPlaying ? "Now playing" : "Last played"}:`,
`${track.artist} - ${track.name}`,
);
return track;
} catch (error) {
this.logError("Failed to fetch latest scrobble:", error);
throw error;
}
}
}

View file

@ -38,137 +38,137 @@ interface LibreFMTrack {
}
export class LibreFmService extends BaseService {
getServiceName(): string {
return "Libre.fm";
}
protected logVerbose(...args: any[]): void {
if (currentSettings.verboseLogging) {
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
getServiceName(): string {
return "Libre.fm";
}
}
async validateCredentials(): Promise<boolean> {
try {
const username = currentSettings.librefmUsername;
const apiKey = currentSettings.librefmApiKey;
if (!username || !apiKey) {
throw new Error("Username or API key not set for Libre.fm");
}
const params = new URLSearchParams({
method: "user.getinfo",
user: username,
api_key: apiKey,
format: "json",
});
const url = `${Constants.SERVICES.librefm.baseUrl}?${params}`;
await this.makeRequest(url);
this.log("Credentials validation successful");
return true;
} catch (error) {
this.logError("Credentials validation failed:", error);
return false;
protected logVerbose(...args: any[]): void {
if (currentSettings.verboseLogging) {
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
}
}
}
async fetchLatestScrobble(): Promise<Track> {
try {
const username = currentSettings.librefmUsername;
const apiKey = currentSettings.librefmApiKey;
if (!username || !apiKey) {
throw new Error("Username or API key not set for Libre.fm");
}
this.logVerbose("Fetching latest scrobble for user:", username);
const params = new URLSearchParams({
method: "user.getrecenttracks",
user: username,
api_key: apiKey,
limit: "1",
extended: "1",
format: "json",
});
const url = `${Constants.SERVICES.librefm.baseUrl}?${params}`;
const data: LibreFMResponse = await this.makeRequest(url);
const lastTrack = data?.recenttracks?.track?.[0];
if (!lastTrack) {
throw new Error("No tracks found");
}
this.logVerbose("Raw track data:", lastTrack);
const isNowPlaying = Boolean(lastTrack["@attr"]?.nowplaying);
const trackTimestamp = lastTrack.date?.uts
? parseInt(lastTrack.date.uts)
: Math.floor(Date.now() / 1000);
let duration: number | null = null;
let endTime: number | null = null;
// Fetch track duration if it's currently playing
if (isNowPlaying) {
async validateCredentials(): Promise<boolean> {
try {
const trackInfoParams = new URLSearchParams({
method: "track.getInfo",
track: lastTrack.name,
artist: lastTrack.artist.name,
api_key: apiKey,
format: "json",
});
const username = currentSettings.librefmUsername;
const apiKey = currentSettings.librefmApiKey;
const trackInfoUrl = `${Constants.SERVICES.librefm.baseUrl}?${trackInfoParams}`;
const trackInfo: LibreFMResponse =
if (!username || !apiKey) {
throw new Error("Username or API key not set for Libre.fm");
}
const params = new URLSearchParams({
method: "user.getinfo",
user: username,
api_key: apiKey,
format: "json",
});
const url = `${Constants.SERVICES.librefm.baseUrl}?${params}`;
await this.makeRequest(url);
this.log("Credentials validation successful");
return true;
} catch (error) {
this.logError("Credentials validation failed:", error);
return false;
}
}
async fetchLatestScrobble(): Promise<Track> {
try {
const username = currentSettings.librefmUsername;
const apiKey = currentSettings.librefmApiKey;
if (!username || !apiKey) {
throw new Error("Username or API key not set for Libre.fm");
}
this.logVerbose("Fetching latest scrobble for user:", username);
const params = new URLSearchParams({
method: "user.getrecenttracks",
user: username,
api_key: apiKey,
limit: "1",
extended: "1",
format: "json",
});
const url = `${Constants.SERVICES.librefm.baseUrl}?${params}`;
const data: LibreFMResponse = await this.makeRequest(url);
const lastTrack = data?.recenttracks?.track?.[0];
if (!lastTrack) {
throw new Error("No tracks found");
}
this.logVerbose("Raw track data:", lastTrack);
const isNowPlaying = Boolean(lastTrack["@attr"]?.nowplaying);
const trackTimestamp = lastTrack.date?.uts
? parseInt(lastTrack.date.uts)
: Math.floor(Date.now() / 1000);
let duration: number | null = null;
let endTime: number | null = null;
// Fetch track duration if it's currently playing
if (isNowPlaying) {
try {
const trackInfoParams = new URLSearchParams({
method: "track.getInfo",
track: lastTrack.name,
artist: lastTrack.artist.name,
api_key: apiKey,
format: "json",
});
const trackInfoUrl = `${Constants.SERVICES.librefm.baseUrl}?${trackInfoParams}`;
const trackInfo: LibreFMResponse =
await this.makeRequest(trackInfoUrl);
if (trackInfo?.track?.duration) {
duration = parseInt(trackInfo.track.duration);
if (duration > 0) {
// Duration is in milliseconds, convert to seconds
duration = Math.floor(duration / 1000);
endTime = trackTimestamp + duration;
if (trackInfo?.track?.duration) {
duration = parseInt(trackInfo.track.duration);
if (duration > 0) {
// Duration is in milliseconds, convert to seconds
duration = Math.floor(duration / 1000);
endTime = trackTimestamp + duration;
}
}
} catch (error) {
this.logVerbose("Failed to fetch track duration:", error);
}
}
}
const albumArt = this.processAlbumArt(
lastTrack.image?.find((img) => img.size === "large")?.["#text"],
);
const track: Track = {
name: lastTrack.name,
artist: lastTrack.artist.name,
album: lastTrack.album["#text"],
albumArt,
url: lastTrack.url,
date: lastTrack.date?.["#text"] ?? "now",
nowPlaying: isNowPlaying,
loved: lastTrack.loved === "1",
from: trackTimestamp,
to: endTime,
duration,
};
this.logVerbose("Processed track:", track);
this.log(
`${isNowPlaying ? "Now playing" : "Last played"}:`,
`${track.artist} - ${track.name}`,
);
return track;
} catch (error) {
this.logVerbose("Failed to fetch track duration:", error);
this.logError("Failed to fetch latest scrobble:", error);
throw error;
}
}
const albumArt = this.processAlbumArt(
lastTrack.image?.find((img) => img.size === "large")?.["#text"],
);
const track: Track = {
name: lastTrack.name,
artist: lastTrack.artist.name,
album: lastTrack.album["#text"],
albumArt,
url: lastTrack.url,
date: lastTrack.date?.["#text"] ?? "now",
nowPlaying: isNowPlaying,
loved: lastTrack.loved === "1",
from: trackTimestamp,
to: endTime,
duration,
};
this.logVerbose("Processed track:", track);
this.log(
`${isNowPlaying ? "Now playing" : "Last played"}:`,
`${track.artist} - ${track.name}`,
);
return track;
} catch (error) {
this.logError("Failed to fetch latest scrobble:", error);
throw error;
}
}
}

View file

@ -37,171 +37,171 @@ interface ListenBrainzPlayingNowResponse {
}
export class ListenBrainzService extends BaseService {
getServiceName(): string {
return "ListenBrainz";
}
protected logVerbose(...args: any[]): void {
if (currentSettings.verboseLogging) {
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
getServiceName(): string {
return "ListenBrainz";
}
}
async validateCredentials(): Promise<boolean> {
try {
const username = currentSettings.listenbrainzUsername;
const token = currentSettings.listenbrainzToken;
if (!username) {
throw new Error("Username not set for ListenBrainz");
}
// For ListenBrainz, we don't need a token to read public data
// But we can test the connection by fetching user listens
const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`;
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Token ${token}`;
}
await this.makeRequest(url, { headers });
this.log("Credentials validation successful");
return true;
} catch (error) {
this.logError("Credentials validation failed:", error);
return false;
}
}
async fetchLatestScrobble(): Promise<Track> {
try {
const username = currentSettings.listenbrainzUsername;
const token = currentSettings.listenbrainzToken;
if (!username) {
throw new Error("Username not set for ListenBrainz");
}
this.logVerbose("Fetching latest scrobble for user:", username);
// First check for currently playing track
let currentlyPlaying: ListenBrainzListen | null = null;
try {
const playingNowUrl = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/playing-now`;
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Token ${token}`;
protected logVerbose(...args: any[]): void {
if (currentSettings.verboseLogging) {
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
}
}
const playingNowData: ListenBrainzPlayingNowResponse =
async validateCredentials(): Promise<boolean> {
try {
const username = currentSettings.listenbrainzUsername;
const token = currentSettings.listenbrainzToken;
if (!username) {
throw new Error("Username not set for ListenBrainz");
}
// For ListenBrainz, we don't need a token to read public data
// But we can test the connection by fetching user listens
const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`;
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Token ${token}`;
}
await this.makeRequest(url, { headers });
this.log("Credentials validation successful");
return true;
} catch (error) {
this.logError("Credentials validation failed:", error);
return false;
}
}
async fetchLatestScrobble(): Promise<Track> {
try {
const username = currentSettings.listenbrainzUsername;
const token = currentSettings.listenbrainzToken;
if (!username) {
throw new Error("Username not set for ListenBrainz");
}
this.logVerbose("Fetching latest scrobble for user:", username);
// First check for currently playing track
let currentlyPlaying: ListenBrainzListen | null = null;
try {
const playingNowUrl = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/playing-now`;
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Token ${token}`;
}
const playingNowData: ListenBrainzPlayingNowResponse =
await this.makeRequest(playingNowUrl, { headers });
if (playingNowData.listens && playingNowData.listens.length > 0) {
currentlyPlaying = playingNowData.listens[0];
currentlyPlaying.playing_now = true;
}
} catch (error) {
this.logVerbose(
"No currently playing track or failed to fetch:",
error,
);
}
if (playingNowData.listens && playingNowData.listens.length > 0) {
currentlyPlaying = playingNowData.listens[0];
currentlyPlaying.playing_now = true;
}
} catch (error) {
this.logVerbose(
"No currently playing track or failed to fetch:",
error,
);
}
let latestListen: ListenBrainzListen;
let latestListen: ListenBrainzListen;
if (currentlyPlaying) {
latestListen = currentlyPlaying;
this.logVerbose("Using currently playing track");
} else {
// Fetch recent listens
const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`;
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Token ${token}`;
}
if (currentlyPlaying) {
latestListen = currentlyPlaying;
this.logVerbose("Using currently playing track");
} else {
// Fetch recent listens
const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`;
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Token ${token}`;
}
const data: ListenBrainzResponse = await this.makeRequest(url, {
headers,
});
const data: ListenBrainzResponse = await this.makeRequest(url, {
headers,
});
if (!data.listens || data.listens.length === 0) {
throw new Error("No listens found");
}
if (!data.listens || data.listens.length === 0) {
throw new Error("No listens found");
}
latestListen = data.listens[0];
this.logVerbose("Using latest completed listen");
}
latestListen = data.listens[0];
this.logVerbose("Using latest completed listen");
}
this.logVerbose("Raw listen data:", latestListen);
this.logVerbose("Raw listen data:", latestListen);
const isNowPlaying = Boolean(latestListen.playing_now);
const trackTimestamp =
const isNowPlaying = Boolean(latestListen.playing_now);
const trackTimestamp =
latestListen.listened_at || Math.floor(Date.now() / 1000);
let duration: number | null = null;
let endTime: number | null = null;
let duration: number | null = null;
let endTime: number | null = null;
// Extract duration from additional_info if available
if (latestListen.track_metadata.additional_info?.duration_ms) {
duration = Math.floor(
latestListen.track_metadata.additional_info.duration_ms / 1000,
);
if (isNowPlaying && duration > 0) {
endTime = trackTimestamp + duration;
}
}
// Extract duration from additional_info if available
if (latestListen.track_metadata.additional_info?.duration_ms) {
duration = Math.floor(
latestListen.track_metadata.additional_info.duration_ms / 1000,
);
if (isNowPlaying && duration > 0) {
endTime = trackTimestamp + duration;
}
}
// Try to get album art from MusicBrainz or other sources
const albumArt: string | null = null;
if (latestListen.track_metadata.additional_info?.release_mbid) {
try {
// We could fetch cover art from Cover Art Archive, but for now we'll leave it null
// albumArt = `https://coverartarchive.org/release/${latestListen.track_metadata.additional_info.release_mbid}/front`;
} catch (error) {
this.logVerbose("Failed to fetch album art:", error);
}
}
// Try to get album art from MusicBrainz or other sources
const albumArt: string | null = null;
if (latestListen.track_metadata.additional_info?.release_mbid) {
try {
// We could fetch cover art from Cover Art Archive, but for now we'll leave it null
// albumArt = `https://coverartarchive.org/release/${latestListen.track_metadata.additional_info.release_mbid}/front`;
} catch (error) {
this.logVerbose("Failed to fetch album art:", error);
}
}
const track: Track = {
name: latestListen.track_metadata.track_name,
artist: latestListen.track_metadata.artist_name,
album: latestListen.track_metadata.release_name || "",
albumArt,
url:
const track: Track = {
name: latestListen.track_metadata.track_name,
artist: latestListen.track_metadata.artist_name,
album: latestListen.track_metadata.release_name || "",
albumArt,
url:
latestListen.track_metadata.additional_info?.origin_url ||
`https://listenbrainz.org/user/${username}`,
date: isNowPlaying
? "now"
: new Date(trackTimestamp * 1000).toISOString(),
nowPlaying: isNowPlaying,
loved: false, // ListenBrainz doesn't have a direct "loved" concept
from: trackTimestamp,
to: endTime,
duration,
};
date: isNowPlaying
? "now"
: new Date(trackTimestamp * 1000).toISOString(),
nowPlaying: isNowPlaying,
loved: false, // ListenBrainz doesn't have a direct "loved" concept
from: trackTimestamp,
to: endTime,
duration,
};
this.logVerbose("Processed track:", track);
this.log(
`${isNowPlaying ? "Now playing" : "Last played"}:`,
`${track.artist} - ${track.name}`,
);
this.logVerbose("Processed track:", track);
this.log(
`${isNowPlaying ? "Now playing" : "Last played"}:`,
`${track.artist} - ${track.name}`,
);
return track;
} catch (error) {
this.logError("Failed to fetch latest listen:", error);
throw error;
return track;
} catch (error) {
this.logError("Failed to fetch latest listen:", error);
throw error;
}
}
}
protected getErrorMessage(error: any): string {
protected getErrorMessage(error: any): string {
// ListenBrainz uses different error format
if (error.error) {
return error.error;
if (error.error) {
return error.error;
}
if (error.message) {
return error.message;
}
return error.toString() || "Unknown error";
}
if (error.message) {
return error.message;
}
return error.toString() || "Unknown error";
}
}

View file

@ -5,91 +5,91 @@ import { LibreFmService } from "./LibreFmService";
import { ListenBrainzService } from "./ListenBrainzService";
export class ServiceFactory {
private static instance: ServiceFactory;
private serviceInstances: Map<ServiceType, ServiceClient>;
private static instance: ServiceFactory;
private serviceInstances: Map<ServiceType, ServiceClient>;
private constructor() {
private constructor() {
// singleton pattern
this.serviceInstances = new Map();
}
public static getInstance(): ServiceFactory {
if (!ServiceFactory.instance) {
ServiceFactory.instance = new ServiceFactory();
this.serviceInstances = new Map();
}
return ServiceFactory.instance;
}
public getService(serviceType?: ServiceType): ServiceClient {
public static getInstance(): ServiceFactory {
if (!ServiceFactory.instance) {
ServiceFactory.instance = new ServiceFactory();
}
return ServiceFactory.instance;
}
public getService(serviceType?: ServiceType): ServiceClient {
// make sure map is set up
if (!this.serviceInstances) {
this.serviceInstances = new Map();
if (!this.serviceInstances) {
this.serviceInstances = new Map();
}
const type = serviceType || currentSettings.service;
if (!type) {
throw new Error(
"[ServiceFactory] No service type specified and no default service configured",
);
}
if (!this.serviceInstances.has(type)) {
this.serviceInstances.set(type, this.createService(type));
}
return this.serviceInstances.get(type)!;
}
const type = serviceType || currentSettings.service;
if (!type) {
throw new Error(
"[ServiceFactory] No service type specified and no default service configured",
);
public getCurrentService(): ServiceClient {
return this.getService(currentSettings.service);
}
if (!this.serviceInstances.has(type)) {
this.serviceInstances.set(type, this.createService(type));
private createService(serviceType: ServiceType): ServiceClient {
switch (serviceType) {
case "lastfm":
return new LastFmService();
case "librefm":
return new LibreFmService();
case "listenbrainz":
return new ListenBrainzService();
default:
throw new Error(
`[ServiceFactory] Unknown service type: ${serviceType}`,
);
}
}
return this.serviceInstances.get(type)!;
}
public getCurrentService(): ServiceClient {
return this.getService(currentSettings.service);
}
private createService(serviceType: ServiceType): ServiceClient {
switch (serviceType) {
case "lastfm":
return new LastFmService();
case "librefm":
return new LibreFmService();
case "listenbrainz":
return new ListenBrainzService();
default:
throw new Error(
`[ServiceFactory] Unknown service type: ${serviceType}`,
);
public clearCache(): void {
if (this.serviceInstances) {
this.serviceInstances.clear();
} else {
this.serviceInstances = new Map();
}
}
}
public clearCache(): void {
if (this.serviceInstances) {
this.serviceInstances.clear();
} else {
this.serviceInstances = new Map();
public validateCurrentService(): Promise<boolean> {
return this.getCurrentService().validateCredentials();
}
}
public validateCurrentService(): Promise<boolean> {
return this.getCurrentService().validateCredentials();
}
public async testService(serviceType: ServiceType): Promise<boolean> {
try {
const service = this.getService(serviceType);
return await service.validateCredentials();
} catch (error) {
console.error(`[ServiceFactory] Failed to test ${serviceType}:`, error);
return false;
public async testService(serviceType: ServiceType): Promise<boolean> {
try {
const service = this.getService(serviceType);
return await service.validateCredentials();
} catch (error) {
console.error(`[ServiceFactory] Failed to test ${serviceType}:`, error);
return false;
}
}
}
public getSupportedServices(): ServiceType[] {
return ["lastfm", "librefm", "listenbrainz"];
}
public getSupportedServices(): ServiceType[] {
return ["lastfm", "librefm", "listenbrainz"];
}
public getServiceDisplayName(serviceType: ServiceType): string {
const service = this.getService(serviceType);
return service.getServiceName();
}
public getServiceDisplayName(serviceType: ServiceType): string {
const service = this.getService(serviceType);
return service.getServiceName();
}
}
// main instance
@ -98,6 +98,6 @@ export const serviceFactory = ServiceFactory.getInstance();
// shortcut functions
export const getCurrentService = () => serviceFactory.getCurrentService();
export const getService = (type?: ServiceType) =>
serviceFactory.getService(type);
serviceFactory.getService(type);
export const validateCurrentService = () =>
serviceFactory.validateCurrentService();
serviceFactory.validateCurrentService();

View file

@ -15,197 +15,197 @@ const bunny = window.bunny;
const tabsNavigationRef = bunny?.metro?.findByPropsLazy("getRootNavigationRef");
const settingConstants = bunny?.metro?.findByPropsLazy(
"SETTING_RENDERER_CONFIG",
"SETTING_RENDERER_CONFIG",
);
const SettingsOverviewScreen = bunny?.metro?.findByNameLazy(
"SettingsOverviewScreen",
false,
"SettingsOverviewScreen",
false,
);
function Section({ tabs }) {
const navigation = NavigationNative.useNavigation();
const navigation = NavigationNative.useNavigation();
return React.createElement(FormRow, {
label: tabs.title(),
leading: React.createElement(FormRow.Icon, { source: tabs.icon }),
trailing: React.createElement(React.Fragment, {}, [
tabs.trailing ? tabs.trailing() : null,
React.createElement(FormRow.Arrow, { key: "arrow" }),
]),
onPress: () => {
const Component = tabs.page;
navigation.navigate("VendettaCustomPage", {
title: tabs.title(),
render: () => React.createElement(Component),
});
},
});
return React.createElement(FormRow, {
label: tabs.title(),
leading: React.createElement(FormRow.Icon, { source: tabs.icon }),
trailing: React.createElement(React.Fragment, {}, [
tabs.trailing ? tabs.trailing() : null,
React.createElement(FormRow.Arrow, { key: "arrow" }),
]),
onPress: () => {
const Component = tabs.page;
navigation.navigate("VendettaCustomPage", {
title: tabs.title(),
render: () => React.createElement(Component),
});
},
});
}
function patchPanelUI(tabs, patches) {
try {
patches.push(
after(
"default",
bunny?.metro?.findByNameLazy("UserSettingsOverviewWrapper", false),
(_, ret) => {
const UserSettingsOverview = findInReactTree(
ret.props.children,
(n) => n.type?.name === "UserSettingsOverview",
);
if (UserSettingsOverview) {
patches.push(
after(
"render",
UserSettingsOverview.type.prototype,
(_args, res) => {
const sections = findInReactTree(
res.props.children,
(n) => n?.children?.[1]?.type === FormSection,
)?.children;
if (sections) {
const index = sections.findIndex((c) =>
["BILLING_SETTINGS", "PREMIUM_SETTINGS"].includes(
c?.props?.label,
),
try {
patches.push(
after(
"default",
bunny?.metro?.findByNameLazy("UserSettingsOverviewWrapper", false),
(_, ret) => {
const UserSettingsOverview = findInReactTree(
ret.props.children,
(n) => n.type?.name === "UserSettingsOverview",
);
sections.splice(
-~index || 4,
0,
React.createElement(Section, { key: tabs.key, tabs }),
);
}
if (UserSettingsOverview) {
patches.push(
after(
"render",
UserSettingsOverview.type.prototype,
(_args, res) => {
const sections = findInReactTree(
res.props.children,
(n) => n?.children?.[1]?.type === FormSection,
)?.children;
if (sections) {
const index = sections.findIndex((c) =>
["BILLING_SETTINGS", "PREMIUM_SETTINGS"].includes(
c?.props?.label,
),
);
sections.splice(
-~index || 4,
0,
React.createElement(Section, { key: tabs.key, tabs }),
);
}
},
),
);
}
},
),
);
}
},
true,
),
);
} catch (error) {
logger.info("Panel UI patch failed graciously 💔", error);
}
true,
),
);
} catch (error) {
logger.info("Panel UI patch failed graciously 💔", error);
}
}
function patchTabsUI(tabs, patches) {
if (!settingConstants || !SettingsOverviewScreen || !tabsNavigationRef) {
console.warn("[LastFm] Missing required constants for tabs UI patch");
return;
}
if (!settingConstants || !SettingsOverviewScreen || !tabsNavigationRef) {
console.warn("[LastFm] Missing required constants for tabs UI patch");
return;
}
const row = {};
row[tabs.key] = {
type: "pressable",
title: tabs.title,
icon: tabs.icon,
IconComponent:
const row = {};
row[tabs.key] = {
type: "pressable",
title: tabs.title,
icon: tabs.icon,
IconComponent:
tabs.icon &&
(() => {
const actualIconSource =
const actualIconSource =
typeof tabs.icon === "object" && tabs.icon.uri !== undefined
? tabs.icon.uri
: tabs.icon;
return React.createElement(TableRowIcon, { source: actualIconSource });
? tabs.icon.uri
: tabs.icon;
return React.createElement(TableRowIcon, { source: actualIconSource });
}),
usePredicate: tabs.predicate,
useTrailing: tabs.trailing,
onPress: () => {
const navigation = tabsNavigationRef.getRootNavigationRef();
const Component = tabs.page;
usePredicate: tabs.predicate,
useTrailing: tabs.trailing,
onPress: () => {
const navigation = tabsNavigationRef.getRootNavigationRef();
const Component = tabs.page;
navigation.navigate("VendettaCustomPage", {
title: tabs.title(),
render: () => React.createElement(Component),
});
},
withArrow: true,
};
navigation.navigate("VendettaCustomPage", {
title: tabs.title(),
render: () => React.createElement(Component),
});
},
withArrow: true,
};
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
enumerable: true,
configurable: true,
get: () => ({
...rendererConfigValue,
...row,
}),
set: (v) => (rendererConfigValue = v),
});
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
enumerable: true,
configurable: true,
get: () => ({
...rendererConfigValue,
...row,
}),
set: (v) => (rendererConfigValue = v),
});
const firstRender = Symbol("LastFm-pinToSettings");
const firstRender = Symbol("LastFm-pinToSettings");
patches.push(
after("default", SettingsOverviewScreen, (args, ret) => {
if (!args[0][firstRender]) {
args[0][firstRender] = true;
patches.push(
after("default", SettingsOverviewScreen, (args, ret) => {
if (!args[0][firstRender]) {
args[0][firstRender] = true;
const { sections } = findInReactTree(
ret,
(i) => i.props?.sections,
).props;
const { sections } = findInReactTree(
ret,
(i) => i.props?.sections,
).props;
const section = sections?.find((x) =>
["Bunny", "Revenge", "Kettu"].some(
(mod) => x.label === mod && x.title === mod,
),
);
const section = sections?.find((x) =>
["Bunny", "Revenge", "Kettu"].some(
(mod) => x.label === mod && x.title === mod,
),
);
if (section?.settings) {
section.settings = [...section.settings, tabs.key];
}
}
}),
);
if (section?.settings) {
section.settings = [...section.settings, tabs.key];
}
}
}),
);
}
function patchSettingsPin(tabs) {
const patches = [];
const patches = [];
let disabled = false;
let disabled = false;
const realPredicate = tabs.predicate || (() => true);
tabs.predicate = () => (disabled ? false : realPredicate());
const realPredicate = tabs.predicate || (() => true);
tabs.predicate = () => (disabled ? false : realPredicate());
patchPanelUI(tabs, patches);
patchTabsUI(tabs, patches);
patches.push(() => (disabled = true));
patchPanelUI(tabs, patches);
patchTabsUI(tabs, patches);
patches.push(() => (disabled = true));
return () => {
for (const x of patches) {
x();
}
};
return () => {
for (const x of patches) {
x();
}
};
}
export default function patchSidebar() {
// Check if sidebar option is enabled in plugin settings
if (!plugin.storage.addToSidebar) {
console.log("[Multi Scrobbler] Sidebar disabled in settings");
return () => {};
}
// Check if sidebar option is enabled in plugin settings
if (!plugin.storage.addToSidebar) {
console.log("[Multi Scrobbler] Sidebar disabled in settings");
return () => {};
}
console.log(
"[Multi Scrobbler] Patching sidebar using custom patchSettingsPin...",
);
console.log(
"[Multi Scrobbler] Patching sidebar using custom patchSettingsPin...",
);
try {
const unpatch = patchSettingsPin({
key: "LastFmScrobbler",
icon: getAssetIDByName("MusicIcon"),
title: () => "Multi Scrobbler",
predicate: () => plugin.storage.addToSidebar === true,
page: Settings,
});
try {
const unpatch = patchSettingsPin({
key: "LastFmScrobbler",
icon: getAssetIDByName("MusicIcon"),
title: () => "Multi Scrobbler",
predicate: () => plugin.storage.addToSidebar === true,
page: Settings,
});
console.log("[Multi Scrobbler] Successfully patched sidebar");
return unpatch;
} catch (error) {
console.error("[Multi Scrobbler] Failed to patch sidebar:", error);
return () => {};
}
console.log("[Multi Scrobbler] Successfully patched sidebar");
return unpatch;
} catch (error) {
console.error("[Multi Scrobbler] Failed to patch sidebar:", error);
return () => {};
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,68 +8,68 @@ import { AssetManager } from "../modules";
const log = (...args: any[]) => console.log("[Activity]", ...args);
const logError = (...args: any[]) =>
console.error("[Activity] Error:", ...args);
console.error("[Activity] Error:", ...args);
const logVerbose = (...args: any[]) => {
// just log everything for now since checking the setting is tricky here
try {
console.log("[Activity] Verbose:", ...args);
} catch {
console.log("[Activity] Verbose:", ...args);
}
// just log everything for now since checking the setting is tricky here
try {
console.log("[Activity] Verbose:", ...args);
} catch {
console.log("[Activity] Verbose:", ...args);
}
};
// Remove Discord activity
export function clearActivity() {
log("Clearing Discord activity");
return sendRequest(null);
log("Clearing Discord activity");
return sendRequest(null);
}
// Send activity to Discord or clear it
export function sendRequest(activity: Activity | null) {
if (pluginState.pluginStopped) {
logVerbose("Plugin is stopped, clearing activity");
stop();
activity = null;
}
if (pluginState.pluginStopped) {
logVerbose("Plugin is stopped, clearing activity");
stop();
activity = null;
}
pluginState.lastActivity = activity;
pluginState.lastActivity = activity;
logVerbose(
"Dispatching activity update:",
activity ? "Setting activity" : "Clearing activity",
);
logVerbose(
"Dispatching activity update:",
activity ? "Setting activity" : "Clearing activity",
);
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity: activity,
pid: 2312,
socketId: "Multi-Service-Scrobbler@Vendetta",
});
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity: activity,
pid: 2312,
socketId: "Multi-Service-Scrobbler@Vendetta",
});
if (activity) {
log(`Activity set: ${activity.details} - ${activity.state}`);
} else {
log("Activity cleared");
}
if (activity) {
log(`Activity set: ${activity.details} - ${activity.state}`);
} else {
log("Activity cleared");
}
}
// Get Discord asset for album art
export async function fetchAsset(
asset: string[],
appId: string = Constants.APPLICATION_ID,
asset: string[],
appId: string = Constants.APPLICATION_ID,
): Promise<string[]> {
if (!asset?.length) {
logVerbose("No assets to fetch");
return [];
}
if (!asset?.length) {
logVerbose("No assets to fetch");
return [];
}
try {
logVerbose(`Fetching ${asset.length} asset(s):`, asset);
const result = await AssetManager.fetchAssetIds(appId, asset);
logVerbose(`Successfully fetched ${result.length} asset(s)`);
return result;
} catch (error) {
logError("Failed to fetch assets:", error);
return [];
}
try {
logVerbose(`Fetching ${asset.length} asset(s):`, asset);
const result = await AssetManager.fetchAssetIds(appId, asset);
logVerbose(`Successfully fetched ${result.length} asset(s)`);
return result;
} catch (error) {
logError("Failed to fetch assets:", error);
return [];
}
}

View file

@ -30,181 +30,181 @@ debugInfo.serviceErrors = { lastfm: [], librefm: [], listenbrainz: [] };
debugInfo.apiCallCount = 0;
debugInfo.connectionAttempts = 0;
debugInfo.lastCredentialValidation = {
lastfm: false,
librefm: false,
listenbrainz: false,
lastfm: false,
librefm: false,
listenbrainz: false,
};
const log = (...args: any[]) => console.log("[Debug]", ...args);
const logError = (...args: any[]) => console.error("[Debug] Error:", ...args);
export function setDebugInfo(
key: keyof typeof debugInfo,
value: (typeof debugInfo)[typeof key],
key: keyof typeof debugInfo,
value: (typeof debugInfo)[typeof key],
) {
debugInfo[key] = value;
debugInfo[key] = value;
// log the important stuff
if (key === "lastError" && value) {
logError("Error recorded:", (value as Error).message);
} else if (key === "lastTrack" && value) {
log(
"Track updated:",
`${(value as Track).artist} - ${(value as Track).name}`,
);
} else if (key === "currentService" && value) {
log("Service changed to:", value);
}
// log the important stuff
if (key === "lastError" && value) {
logError("Error recorded:", (value as Error).message);
} else if (key === "lastTrack" && value) {
log(
"Track updated:",
`${(value as Track).artist} - ${(value as Track).name}`,
);
} else if (key === "currentService" && value) {
log("Service changed to:", value);
}
__forceUpdate?.();
__forceUpdate?.();
}
export function incrementApiCall() {
debugInfo.apiCallCount = (debugInfo.apiCallCount || 0) + 1;
log(`API call count: ${debugInfo.apiCallCount}`);
debugInfo.apiCallCount = (debugInfo.apiCallCount || 0) + 1;
log(`API call count: ${debugInfo.apiCallCount}`);
}
export function recordServiceError(service: ServiceType, error: string) {
debugInfo.serviceErrors = debugInfo.serviceErrors || {
lastfm: [],
librefm: [],
listenbrainz: [],
};
debugInfo.serviceErrors[service] = debugInfo.serviceErrors[service] || [];
debugInfo.serviceErrors[service].push(
`${new Date().toISOString()}: ${error}`,
);
debugInfo.serviceErrors = debugInfo.serviceErrors || {
lastfm: [],
librefm: [],
listenbrainz: [],
};
debugInfo.serviceErrors[service] = debugInfo.serviceErrors[service] || [];
debugInfo.serviceErrors[service].push(
`${new Date().toISOString()}: ${error}`,
);
// don't let error logs get too long
if (debugInfo.serviceErrors[service].length > 10) {
debugInfo.serviceErrors[service] =
// don't let error logs get too long
if (debugInfo.serviceErrors[service].length > 10) {
debugInfo.serviceErrors[service] =
debugInfo.serviceErrors[service].slice(-10);
}
}
logError(`${service} error:`, error);
__forceUpdate?.();
logError(`${service} error:`, error);
__forceUpdate?.();
}
export function recordCredentialValidation(
service: ServiceType,
isValid: boolean,
service: ServiceType,
isValid: boolean,
) {
debugInfo.lastCredentialValidation = debugInfo.lastCredentialValidation || {
lastfm: false,
librefm: false,
listenbrainz: false,
};
debugInfo.lastCredentialValidation[service] = isValid;
log(
`${service} credentials validation:`,
isValid ? "✅ Valid" : "❌ Invalid",
);
__forceUpdate?.();
debugInfo.lastCredentialValidation = debugInfo.lastCredentialValidation || {
lastfm: false,
librefm: false,
listenbrainz: false,
};
debugInfo.lastCredentialValidation[service] = isValid;
log(
`${service} credentials validation:`,
isValid ? "✅ Valid" : "❌ Invalid",
);
__forceUpdate?.();
}
export function recordSuccessfulUpdate() {
debugInfo.lastSuccessfulUpdate = new Date().toISOString();
log("Successful update recorded at:", debugInfo.lastSuccessfulUpdate);
__forceUpdate?.();
debugInfo.lastSuccessfulUpdate = new Date().toISOString();
log("Successful update recorded at:", debugInfo.lastSuccessfulUpdate);
__forceUpdate?.();
}
export function incrementConnectionAttempt() {
debugInfo.connectionAttempts = (debugInfo.connectionAttempts || 0) + 1;
log(`Connection attempt: ${debugInfo.connectionAttempts}`);
debugInfo.connectionAttempts = (debugInfo.connectionAttempts || 0) + 1;
log(`Connection attempt: ${debugInfo.connectionAttempts}`);
}
export function resetConnectionAttempts() {
debugInfo.connectionAttempts = 0;
log("Connection attempts reset");
debugInfo.connectionAttempts = 0;
log("Connection attempts reset");
}
export function logComponentMount(componentName: string) {
debugInfo.componentMountCount = (debugInfo.componentMountCount || 0) + 1;
log(
`Component mounted: ${componentName} (count: ${debugInfo.componentMountCount})`,
);
debugInfo.componentMountCount = (debugInfo.componentMountCount || 0) + 1;
log(
`Component mounted: ${componentName} (count: ${debugInfo.componentMountCount})`,
);
}
export function logComponentError(componentName: string, error: any) {
const errorMessage = `${componentName}: ${String(error)}`;
debugInfo.componentMountErrors = debugInfo.componentMountErrors || [];
debugInfo.componentMountErrors.push(errorMessage);
const errorMessage = `${componentName}: ${String(error)}`;
debugInfo.componentMountErrors = debugInfo.componentMountErrors || [];
debugInfo.componentMountErrors.push(errorMessage);
if (String(error).includes("Reanimated")) {
debugInfo.lastReanimatedError = errorMessage;
}
if (String(error).includes("Reanimated")) {
debugInfo.lastReanimatedError = errorMessage;
}
logError(`Component error in ${componentName}:`, error);
__forceUpdate?.();
logError(`Component error in ${componentName}:`, error);
__forceUpdate?.();
}
export function logNavigationError(error: any) {
debugInfo.lastNavigationError = String(error);
logError("Navigation error:", error);
__forceUpdate?.();
debugInfo.lastNavigationError = String(error);
logError("Navigation error:", error);
__forceUpdate?.();
}
export function incrementSettingsLoad() {
debugInfo.settingsLoadAttempts = (debugInfo.settingsLoadAttempts || 0) + 1;
log(`Settings load attempt: ${debugInfo.settingsLoadAttempts}`);
debugInfo.settingsLoadAttempts = (debugInfo.settingsLoadAttempts || 0) + 1;
log(`Settings load attempt: ${debugInfo.settingsLoadAttempts}`);
}
export function getDebugSummary(): string {
const summary = {
status: {
currentService: debugInfo.currentService || "unknown",
lastSuccessfulUpdate: debugInfo.lastSuccessfulUpdate || "never",
connectionAttempts: debugInfo.connectionAttempts || 0,
apiCallCount: debugInfo.apiCallCount || 0,
},
validation: debugInfo.lastCredentialValidation,
errors: {
totalComponentErrors: debugInfo.componentMountErrors?.length || 0,
lastError: debugInfo.lastError?.message || "none",
serviceErrors: Object.fromEntries(
Object.entries(debugInfo.serviceErrors || {}).map(
([service, errors]) => [service, errors.length],
),
),
},
lastTrack: debugInfo.lastTrack
? {
artist: debugInfo.lastTrack.artist,
name: debugInfo.lastTrack.name,
nowPlaying: debugInfo.lastTrack.nowPlaying,
service: debugInfo.currentService,
}
: null,
};
const summary = {
status: {
currentService: debugInfo.currentService || "unknown",
lastSuccessfulUpdate: debugInfo.lastSuccessfulUpdate || "never",
connectionAttempts: debugInfo.connectionAttempts || 0,
apiCallCount: debugInfo.apiCallCount || 0,
},
validation: debugInfo.lastCredentialValidation,
errors: {
totalComponentErrors: debugInfo.componentMountErrors?.length || 0,
lastError: debugInfo.lastError?.message || "none",
serviceErrors: Object.fromEntries(
Object.entries(debugInfo.serviceErrors || {}).map(
([service, errors]) => [service, errors.length],
),
),
},
lastTrack: debugInfo.lastTrack
? {
artist: debugInfo.lastTrack.artist,
name: debugInfo.lastTrack.name,
nowPlaying: debugInfo.lastTrack.nowPlaying,
service: debugInfo.currentService,
}
: null,
};
return JSON.stringify(summary, null, 2);
return JSON.stringify(summary, null, 2);
}
export function useDebugInfo(): string {
try {
[, __forceUpdate] = useReducer((x) => ~x, 0);
try {
[, __forceUpdate] = useReducer((x) => ~x, 0);
// add some extra runtime info
const runtimeInfo = {
...debugInfo,
timestamp: new Date().toISOString(),
reactVersion: "18.0.0", // we can't check the real version here
hasReanimatedError: !!debugInfo.lastReanimatedError,
totalErrors: debugInfo.componentMountErrors?.length || 0,
summary: getDebugSummary(),
};
// add some extra runtime info
const runtimeInfo = {
...debugInfo,
timestamp: new Date().toISOString(),
reactVersion: "18.0.0", // we can't check the real version here
hasReanimatedError: !!debugInfo.lastReanimatedError,
totalErrors: debugInfo.componentMountErrors?.length || 0,
summary: getDebugSummary(),
};
return JSON.stringify(runtimeInfo, null, 4);
} catch (error) {
logError("Error in useDebugInfo:", error);
return JSON.stringify(
{
error: "Failed to generate debug info",
errorMessage: String(error),
timestamp: new Date().toISOString(),
},
null,
4,
);
}
return JSON.stringify(runtimeInfo, null, 4);
} catch (error) {
logError("Error in useDebugInfo:", error);
return JSON.stringify(
{
error: "Failed to generate debug info",
errorMessage: String(error),
timestamp: new Date().toISOString(),
},
null,
4,
);
}
}

View file

@ -2,126 +2,126 @@
// Convert seconds to MM:SS format
export function formatTime(seconds: number): string {
if (!seconds || seconds < 0) return "0:00";
if (!seconds || seconds < 0) return "0:00";
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}
// Convert milliseconds to MM:SS format
export function formatTimeFromMs(milliseconds: number): string {
return formatTime(Math.floor(milliseconds / 1000));
return formatTime(Math.floor(milliseconds / 1000));
}
// Format duration with hours if it's a long track
export function formatDuration(seconds: number): string {
if (!seconds || seconds < 0) return "0:00";
if (!seconds || seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
}
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}
// Convert timestamp to ISO date string
export function timestampToISO(timestamp: number): string {
return new Date(timestamp * 1000).toISOString();
return new Date(timestamp * 1000).toISOString();
}
// Get current time as Unix timestamp
export function getCurrentTimestamp(): number {
return Math.floor(Date.now() / 1000);
return Math.floor(Date.now() / 1000);
}
// Figure out how far through a track we are (0-100%)
export function calculateProgress(
startTime: number,
endTime?: number,
currentTime: number = getCurrentTimestamp(),
startTime: number,
endTime?: number,
currentTime: number = getCurrentTimestamp(),
): number {
if (!endTime || endTime <= startTime) return 0;
if (!endTime || endTime <= startTime) return 0;
const elapsed = currentTime - startTime;
const total = endTime - startTime;
const elapsed = currentTime - startTime;
const total = endTime - startTime;
if (elapsed <= 0) return 0;
if (elapsed >= total) return 100;
if (elapsed <= 0) return 0;
if (elapsed >= total) return 100;
return Math.round((elapsed / total) * 100);
return Math.round((elapsed / total) * 100);
}
// How much time is left in the track
export function formatTimeRemaining(
startTime: number,
endTime: number,
currentTime: number = getCurrentTimestamp(),
startTime: number,
endTime: number,
currentTime: number = getCurrentTimestamp(),
): string {
const remaining = endTime - currentTime;
if (remaining <= 0) return "0:00";
const remaining = endTime - currentTime;
if (remaining <= 0) return "0:00";
return formatTime(remaining);
return formatTime(remaining);
}
// How much time has passed since the track started
export function formatElapsedTime(
startTime: number,
currentTime: number = getCurrentTimestamp(),
startTime: number,
currentTime: number = getCurrentTimestamp(),
): string {
const elapsed = currentTime - startTime;
if (elapsed <= 0) return "0:00";
const elapsed = currentTime - startTime;
if (elapsed <= 0) return "0:00";
return formatTime(elapsed);
return formatTime(elapsed);
}
// Check if timestamps make sense
export function validateTimestamps(
startTime: number,
endTime?: number,
startTime: number,
endTime?: number,
): boolean {
const now = getCurrentTimestamp();
const oneWeekAgo = now - 7 * 24 * 60 * 60; // 7 days ago
const twoHoursFuture = now + 2 * 60 * 60; // 2 hours in future (more lenient)
const now = getCurrentTimestamp();
const oneWeekAgo = now - 7 * 24 * 60 * 60; // 7 days ago
const twoHoursFuture = now + 2 * 60 * 60; // 2 hours in future (more lenient)
// Make sure start time isn't crazy old or in the future
// Allow some wiggle room for tracks that just started
const twoHoursAgo = now - 2 * 60 * 60;
if (startTime < oneWeekAgo || startTime > twoHoursFuture) {
// Make sure start time isn't crazy old or in the future
// Allow some wiggle room for tracks that just started
const twoHoursAgo = now - 2 * 60 * 60;
if (startTime < oneWeekAgo || startTime > twoHoursFuture) {
// give recent tracks some slack
if (startTime >= twoHoursAgo && startTime <= now + 300) {
// allow 5 minutes in the future for "now playing"
return true;
if (startTime >= twoHoursAgo && startTime <= now + 300) {
// allow 5 minutes in the future for "now playing"
return true;
}
return false;
}
return false;
}
// if we have an end time, make sure it makes sense
if (endTime !== undefined) {
if (endTime <= startTime) return false;
// if we have an end time, make sure it makes sense
if (endTime !== undefined) {
if (endTime <= startTime) return false;
const duration = endTime - startTime;
const maxDuration = 2 * 60 * 60; // 2 hours max - pretty generous
const duration = endTime - startTime;
const maxDuration = 2 * 60 * 60; // 2 hours max - pretty generous
if (duration > maxDuration) return false;
}
if (duration > maxDuration) return false;
}
return true;
return true;
}
// Turn timestamp into "2 minutes ago" style text
export function formatRelativeTime(timestamp: number): string {
const now = getCurrentTimestamp();
const diff = now - timestamp;
const now = getCurrentTimestamp();
const diff = now - timestamp;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)} days ago`;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)} days ago`;
return new Date(timestamp * 1000).toLocaleDateString();
return new Date(timestamp * 1000).toLocaleDateString();
}