Add multi-service scrobbler plugin with Last.fm, Libre.fm, and
ListenBrainz support
This commit is contained in:
parent
19168b1238
commit
a5d2b77f40
18 changed files with 2882 additions and 0 deletions
14
plugins/multi-scrobbler/manifest.json
Normal file
14
plugins/multi-scrobbler/manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "Multi Scrobbler",
|
||||||
|
"description": "Show off your music status from Last.fm, Libre.fm, or ListenBrainz on your Discord profile with comprehensive console logging.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "kmmiio99o",
|
||||||
|
"id": "879393496627306587"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main": "src/index.tsx",
|
||||||
|
"vendetta": {
|
||||||
|
"icon": "ic_stage_music"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
plugins/multi-scrobbler/src/changelog.ts
Normal file
61
plugins/multi-scrobbler/src/changelog.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
changes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const changelog: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "3.0.0",
|
||||||
|
date: "2025-01-21",
|
||||||
|
changes: [
|
||||||
|
"Complete plugin rewrite with multi-service support",
|
||||||
|
"Added support for Last.fm, Libre.fm, and ListenBrainz",
|
||||||
|
"Replaced toast notifications with comprehensive console logging",
|
||||||
|
"Enhanced timestamp handling with proper start/end times",
|
||||||
|
"Improved error handling and retry logic",
|
||||||
|
"Added service validation and connection testing",
|
||||||
|
"New settings UI with per-service configuration",
|
||||||
|
"Better credential management for multiple services",
|
||||||
|
"Enhanced verbose logging for debugging",
|
||||||
|
"Improved activity updates with service detection",
|
||||||
|
"Added dynamic app name with track variables",
|
||||||
|
"Better album art handling and fallbacks",
|
||||||
|
"Comprehensive status monitoring and reporting",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "2.3.0",
|
||||||
|
date: "2025-09-19",
|
||||||
|
changes: [
|
||||||
|
"1. I TRIED SO HARD 2. remove backend error 3. factor settings tab 4. factor V2 5. factor V3 6. factor V4 7. factor V5 8. factor V6 9. Full settings refactor :3",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "2.2.0",
|
||||||
|
date: "2025-09-13",
|
||||||
|
changes: ["Remember kids! Do not sniff crack!"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "2.0.0",
|
||||||
|
date: "2025-09-13",
|
||||||
|
changes: ["Fix settings scrolling"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.3.0",
|
||||||
|
date: "2025-09-13",
|
||||||
|
changes: ["fix reanimated"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.2.0",
|
||||||
|
date: "2025-09-13",
|
||||||
|
changes: ["test"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.1.0",
|
||||||
|
date: "2025-09-12",
|
||||||
|
changes: ["fixed reanimated issue??"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const currentVersion = "3.0.0";
|
||||||
79
plugins/multi-scrobbler/src/constants.ts
Normal file
79
plugins/multi-scrobbler/src/constants.ts
Normal file
|
|
@ -0,0 +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,
|
||||||
|
|
||||||
|
// 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: "lastfm" as ServiceType,
|
||||||
|
librefmUsername: "",
|
||||||
|
librefmApiKey: "",
|
||||||
|
listenbrainzUsername: "",
|
||||||
|
listenbrainzToken: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last.fm/Libre.fm API error codes
|
||||||
|
API_ERROR_CODES: {
|
||||||
|
2: "Invalid service",
|
||||||
|
3: "Invalid method",
|
||||||
|
4: "Invalid format",
|
||||||
|
5: "Invalid parameters",
|
||||||
|
6: "Invalid resource specified",
|
||||||
|
7: "Invalid session key",
|
||||||
|
8: "Invalid API key",
|
||||||
|
9: "Invalid session",
|
||||||
|
10: "Invalid API signature",
|
||||||
|
11: "Service offline",
|
||||||
|
13: "Invalid method signature supplied",
|
||||||
|
16: "Service temporarily unavailable",
|
||||||
|
26: "Suspended API key",
|
||||||
|
29: "Rate limit exceeded",
|
||||||
|
} as Record<number, string>,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default Constants;
|
||||||
220
plugins/multi-scrobbler/src/index.tsx
Normal file
220
plugins/multi-scrobbler/src/index.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { plugin } from "@vendetta";
|
||||||
|
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||||
|
|
||||||
|
import { LFMSettings } from "../../defs";
|
||||||
|
import Constants from "./constants";
|
||||||
|
import { initialize, stop, switchService } from "./manager";
|
||||||
|
import { UserStore } from "./modules";
|
||||||
|
import { serviceFactory } from "./services/ServiceFactory";
|
||||||
|
|
||||||
|
import Settings from "./ui/pages/Settings";
|
||||||
|
import patchSidebar from "./sidebar";
|
||||||
|
|
||||||
|
export const pluginState = {
|
||||||
|
pluginStopped: false,
|
||||||
|
lastActivity: undefined,
|
||||||
|
updateInterval: undefined,
|
||||||
|
lastTrackUrl: undefined,
|
||||||
|
} as {
|
||||||
|
pluginStopped: boolean;
|
||||||
|
lastActivity?: any;
|
||||||
|
updateInterval?: NodeJS.Timer;
|
||||||
|
lastTrackUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupFunctions: (() => void)[] = [];
|
||||||
|
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] ?? 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep track of connection attempts
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
const MAX_CONNECTION_ATTEMPTS = 3;
|
||||||
|
const RECONNECT_DELAY = 5000;
|
||||||
|
|
||||||
|
const log = (...args: any[]) => console.log("[Scrobble Plugin]", ...args);
|
||||||
|
const logError = (...args: any[]) =>
|
||||||
|
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++;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const serviceName = serviceFactory.getCurrentService().getServiceName();
|
||||||
|
|
||||||
|
// Make sure we have credentials for whatever service is selected
|
||||||
|
const service = currentSettings.service || "lastfm";
|
||||||
|
let hasCredentials = false;
|
||||||
|
|
||||||
|
switch (service) {
|
||||||
|
case "lastfm":
|
||||||
|
hasCredentials = !!(currentSettings.username && currentSettings.apiKey);
|
||||||
|
break;
|
||||||
|
case "librefm":
|
||||||
|
hasCredentials = !!(
|
||||||
|
(currentSettings.librefmUsername || currentSettings.username) &&
|
||||||
|
(currentSettings.librefmApiKey || currentSettings.apiKey)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "listenbrainz":
|
||||||
|
hasCredentials = !!(
|
||||||
|
currentSettings.listenbrainzUsername || currentSettings.username
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCredentials) {
|
||||||
|
logError(
|
||||||
|
`Missing credentials for ${serviceName}. Please configure in plugin settings.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
FluxDispatcher.subscribe("CONNECTION_OPEN", waitForUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onLoad() {
|
||||||
|
log("Plugin loading...");
|
||||||
|
pluginState.pluginStopped = false;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} 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) {
|
||||||
|
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} to ${newService}`);
|
||||||
|
await switchService(newService);
|
||||||
|
} else if (!pluginState.pluginStopped) {
|
||||||
|
// Just restart with the updated settings
|
||||||
|
log("Restarting with updated settings...");
|
||||||
|
await tryInitialize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDiscordReconnect() {
|
||||||
|
if (!pluginState.pluginStopped) {
|
||||||
|
log("Discord reconnected, reinitializing plugin...");
|
||||||
|
tryInitialize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: Settings,
|
||||||
|
};
|
||||||
412
plugins/multi-scrobbler/src/manager.ts
Normal file
412
plugins/multi-scrobbler/src/manager.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
import { currentSettings, pluginState } from ".";
|
||||||
|
import { Activity } from "../../defs";
|
||||||
|
import Constants from "./constants";
|
||||||
|
import { SelfPresenceStore } from "./modules";
|
||||||
|
import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
|
||||||
|
import {
|
||||||
|
setDebugInfo,
|
||||||
|
recordSuccessfulUpdate,
|
||||||
|
recordServiceError,
|
||||||
|
incrementApiCall,
|
||||||
|
} from "./utils/debug";
|
||||||
|
import { serviceFactory } from "./services/ServiceFactory";
|
||||||
|
import {
|
||||||
|
formatDuration,
|
||||||
|
validateTimestamps,
|
||||||
|
getCurrentTimestamp,
|
||||||
|
} from "./utils/time";
|
||||||
|
|
||||||
|
enum ActivityType {
|
||||||
|
PLAYING = 0,
|
||||||
|
STREAMING = 1,
|
||||||
|
LISTENING = 2,
|
||||||
|
COMPETING = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = (...message: any[]) => console.log("[Scrobble Plugin]", ...message);
|
||||||
|
const logError = (...message: any[]) =>
|
||||||
|
console.error("[Scrobble Plugin] Error:", ...message);
|
||||||
|
const logVerbose = (...message: any[]) =>
|
||||||
|
currentSettings.verboseLogging &&
|
||||||
|
console.log("[Scrobble Plugin] Verbose:", ...message);
|
||||||
|
|
||||||
|
class PluginManager {
|
||||||
|
private static instance: PluginManager;
|
||||||
|
private updateTimer?: any;
|
||||||
|
private reconnectTimer?: any;
|
||||||
|
private consecutiveFailures = 0;
|
||||||
|
private isReconnecting = false;
|
||||||
|
private currentActivity?: Activity;
|
||||||
|
private lastUpdateTime = 0;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// singleton pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): PluginManager {
|
||||||
|
if (!PluginManager.instance) {
|
||||||
|
PluginManager.instance = new PluginManager();
|
||||||
|
}
|
||||||
|
return PluginManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for new tracks and update Discord status when something changes
|
||||||
|
private async updateActivity() {
|
||||||
|
if (pluginState.pluginStopped) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
setDebugInfo("isSpotifyIgnored", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementApiCall();
|
||||||
|
const lastTrack = await serviceFactory
|
||||||
|
.getCurrentService()
|
||||||
|
.fetchLatestScrobble();
|
||||||
|
setDebugInfo("lastTrack", lastTrack);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// if the timestamp is way old, estimate when it started
|
||||||
|
if (startTime < now - 3600) {
|
||||||
|
// more than an hour old - probably wrong
|
||||||
|
if (lastTrack.duration && lastTrack.duration > 0) {
|
||||||
|
// guess we're about 10% in or 30 seconds, whatever's smaller
|
||||||
|
const estimatedElapsed = Math.min(lastTrack.duration * 0.1, 30);
|
||||||
|
startTime = now - estimatedElapsed;
|
||||||
|
} else {
|
||||||
|
startTime = now;
|
||||||
|
}
|
||||||
|
logVerbose("had to estimate start time");
|
||||||
|
}
|
||||||
|
|
||||||
|
activityTimestamps = {
|
||||||
|
start: startTime * 1000, // Discord wants milliseconds
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastTrack.to) {
|
||||||
|
activityTimestamps.end = lastTrack.to * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logVerbose(
|
||||||
|
`🎯 Preparing RPC update for: ${lastTrack.artist} - ${lastTrack.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const activity: Activity = {
|
||||||
|
name: currentSettings.appName || Constants.DEFAULT_APP_NAME,
|
||||||
|
flags: 0,
|
||||||
|
type: currentSettings.listeningTo
|
||||||
|
? ActivityType.LISTENING
|
||||||
|
: ActivityType.PLAYING,
|
||||||
|
details: lastTrack.name,
|
||||||
|
state: `by ${lastTrack.artist}`,
|
||||||
|
status_display_type: 1,
|
||||||
|
application_id: Constants.APPLICATION_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
// replace template variables in app name if user is using them
|
||||||
|
if (activity.name.includes("{{")) {
|
||||||
|
const variables = {
|
||||||
|
artist: lastTrack.artist,
|
||||||
|
name: lastTrack.name,
|
||||||
|
album: lastTrack.album,
|
||||||
|
service: serviceName,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
activity.name = activity.name.replace(
|
||||||
|
new RegExp(`{{${key}}}`, "g"),
|
||||||
|
value || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the timestamps we figured out
|
||||||
|
if (activityTimestamps) {
|
||||||
|
activity.timestamps = activityTimestamps;
|
||||||
|
|
||||||
|
logVerbose("Timestamps set:", {
|
||||||
|
start: new Date(activityTimestamps.start).toISOString(),
|
||||||
|
end: activityTimestamps.end
|
||||||
|
? new Date(activityTimestamps.end).toISOString()
|
||||||
|
: "none",
|
||||||
|
duration: lastTrack.duration
|
||||||
|
? formatDuration(lastTrack.duration)
|
||||||
|
: "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// set up album art and tooltip text
|
||||||
|
if (lastTrack.album || lastTrack.albumArt) {
|
||||||
|
const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : [];
|
||||||
|
const assets = await fetchAsset(assetUrls);
|
||||||
|
|
||||||
|
if (assets[0]) {
|
||||||
|
const largeText = lastTrack.album
|
||||||
|
? `on ${lastTrack.album}`
|
||||||
|
: `${lastTrack.artist} - ${lastTrack.name}`;
|
||||||
|
|
||||||
|
// include track length in the tooltip if we have it
|
||||||
|
const durationText =
|
||||||
|
currentSettings.showTimestamp && lastTrack.duration
|
||||||
|
? ` • ${formatDuration(lastTrack.duration)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
activity.assets = {
|
||||||
|
large_image: assets[0],
|
||||||
|
large_text: largeText + durationText,
|
||||||
|
};
|
||||||
|
|
||||||
|
logVerbose("Album art set:", assets[0]);
|
||||||
|
} else if (lastTrack.album) {
|
||||||
|
// no album art but we can still show the album name
|
||||||
|
activity.assets = {
|
||||||
|
large_text: `on ${lastTrack.album}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logVerbose("Setting Discord activity:", activity);
|
||||||
|
setDebugInfo("lastActivity", activity);
|
||||||
|
|
||||||
|
await sendRequest(activity);
|
||||||
|
pluginState.lastTrackUrl = lastTrack.url;
|
||||||
|
this.currentActivity = activity;
|
||||||
|
pluginState.lastActivity = activity;
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.lastUpdateTime = getCurrentTimestamp();
|
||||||
|
recordSuccessfulUpdate();
|
||||||
|
|
||||||
|
log(
|
||||||
|
`✅ RPC updated successfully: ${lastTrack.artist} - ${lastTrack.name}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logError("Update failed:", error);
|
||||||
|
recordServiceError(currentSettings.service, (error as Error).message);
|
||||||
|
this.handleError(error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: Error) {
|
||||||
|
this.consecutiveFailures++;
|
||||||
|
setDebugInfo("lastError", error);
|
||||||
|
|
||||||
|
logError(
|
||||||
|
`Failure ${this.consecutiveFailures}/${Constants.MAX_RETRY_ATTEMPTS}:`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
|
||||||
|
logError("Max retry attempts reached, initiating reconnection...");
|
||||||
|
this.startReconnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startReconnection() {
|
||||||
|
if (this.isReconnecting) return;
|
||||||
|
|
||||||
|
this.isReconnecting = true;
|
||||||
|
this.stopUpdates();
|
||||||
|
|
||||||
|
log("Starting reconnection process...");
|
||||||
|
this.reconnectTimer = setInterval(() => {
|
||||||
|
log("Attempting to reconnect...");
|
||||||
|
this.initialize() // don't reset state
|
||||||
|
.then(() => {
|
||||||
|
log("Reconnection successful!");
|
||||||
|
this.stopReconnection();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logError("Reconnection attempt failed:", error.message);
|
||||||
|
});
|
||||||
|
}, Constants.RETRY_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopReconnection() {
|
||||||
|
try {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearInterval(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = undefined;
|
||||||
|
}
|
||||||
|
this.isReconnecting = false;
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
} catch (error) {
|
||||||
|
// Silently handle reconnection cleanup errors
|
||||||
|
console.error("[Scrobble Plugin] Reconnection cleanup error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean up all timers
|
||||||
|
private stopUpdates() {
|
||||||
|
try {
|
||||||
|
if (this.updateTimer) {
|
||||||
|
clearInterval(this.updateTimer);
|
||||||
|
this.updateTimer = undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently handle timer cleanup errors
|
||||||
|
console.error("[Scrobble Plugin] Timer cleanup error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start everything up
|
||||||
|
public async initialize() {
|
||||||
|
if (pluginState.pluginStopped) {
|
||||||
|
throw new Error("Plugin is stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceName = serviceFactory.getCurrentService().getServiceName();
|
||||||
|
log(`Initializing with ${serviceName}...`);
|
||||||
|
|
||||||
|
// make sure credentials work before starting
|
||||||
|
try {
|
||||||
|
const isValid = await serviceFactory.validateCurrentService();
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(`Invalid credentials for ${serviceName}`);
|
||||||
|
}
|
||||||
|
log(`${serviceName} credentials validated successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Failed to validate ${serviceName} credentials:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopUpdates();
|
||||||
|
|
||||||
|
// check right away in case something is already playing
|
||||||
|
log("🎵 checking for already playing songs...");
|
||||||
|
// Don't reset track state - let updateActivity() handle whether to update
|
||||||
|
await this.updateActivity();
|
||||||
|
|
||||||
|
const interval = Math.max(
|
||||||
|
(Number(currentSettings.timeInterval) ||
|
||||||
|
Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
|
||||||
|
Constants.MIN_UPDATE_INTERVAL * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateTimer = setInterval(() => this.updateActivity(), interval);
|
||||||
|
log(
|
||||||
|
`Update timer started with interval: ${interval}ms (${interval / 1000}s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop everything and clean up
|
||||||
|
public stop() {
|
||||||
|
if (pluginState.pluginStopped) {
|
||||||
|
return; // already stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Stopping plugin...");
|
||||||
|
pluginState.pluginStopped = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.stopUpdates();
|
||||||
|
this.stopReconnection();
|
||||||
|
clearActivity();
|
||||||
|
// Don't reset track state on stop - only when actually switching services
|
||||||
|
log("Plugin stopped successfully");
|
||||||
|
} catch (error) {
|
||||||
|
// don't let cleanup errors break things
|
||||||
|
console.error("[Scrobble Plugin] Stop error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// change to a different scrobble service
|
||||||
|
public async switchService(newService: string) {
|
||||||
|
if (pluginState.pluginStopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Switching to ${newService}...`);
|
||||||
|
|
||||||
|
// stop what we're doing first
|
||||||
|
const wasRunning = !pluginState.pluginStopped;
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// clear cache so we get fresh service instances
|
||||||
|
serviceFactory.clearCache();
|
||||||
|
|
||||||
|
// Reset track state when switching services
|
||||||
|
pluginState.lastTrackUrl = undefined;
|
||||||
|
this.currentActivity = undefined;
|
||||||
|
this.lastUpdateTime = 0;
|
||||||
|
|
||||||
|
// start back up with the new service
|
||||||
|
if (wasRunning) {
|
||||||
|
pluginState.pluginStopped = false;
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError("Failed to switch service:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get info about what's currently happening
|
||||||
|
public getStatus() {
|
||||||
|
const serviceName = serviceFactory.getCurrentService().getServiceName();
|
||||||
|
return {
|
||||||
|
running: !pluginState.pluginStopped,
|
||||||
|
service: serviceName,
|
||||||
|
consecutiveFailures: this.consecutiveFailures,
|
||||||
|
isReconnecting: this.isReconnecting,
|
||||||
|
lastTrackUrl: pluginState.lastTrackUrl,
|
||||||
|
updateInterval: this.updateTimer ? "Active" : "Inactive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expose the manager functions
|
||||||
|
const manager = PluginManager.getInstance();
|
||||||
|
export const initialize = () => manager.initialize();
|
||||||
|
export const stop = () => manager.stop();
|
||||||
|
export const switchService = (service: string) =>
|
||||||
|
manager.switchService(service);
|
||||||
|
export const getStatus = () => manager.getStatus();
|
||||||
18
plugins/multi-scrobbler/src/modules.ts
Normal file
18
plugins/multi-scrobbler/src/modules.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||||
|
|
||||||
|
// We don't really *need* this module, BUT this module has to be initialized before we can dispatch LOCAL_ACTIVITY_UPDATE
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { SET_ACTIVITY } = findByProps("SET_ACTIVITY");
|
||||||
|
|
||||||
|
export const AssetManager = findByProps("getAssetIds") as {
|
||||||
|
getAssetIds: (appId: string, ids: string[]) => string[];
|
||||||
|
fetchAssetIds: (appId: string, ids: string[]) => Promise<string[]>;
|
||||||
|
[key: PropertyKey]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelfPresenceStore = findByStoreName("SelfPresenceStore") as {
|
||||||
|
getActivities: () => any[];
|
||||||
|
findActivity: (filter: (activity) => boolean) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserStore = findByStoreName("UserStore");
|
||||||
121
plugins/multi-scrobbler/src/services/BaseService.ts
Normal file
121
plugins/multi-scrobbler/src/services/BaseService.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { ServiceClient, Track } from "../../../defs";
|
||||||
|
import Constants from "../constants";
|
||||||
|
import { incrementApiCall, recordServiceError } from "../utils/debug";
|
||||||
|
|
||||||
|
export abstract class BaseService implements ServiceClient {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected logError(...args: any[]): void {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleError(error: any): Promise<never> {
|
||||||
|
this.lastError = error.error || 0;
|
||||||
|
|
||||||
|
const errorMessage = this.getErrorMessage(error);
|
||||||
|
this.logError(errorMessage);
|
||||||
|
recordServiceError(
|
||||||
|
this.getServiceName().toLowerCase() as any,
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async makeRequest(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<any> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(
|
||||||
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
recordServiceError(
|
||||||
|
this.getServiceName().toLowerCase() as any,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
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.getServiceName().toLowerCase() as any,
|
||||||
|
`Max retries exceeded: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isDefaultCover(cover?: string): boolean {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLastError(): number {
|
||||||
|
return this.lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetRetryCount(): void {
|
||||||
|
this.retryCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
plugins/multi-scrobbler/src/services/LastFmService.ts
Normal file
171
plugins/multi-scrobbler/src/services/LastFmService.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { Track } from "../../../defs";
|
||||||
|
import { currentSettings } from "..";
|
||||||
|
import Constants from "../constants";
|
||||||
|
import { BaseService } from "./BaseService";
|
||||||
|
|
||||||
|
interface LastFMResponse {
|
||||||
|
recenttracks?: {
|
||||||
|
track: LastFMTrack[];
|
||||||
|
};
|
||||||
|
track?: {
|
||||||
|
duration: string;
|
||||||
|
};
|
||||||
|
error?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LastFMTrack {
|
||||||
|
name: string;
|
||||||
|
artist: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
album: {
|
||||||
|
"#text": string;
|
||||||
|
};
|
||||||
|
image?: {
|
||||||
|
size: string;
|
||||||
|
"#text": string;
|
||||||
|
}[];
|
||||||
|
url: string;
|
||||||
|
date?: {
|
||||||
|
"#text": string;
|
||||||
|
uts: string;
|
||||||
|
};
|
||||||
|
"@attr"?: {
|
||||||
|
nowplaying: boolean;
|
||||||
|
};
|
||||||
|
loved: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LastFmService extends BaseService {
|
||||||
|
getServiceName(): string {
|
||||||
|
return "Last.fm";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected logVerbose(...args: any[]): void {
|
||||||
|
if (currentSettings.verboseLogging) {
|
||||||
|
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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.logError("Failed to fetch latest scrobble:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
plugins/multi-scrobbler/src/services/LibreFmService.ts
Normal file
168
plugins/multi-scrobbler/src/services/LibreFmService.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { Track } from "../../../defs";
|
||||||
|
import { currentSettings } from "..";
|
||||||
|
import Constants from "../constants";
|
||||||
|
import { BaseService } from "./BaseService";
|
||||||
|
|
||||||
|
interface LibreFMResponse {
|
||||||
|
recenttracks?: {
|
||||||
|
track: LibreFMTrack[];
|
||||||
|
};
|
||||||
|
track?: {
|
||||||
|
duration: string;
|
||||||
|
};
|
||||||
|
error?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibreFMTrack {
|
||||||
|
name: string;
|
||||||
|
artist: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
album: {
|
||||||
|
"#text": string;
|
||||||
|
};
|
||||||
|
image?: {
|
||||||
|
size: string;
|
||||||
|
"#text": string;
|
||||||
|
}[];
|
||||||
|
url: string;
|
||||||
|
date?: {
|
||||||
|
"#text": string;
|
||||||
|
uts: string;
|
||||||
|
};
|
||||||
|
"@attr"?: {
|
||||||
|
nowplaying: boolean;
|
||||||
|
};
|
||||||
|
loved: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LibreFmService extends BaseService {
|
||||||
|
getServiceName(): string {
|
||||||
|
return "Libre.fm";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected logVerbose(...args: any[]): void {
|
||||||
|
if (currentSettings.verboseLogging) {
|
||||||
|
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateCredentials(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const username = currentSettings.librefmUsername || currentSettings.username;
|
||||||
|
const apiKey = currentSettings.librefmApiKey || currentSettings.apiKey;
|
||||||
|
|
||||||
|
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 || currentSettings.username;
|
||||||
|
const apiKey = currentSettings.librefmApiKey || currentSettings.apiKey;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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.logError("Failed to fetch latest scrobble:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
plugins/multi-scrobbler/src/services/ListenBrainzService.ts
Normal file
192
plugins/multi-scrobbler/src/services/ListenBrainzService.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { Track } from "../../../defs";
|
||||||
|
import { currentSettings } from "..";
|
||||||
|
import Constants from "../constants";
|
||||||
|
import { BaseService } from "./BaseService";
|
||||||
|
|
||||||
|
interface ListenBrainzResponse {
|
||||||
|
listens?: ListenBrainzListen[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListenBrainzListen {
|
||||||
|
listened_at: number;
|
||||||
|
track_metadata: {
|
||||||
|
artist_name: string;
|
||||||
|
track_name: string;
|
||||||
|
release_name?: string;
|
||||||
|
additional_info?: {
|
||||||
|
duration_ms?: number;
|
||||||
|
artist_mbids?: string[];
|
||||||
|
release_mbid?: string;
|
||||||
|
recording_mbid?: string;
|
||||||
|
spotify_id?: string;
|
||||||
|
youtube_id?: string;
|
||||||
|
isrc?: string;
|
||||||
|
tags?: string[];
|
||||||
|
media_player?: string;
|
||||||
|
submission_client?: string;
|
||||||
|
music_service?: string;
|
||||||
|
origin_url?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
playing_now?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListenBrainzPlayingNowResponse {
|
||||||
|
listens: ListenBrainzListen[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListenBrainzService extends BaseService {
|
||||||
|
getServiceName(): string {
|
||||||
|
return "ListenBrainz";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected logVerbose(...args: any[]): void {
|
||||||
|
if (currentSettings.verboseLogging) {
|
||||||
|
console.log(`[${this.getServiceName()}] Verbose:`, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateCredentials(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const username = currentSettings.listenbrainzUsername || currentSettings.username;
|
||||||
|
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 || currentSettings.username;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ListenBrainzResponse = await this.makeRequest(url, { headers });
|
||||||
|
|
||||||
|
if (!data.listens || data.listens.length === 0) {
|
||||||
|
throw new Error("No listens found");
|
||||||
|
}
|
||||||
|
|
||||||
|
latestListen = data.listens[0];
|
||||||
|
this.logVerbose("Using latest completed listen");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logVerbose("Raw listen data:", latestListen);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getErrorMessage(error: any): string {
|
||||||
|
// ListenBrainz uses different error format
|
||||||
|
if (error.error) {
|
||||||
|
return error.error;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return error.toString() || "Unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
98
plugins/multi-scrobbler/src/services/ServiceFactory.ts
Normal file
98
plugins/multi-scrobbler/src/services/ServiceFactory.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { ServiceClient, ServiceType } from "../../../defs";
|
||||||
|
import { currentSettings } from "..";
|
||||||
|
import { LastFmService } from "./LastFmService";
|
||||||
|
import { LibreFmService } from "./LibreFmService";
|
||||||
|
import { ListenBrainzService } from "./ListenBrainzService";
|
||||||
|
|
||||||
|
export class ServiceFactory {
|
||||||
|
private static instance: ServiceFactory;
|
||||||
|
private serviceInstances: Map<ServiceType, ServiceClient>;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// singleton pattern
|
||||||
|
this.serviceInstances = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = serviceType || currentSettings.service || "lastfm";
|
||||||
|
|
||||||
|
if (!this.serviceInstances.has(type)) {
|
||||||
|
this.serviceInstances.set(type, this.createService(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
console.warn(
|
||||||
|
`[ServiceFactory] Unknown service type: ${serviceType}, falling back to Last.fm`,
|
||||||
|
);
|
||||||
|
return new LastFmService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCache(): void {
|
||||||
|
if (this.serviceInstances) {
|
||||||
|
this.serviceInstances.clear();
|
||||||
|
} else {
|
||||||
|
this.serviceInstances = new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getSupportedServices(): ServiceType[] {
|
||||||
|
return ["lastfm", "librefm", "listenbrainz"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getServiceDisplayName(serviceType: ServiceType): string {
|
||||||
|
const service = this.getService(serviceType);
|
||||||
|
return service.getServiceName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// main instance
|
||||||
|
export const serviceFactory = ServiceFactory.getInstance();
|
||||||
|
|
||||||
|
// shortcut functions
|
||||||
|
export const getCurrentService = () => serviceFactory.getCurrentService();
|
||||||
|
export const getService = (type?: ServiceType) =>
|
||||||
|
serviceFactory.getService(type);
|
||||||
|
export const validateCurrentService = () =>
|
||||||
|
serviceFactory.validateCurrentService();
|
||||||
211
plugins/multi-scrobbler/src/sidebar.tsx
Normal file
211
plugins/multi-scrobbler/src/sidebar.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { plugin } from "@vendetta";
|
||||||
|
import { React, NavigationNative } from "@vendetta/metro/common";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { after } from "@vendetta/patcher";
|
||||||
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import { findInReactTree } from "@vendetta/utils";
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
import { logger } from "@vendetta";
|
||||||
|
import Settings from "./ui/pages/Settings";
|
||||||
|
|
||||||
|
const { FormSection, FormRow } = Forms;
|
||||||
|
const { TableRowIcon } = findByProps("TableRowIcon");
|
||||||
|
|
||||||
|
const bunny = window.bunny;
|
||||||
|
|
||||||
|
const tabsNavigationRef = bunny?.metro?.findByPropsLazy("getRootNavigationRef");
|
||||||
|
const settingConstants = bunny?.metro?.findByPropsLazy(
|
||||||
|
"SETTING_RENDERER_CONFIG",
|
||||||
|
);
|
||||||
|
const SettingsOverviewScreen = bunny?.metro?.findByNameLazy(
|
||||||
|
"SettingsOverviewScreen",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
function Section({ tabs }) {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.splice(
|
||||||
|
-~index || 4,
|
||||||
|
0,
|
||||||
|
React.createElement(Section, { key: tabs.key, tabs }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {};
|
||||||
|
row[tabs.key] = {
|
||||||
|
type: "pressable",
|
||||||
|
title: tabs.title,
|
||||||
|
icon: tabs.icon,
|
||||||
|
IconComponent:
|
||||||
|
tabs.icon &&
|
||||||
|
(() => {
|
||||||
|
const actualIconSource =
|
||||||
|
typeof tabs.icon === "object" && tabs.icon.uri !== undefined
|
||||||
|
? 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;
|
||||||
|
|
||||||
|
navigation.navigate("VendettaCustomPage", {
|
||||||
|
title: tabs.title(),
|
||||||
|
render: () => React.createElement(Component),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
withArrow: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
|
||||||
|
|
||||||
|
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
...rendererConfigValue,
|
||||||
|
...row,
|
||||||
|
}),
|
||||||
|
set: (v) => (rendererConfigValue = v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRender = Symbol("LastFm-pinToSettings");
|
||||||
|
|
||||||
|
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 section = sections?.find((x) =>
|
||||||
|
["Bunny", "Revenge", "Kettu"].some(
|
||||||
|
(mod) => x.label === mod && x.title === mod,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (section?.settings) {
|
||||||
|
section.settings = [...section.settings, tabs.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSettingsPin(tabs) {
|
||||||
|
const patches = [];
|
||||||
|
|
||||||
|
let disabled = false;
|
||||||
|
|
||||||
|
const realPredicate = tabs.predicate || (() => true);
|
||||||
|
tabs.predicate = () => (disabled ? false : realPredicate());
|
||||||
|
|
||||||
|
patchPanelUI(tabs, patches);
|
||||||
|
patchTabsUI(tabs, patches);
|
||||||
|
patches.push(() => (disabled = true));
|
||||||
|
|
||||||
|
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 () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Multi Scrobbler] Successfully patched sidebar");
|
||||||
|
return unpatch;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Multi Scrobbler] Failed to patch sidebar:", error);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}
|
||||||
80
plugins/multi-scrobbler/src/ui/ChangelogView.tsx
Normal file
80
plugins/multi-scrobbler/src/ui/ChangelogView.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { clipboard } from "@vendetta/metro/common";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { Forms, General } from "@vendetta/ui/components";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { changelog, currentVersion } from "../changelog";
|
||||||
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
|
||||||
|
const { TableRowGroup, TableRow, Stack } = findByProps(
|
||||||
|
"TableSwitchRow",
|
||||||
|
"TableRowGroup",
|
||||||
|
"Stack",
|
||||||
|
"TableRow",
|
||||||
|
);
|
||||||
|
const { ScrollView } = General;
|
||||||
|
|
||||||
|
export default function ChangelogView() {
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
<TableRowGroup title="CURRENT VERSION">
|
||||||
|
<TableRow
|
||||||
|
label={currentVersion}
|
||||||
|
trailing={
|
||||||
|
<General.TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
clipboard.setString(currentVersion);
|
||||||
|
showToast(
|
||||||
|
"Version copied to clipboard",
|
||||||
|
getAssetIDByName("toast_copy_link"),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<General.Text style={{ color: "#72767d", fontSize: 16 }}>
|
||||||
|
Copy
|
||||||
|
</General.Text>
|
||||||
|
</General.TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="CHANGELOG">
|
||||||
|
{changelog.map((entry, index) => (
|
||||||
|
<Stack
|
||||||
|
key={index}
|
||||||
|
spacing={4}
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderBottomWidth: index === changelog.length - 1 ? 0 : 0.5,
|
||||||
|
borderBottomColor: "#292929",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<General.View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<General.Text style={{ color: "#fff", fontWeight: "bold" }}>
|
||||||
|
Version {entry.version}
|
||||||
|
</General.Text>
|
||||||
|
<General.Text style={{ color: "#72767d" }}>
|
||||||
|
{entry.date}
|
||||||
|
</General.Text>
|
||||||
|
</General.View>
|
||||||
|
{entry.changes.map((change, changeIndex) => (
|
||||||
|
<General.Text
|
||||||
|
key={changeIndex}
|
||||||
|
style={{ color: "#dcddde", marginLeft: 8, marginTop: 2 }}
|
||||||
|
>
|
||||||
|
• {change}
|
||||||
|
</General.Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
plugins/multi-scrobbler/src/ui/pages/Debug.tsx
Normal file
63
plugins/multi-scrobbler/src/ui/pages/Debug.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import { ScrollView, Text } from "react-native";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useDebugInfo,
|
||||||
|
logComponentMount,
|
||||||
|
logComponentError,
|
||||||
|
} from "../../utils/debug";
|
||||||
|
|
||||||
|
const { FormText } = Forms;
|
||||||
|
|
||||||
|
export default React.memo(function Debug() {
|
||||||
|
// Log component mount
|
||||||
|
useEffect(() => {
|
||||||
|
logComponentMount("Debug");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const debugInfo = useDebugInfo();
|
||||||
|
|
||||||
|
// Safety check for debugInfo
|
||||||
|
if (!debugInfo) {
|
||||||
|
logComponentError("Debug", "useDebugInfo returned null or undefined");
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
<FormText style={{ margin: 12 }}>
|
||||||
|
No debug information available
|
||||||
|
</FormText>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
style={{
|
||||||
|
margin: 12,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
backgroundColor: "#2f3136",
|
||||||
|
color: "#dcddde",
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{debugInfo}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logComponentError("Debug", error);
|
||||||
|
console.error("Debug component error:", error);
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
<FormText style={{ margin: 12, color: "#FF0000" }}>
|
||||||
|
Error loading debug information: {String(error)}
|
||||||
|
</FormText>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
562
plugins/multi-scrobbler/src/ui/pages/Settings.tsx
Normal file
562
plugins/multi-scrobbler/src/ui/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
import { plugin } from "@vendetta";
|
||||||
|
import { useProxy } from "@vendetta/storage";
|
||||||
|
import { React, ReactNative as RN } from "@vendetta/metro/common";
|
||||||
|
import { Linking } from "react-native";
|
||||||
|
import { NavigationNative } from "@vendetta/metro/common";
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
|
||||||
|
const { ScrollView } = findByProps("ScrollView");
|
||||||
|
const { TableRowGroup, TableSwitchRow, Stack, TableRow } = findByProps(
|
||||||
|
"TableSwitchRow",
|
||||||
|
"TableRowGroup",
|
||||||
|
"Stack",
|
||||||
|
"TableRow",
|
||||||
|
);
|
||||||
|
const { TextInput } = findByProps("TextInput");
|
||||||
|
|
||||||
|
import Constants from "../../constants";
|
||||||
|
import { serviceFactory } from "../../services/ServiceFactory";
|
||||||
|
import { ServiceType } from "../../../../defs";
|
||||||
|
|
||||||
|
plugin.storage.username ??= "";
|
||||||
|
plugin.storage.apiKey ??= "";
|
||||||
|
plugin.storage.appName ??= "Music";
|
||||||
|
plugin.storage.timeInterval ??= 5;
|
||||||
|
plugin.storage.showTimestamp ??= true;
|
||||||
|
plugin.storage.listeningTo ??= true;
|
||||||
|
plugin.storage.ignoreSpotify ??= true;
|
||||||
|
plugin.storage.verboseLogging ??= false;
|
||||||
|
plugin.storage.service ??= "lastfm";
|
||||||
|
plugin.storage.librefmUsername ??= "";
|
||||||
|
plugin.storage.librefmApiKey ??= "";
|
||||||
|
plugin.storage.listenbrainzUsername ??= "";
|
||||||
|
plugin.storage.listenbrainzToken ??= "";
|
||||||
|
plugin.storage.addToSidebar ??= true;
|
||||||
|
|
||||||
|
const get = (k: string, fallback: any = "") => plugin.storage[k] ?? fallback;
|
||||||
|
const set = (k: string, v: any) => (plugin.storage[k] = v);
|
||||||
|
|
||||||
|
// Last.fm Settings Page
|
||||||
|
function LastFmSettingsPage() {
|
||||||
|
useProxy(plugin.storage);
|
||||||
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
showToast("Testing Last.fm connection...", getAssetIDByName("ClockIcon"));
|
||||||
|
try {
|
||||||
|
const isValid = await serviceFactory.testService("lastfm");
|
||||||
|
if (isValid) {
|
||||||
|
showToast(
|
||||||
|
"✅ Last.fm connection successful!",
|
||||||
|
getAssetIDByName("CheckIcon"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast("❌ Last.fm connection failed", getAssetIDByName("XIcon"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast("❌ Connection error", getAssetIDByName("XIcon"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
<TableRowGroup title="Credentials">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Last.fm Username"
|
||||||
|
value={get("username")}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("username", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Last.fm API Key"
|
||||||
|
value={get("apiKey")}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("apiKey", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
secureTextEntry={true}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Actions">
|
||||||
|
<TableRow
|
||||||
|
label="Test Connection"
|
||||||
|
subLabel="Verify your Last.fm credentials"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={testConnection}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Get API Key"
|
||||||
|
subLabel="Create a Last.fm API key at last.fm/api/account/create"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await Linking.openURL("https://www.last.fm/api/account/create");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open Last.fm API URL:", error);
|
||||||
|
showToast(
|
||||||
|
"Failed to open web browser. Please visit: https://www.last.fm/api/account/create",
|
||||||
|
getAssetIDByName("XIcon"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
<RN.View style={{ height: 64 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Libre.fm Settings Page
|
||||||
|
function LibreFmSettingsPage() {
|
||||||
|
useProxy(plugin.storage);
|
||||||
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
showToast("Testing Libre.fm connection...", getAssetIDByName("ClockIcon"));
|
||||||
|
try {
|
||||||
|
const isValid = await serviceFactory.testService("librefm");
|
||||||
|
if (isValid) {
|
||||||
|
showToast(
|
||||||
|
"✅ Libre.fm connection successful!",
|
||||||
|
getAssetIDByName("CheckIcon"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast("❌ Libre.fm connection failed", getAssetIDByName("XIcon"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast("❌ Connection error", getAssetIDByName("XIcon"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
<TableRowGroup title="Credentials">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Libre.fm Username (optional, uses Last.fm if empty)"
|
||||||
|
value={get("librefmUsername")}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("librefmUsername", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Libre.fm API Key (optional, uses Last.fm if empty)"
|
||||||
|
value={get("librefmApiKey")}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("librefmApiKey", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
secureTextEntry={true}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Actions">
|
||||||
|
<TableRow
|
||||||
|
label="Test Connection"
|
||||||
|
subLabel="Verify your Libre.fm credentials"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={testConnection}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Get API Key"
|
||||||
|
subLabel="Create a Last.fm API key (compatible with Libre.fm)"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await Linking.openURL("https://www.last.fm/api/account/create");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open Last.fm API URL:", error);
|
||||||
|
showToast(
|
||||||
|
"Failed to open web browser. Please visit: https://www.last.fm/api/account/create",
|
||||||
|
getAssetIDByName("XIcon"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
<RN.View style={{ height: 64 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenBrainz Settings Page
|
||||||
|
function ListenBrainzSettingsPage() {
|
||||||
|
useProxy(plugin.storage);
|
||||||
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
showToast(
|
||||||
|
"Testing ListenBrainz connection...",
|
||||||
|
getAssetIDByName("ClockIcon"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const isValid = await serviceFactory.testService("listenbrainz");
|
||||||
|
if (isValid) {
|
||||||
|
showToast(
|
||||||
|
"✅ ListenBrainz connection successful!",
|
||||||
|
getAssetIDByName("CheckIcon"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
"❌ ListenBrainz connection failed",
|
||||||
|
getAssetIDByName("XIcon"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast("❌ Connection error", getAssetIDByName("XIcon"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
<TableRowGroup title="Credentials">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="ListenBrainz Username (optional, uses Last.fm if empty)"
|
||||||
|
value={get("listenbrainzUsername")}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("listenbrainzUsername", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="ListenBrainz Token (optional, for private profiles)"
|
||||||
|
value={get("listenbrainzToken")}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("listenbrainzToken", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
secureTextEntry={true}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Actions">
|
||||||
|
<TableRow
|
||||||
|
label="Test Connection"
|
||||||
|
subLabel="Verify your ListenBrainz credentials"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={testConnection}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Get User Token"
|
||||||
|
subLabel="Get your ListenBrainz user token at listenbrainz.org/settings/"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await Linking.openURL("https://listenbrainz.org/settings/");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Failed to open ListenBrainz settings URL:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
"Failed to open web browser. Please visit: https://listenbrainz.org/settings/",
|
||||||
|
getAssetIDByName("XIcon"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
<RN.View style={{ height: 64 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display Settings Page
|
||||||
|
function DisplaySettingsPage() {
|
||||||
|
useProxy(plugin.storage);
|
||||||
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
<TableRowGroup title="Activity Display">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<TextInput
|
||||||
|
placeholder={`App Name (Default: ${Constants.DEFAULT_SETTINGS.appName})`}
|
||||||
|
value={get("appName", Constants.DEFAULT_SETTINGS.appName)}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
set("appName", v);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder={`Update Interval (Default: ${Constants.DEFAULT_SETTINGS.timeInterval}s)`}
|
||||||
|
value={String(
|
||||||
|
get("timeInterval", Constants.DEFAULT_SETTINGS.timeInterval),
|
||||||
|
)}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
const interval = Number(v);
|
||||||
|
if (interval >= Constants.MIN_UPDATE_INTERVAL) {
|
||||||
|
set("timeInterval", interval);
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
keyboardType="numeric"
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Activity Options">
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Show as Listening"
|
||||||
|
subLabel="Display as 'Listening to' instead of 'Playing'"
|
||||||
|
value={get("listeningTo", Constants.DEFAULT_SETTINGS.listeningTo)}
|
||||||
|
onValueChange={(value: boolean) => {
|
||||||
|
set("listeningTo", value);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Show Timestamp"
|
||||||
|
subLabel="Display track progress and duration"
|
||||||
|
value={get(
|
||||||
|
"showTimestamp",
|
||||||
|
Constants.DEFAULT_SETTINGS.showTimestamp,
|
||||||
|
)}
|
||||||
|
onValueChange={(value: boolean) => {
|
||||||
|
set("showTimestamp", value);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Ignore Spotify"
|
||||||
|
subLabel="Don't show activity when Spotify is playing"
|
||||||
|
value={get(
|
||||||
|
"ignoreSpotify",
|
||||||
|
Constants.DEFAULT_SETTINGS.ignoreSpotify,
|
||||||
|
)}
|
||||||
|
onValueChange={(value: boolean) => {
|
||||||
|
set("ignoreSpotify", value);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
<RN.View style={{ height: 64 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging Settings Page
|
||||||
|
function LoggingSettingsPage() {
|
||||||
|
useProxy(plugin.storage);
|
||||||
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
<TableRowGroup title="Logging Options">
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Verbose Logging"
|
||||||
|
subLabel="Enable detailed console logging for debugging"
|
||||||
|
value={get(
|
||||||
|
"verboseLogging",
|
||||||
|
Constants.DEFAULT_SETTINGS.verboseLogging,
|
||||||
|
)}
|
||||||
|
onValueChange={(value: boolean) => {
|
||||||
|
set("verboseLogging", value);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Debug Information">
|
||||||
|
<TableRow
|
||||||
|
label="Console Logging"
|
||||||
|
subLabel="Logs are written to the browser/app console when verbose is enabled"
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Error Tracking"
|
||||||
|
subLabel="Connection errors and API failures are automatically logged"
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Log Information">
|
||||||
|
<TableRow
|
||||||
|
label="API Calls"
|
||||||
|
subLabel="All API requests are logged when verbose is enabled"
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Track Updates"
|
||||||
|
subLabel="Song changes and RPC updates are logged"
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Error Details"
|
||||||
|
subLabel="Connection errors and retries are logged"
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
<RN.View style={{ height: 64 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Settings Page
|
||||||
|
export default function Settings() {
|
||||||
|
useProxy(plugin.storage);
|
||||||
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const currentService = get("service", "lastfm") as ServiceType;
|
||||||
|
|
||||||
|
const getCredentialStatus = (service: ServiceType) => {
|
||||||
|
switch (service) {
|
||||||
|
case "lastfm": {
|
||||||
|
return get("username") && get("apiKey")
|
||||||
|
? "✅ Configured"
|
||||||
|
: "❌ Missing credentials";
|
||||||
|
}
|
||||||
|
case "librefm": {
|
||||||
|
const librefmUser = get("librefmUsername") || get("username");
|
||||||
|
const librefmKey = get("librefmApiKey") || get("apiKey");
|
||||||
|
return librefmUser && librefmKey
|
||||||
|
? "✅ Configured"
|
||||||
|
: "❌ Missing credentials";
|
||||||
|
}
|
||||||
|
case "listenbrainz": {
|
||||||
|
const lbUser = get("listenbrainzUsername") || get("username");
|
||||||
|
return lbUser ? "✅ Configured" : "❌ Missing username";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "❓ Unknown";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||||
|
<Stack spacing={8}>
|
||||||
|
{/* Service Selection */}
|
||||||
|
<TableRowGroup title="Active Service">
|
||||||
|
<TableRow
|
||||||
|
label="Current Service"
|
||||||
|
subLabel={`Using: ${serviceFactory.getServiceDisplayName(currentService)}`}
|
||||||
|
/>
|
||||||
|
{(["lastfm", "librefm", "listenbrainz"] as ServiceType[]).map(
|
||||||
|
(service) => (
|
||||||
|
<TableSwitchRow
|
||||||
|
key={service}
|
||||||
|
label={serviceFactory.getServiceDisplayName(service)}
|
||||||
|
subLabel={getCredentialStatus(service)}
|
||||||
|
value={currentService === service}
|
||||||
|
onValueChange={(value: boolean) => {
|
||||||
|
if (value && service !== currentService) {
|
||||||
|
set("service", service);
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
{/* Service Configuration */}
|
||||||
|
<TableRowGroup title="Service Configuration">
|
||||||
|
<TableRow
|
||||||
|
label="Last.fm Settings"
|
||||||
|
subLabel="Configure Last.fm credentials and options"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.push("VendettaCustomPage", {
|
||||||
|
title: "Last.fm Settings",
|
||||||
|
render: LastFmSettingsPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Libre.fm Settings"
|
||||||
|
subLabel="Configure Libre.fm credentials and options"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.push("VendettaCustomPage", {
|
||||||
|
title: "Libre.fm Settings",
|
||||||
|
render: LibreFmSettingsPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="ListenBrainz Settings"
|
||||||
|
subLabel="Configure ListenBrainz credentials and options"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.push("VendettaCustomPage", {
|
||||||
|
title: "ListenBrainz Settings",
|
||||||
|
render: ListenBrainzSettingsPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
{/* Plugin Configuration */}
|
||||||
|
<TableRowGroup title="Plugin Configuration">
|
||||||
|
<TableRow
|
||||||
|
label="Display Settings"
|
||||||
|
subLabel="Customize how music activity appears"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.push("VendettaCustomPage", {
|
||||||
|
title: "Display Settings",
|
||||||
|
render: DisplaySettingsPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Logging Settings"
|
||||||
|
subLabel="Configure logging and debugging options"
|
||||||
|
trailing={<TableRow.Arrow />}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.push("VendettaCustomPage", {
|
||||||
|
title: "Logging Settings",
|
||||||
|
render: LoggingSettingsPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Add to Sidebar"
|
||||||
|
subLabel="Show plugin in Discord settings"
|
||||||
|
value={get("addToSidebar", false)}
|
||||||
|
onValueChange={(value: boolean) => {
|
||||||
|
set("addToSidebar", value);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<TableRowGroup title="About">
|
||||||
|
<TableRow
|
||||||
|
label="Multi Scrobbler"
|
||||||
|
subLabel="Show off your music status from multiple services"
|
||||||
|
/>
|
||||||
|
<TableRow label="Author" subLabel="kmmiio99o" />
|
||||||
|
<TableRow label="Version" subLabel="1.0.0" />
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
<RN.View style={{ height: 64 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
plugins/multi-scrobbler/src/utils/activity.ts
Normal file
75
plugins/multi-scrobbler/src/utils/activity.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||||
|
|
||||||
|
import { pluginState } from "..";
|
||||||
|
import { Activity } from "../../../defs";
|
||||||
|
import Constants from "../constants";
|
||||||
|
import { stop } from "../manager";
|
||||||
|
import { AssetManager } from "../modules";
|
||||||
|
|
||||||
|
const log = (...args: any[]) => console.log("[Activity]", ...args);
|
||||||
|
const logError = (...args: any[]) =>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove Discord activity
|
||||||
|
export function clearActivity() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginState.lastActivity = 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",
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
): Promise<string[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
210
plugins/multi-scrobbler/src/utils/debug.ts
Normal file
210
plugins/multi-scrobbler/src/utils/debug.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { useReducer } from "react";
|
||||||
|
import { Activity, Track, ServiceType } from "../../../defs";
|
||||||
|
|
||||||
|
let __forceUpdate: () => void;
|
||||||
|
|
||||||
|
const debugInfo = {} as {
|
||||||
|
lastActivity?: Activity;
|
||||||
|
lastTrack?: Track;
|
||||||
|
lastAPIResponse?: any;
|
||||||
|
isSpotifyIgnored?: boolean;
|
||||||
|
componentMountErrors?: string[];
|
||||||
|
lastReanimatedError?: string;
|
||||||
|
componentMountCount?: number;
|
||||||
|
settingsLoadAttempts?: number;
|
||||||
|
lastNavigationError?: string;
|
||||||
|
lastError?: Error;
|
||||||
|
serviceErrors?: Record<ServiceType, string[]>;
|
||||||
|
apiCallCount?: number;
|
||||||
|
lastSuccessfulUpdate?: string;
|
||||||
|
currentService?: ServiceType;
|
||||||
|
connectionAttempts?: number;
|
||||||
|
lastCredentialValidation?: Record<ServiceType, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// set up debug tracking
|
||||||
|
debugInfo.componentMountErrors = [];
|
||||||
|
debugInfo.componentMountCount = 0;
|
||||||
|
debugInfo.settingsLoadAttempts = 0;
|
||||||
|
debugInfo.serviceErrors = { lastfm: [], librefm: [], listenbrainz: [] };
|
||||||
|
debugInfo.apiCallCount = 0;
|
||||||
|
debugInfo.connectionAttempts = 0;
|
||||||
|
debugInfo.lastCredentialValidation = {
|
||||||
|
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],
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
__forceUpdate?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementApiCall() {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordCredentialValidation(
|
||||||
|
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?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSuccessfulUpdate() {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetConnectionAttempts() {
|
||||||
|
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})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logComponentError(componentName: string, error: any) {
|
||||||
|
const errorMessage = `${componentName}: ${String(error)}`;
|
||||||
|
debugInfo.componentMountErrors = debugInfo.componentMountErrors || [];
|
||||||
|
debugInfo.componentMountErrors.push(errorMessage);
|
||||||
|
|
||||||
|
if (String(error).includes("Reanimated")) {
|
||||||
|
debugInfo.lastReanimatedError = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(`Component error in ${componentName}:`, error);
|
||||||
|
__forceUpdate?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logNavigationError(error: any) {
|
||||||
|
debugInfo.lastNavigationError = String(error);
|
||||||
|
logError("Navigation error:", error);
|
||||||
|
__forceUpdate?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementSettingsLoad() {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(summary, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDebugInfo(): string {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
plugins/multi-scrobbler/src/utils/time.ts
Normal file
127
plugins/multi-scrobbler/src/utils/time.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
// Time helpers for the scrobble plugin
|
||||||
|
|
||||||
|
// Convert seconds to MM:SS format
|
||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
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")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert milliseconds to MM:SS format
|
||||||
|
export function formatTimeFromMs(milliseconds: number): string {
|
||||||
|
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";
|
||||||
|
|
||||||
|
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")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current time as Unix timestamp
|
||||||
|
export function getCurrentTimestamp(): number {
|
||||||
|
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(),
|
||||||
|
): number {
|
||||||
|
if (!endTime || endTime <= startTime) return 0;
|
||||||
|
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const total = endTime - startTime;
|
||||||
|
|
||||||
|
if (elapsed <= 0) return 0;
|
||||||
|
if (elapsed >= total) return 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(),
|
||||||
|
): string {
|
||||||
|
const remaining = endTime - currentTime;
|
||||||
|
if (remaining <= 0) return "0:00";
|
||||||
|
|
||||||
|
return formatTime(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How much time has passed since the track started
|
||||||
|
export function formatElapsedTime(
|
||||||
|
startTime: number,
|
||||||
|
currentTime: number = getCurrentTimestamp(),
|
||||||
|
): string {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
if (elapsed <= 0) return "0:00";
|
||||||
|
|
||||||
|
return formatTime(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if timestamps make sense
|
||||||
|
export function validateTimestamps(
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
if (duration > maxDuration) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn timestamp into "2 minutes ago" style text
|
||||||
|
export function formatRelativeTime(timestamp: number): string {
|
||||||
|
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`;
|
||||||
|
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString();
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue