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