fix lint and most of errors in terminal
This commit is contained in:
parent
511d9f8e93
commit
735f8aba4c
18 changed files with 3441 additions and 3439 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
package.json
27
package.json
|
|
@ -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
|
|
@ -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!! 🥺🥺🥺",
|
||||
[],
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue