diff --git a/plugins/multi-scrobbler/manifest.json b/plugins/multi-scrobbler/manifest.json new file mode 100644 index 0000000..d905b8d --- /dev/null +++ b/plugins/multi-scrobbler/manifest.json @@ -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" + } +} diff --git a/plugins/multi-scrobbler/src/changelog.ts b/plugins/multi-scrobbler/src/changelog.ts new file mode 100644 index 0000000..129eb2c --- /dev/null +++ b/plugins/multi-scrobbler/src/changelog.ts @@ -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"; diff --git a/plugins/multi-scrobbler/src/constants.ts b/plugins/multi-scrobbler/src/constants.ts new file mode 100644 index 0000000..832f0b7 --- /dev/null +++ b/plugins/multi-scrobbler/src/constants.ts @@ -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, + + // 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, +} as const; + +export default Constants; diff --git a/plugins/multi-scrobbler/src/index.tsx b/plugins/multi-scrobbler/src/index.tsx new file mode 100644 index 0000000..e2439dd --- /dev/null +++ b/plugins/multi-scrobbler/src/index.tsx @@ -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, +}; diff --git a/plugins/multi-scrobbler/src/manager.ts b/plugins/multi-scrobbler/src/manager.ts new file mode 100644 index 0000000..95a251f --- /dev/null +++ b/plugins/multi-scrobbler/src/manager.ts @@ -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(); diff --git a/plugins/multi-scrobbler/src/modules.ts b/plugins/multi-scrobbler/src/modules.ts new file mode 100644 index 0000000..9cb29da --- /dev/null +++ b/plugins/multi-scrobbler/src/modules.ts @@ -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; + [key: PropertyKey]: any; +}; + +export const SelfPresenceStore = findByStoreName("SelfPresenceStore") as { + getActivities: () => any[]; + findActivity: (filter: (activity) => boolean) => any; +}; + +export const UserStore = findByStoreName("UserStore"); diff --git a/plugins/multi-scrobbler/src/services/BaseService.ts b/plugins/multi-scrobbler/src/services/BaseService.ts new file mode 100644 index 0000000..3df5845 --- /dev/null +++ b/plugins/multi-scrobbler/src/services/BaseService.ts @@ -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; + abstract validateCredentials(): Promise; + 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 { + 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 { + 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; + } +} diff --git a/plugins/multi-scrobbler/src/services/LastFmService.ts b/plugins/multi-scrobbler/src/services/LastFmService.ts new file mode 100644 index 0000000..07ab059 --- /dev/null +++ b/plugins/multi-scrobbler/src/services/LastFmService.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/plugins/multi-scrobbler/src/services/LibreFmService.ts b/plugins/multi-scrobbler/src/services/LibreFmService.ts new file mode 100644 index 0000000..3ee57da --- /dev/null +++ b/plugins/multi-scrobbler/src/services/LibreFmService.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/plugins/multi-scrobbler/src/services/ListenBrainzService.ts b/plugins/multi-scrobbler/src/services/ListenBrainzService.ts new file mode 100644 index 0000000..ed32ec1 --- /dev/null +++ b/plugins/multi-scrobbler/src/services/ListenBrainzService.ts @@ -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 { + 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 = {}; + 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 { + 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 = {}; + 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 = {}; + 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"; + } +} diff --git a/plugins/multi-scrobbler/src/services/ServiceFactory.ts b/plugins/multi-scrobbler/src/services/ServiceFactory.ts new file mode 100644 index 0000000..34fa93e --- /dev/null +++ b/plugins/multi-scrobbler/src/services/ServiceFactory.ts @@ -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; + + 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 { + return this.getCurrentService().validateCredentials(); + } + + public async testService(serviceType: ServiceType): Promise { + 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(); diff --git a/plugins/multi-scrobbler/src/sidebar.tsx b/plugins/multi-scrobbler/src/sidebar.tsx new file mode 100644 index 0000000..9dfcbe9 --- /dev/null +++ b/plugins/multi-scrobbler/src/sidebar.tsx @@ -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 () => {}; + } +} diff --git a/plugins/multi-scrobbler/src/ui/ChangelogView.tsx b/plugins/multi-scrobbler/src/ui/ChangelogView.tsx new file mode 100644 index 0000000..c3c3344 --- /dev/null +++ b/plugins/multi-scrobbler/src/ui/ChangelogView.tsx @@ -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 ( + + + + { + clipboard.setString(currentVersion); + showToast( + "Version copied to clipboard", + getAssetIDByName("toast_copy_link"), + ); + }} + > + + Copy + + + } + /> + + + + {changelog.map((entry, index) => ( + + + + Version {entry.version} + + + {entry.date} + + + {entry.changes.map((change, changeIndex) => ( + + • {change} + + ))} + + ))} + + + + ); +} diff --git a/plugins/multi-scrobbler/src/ui/pages/Debug.tsx b/plugins/multi-scrobbler/src/ui/pages/Debug.tsx new file mode 100644 index 0000000..f4f36a8 --- /dev/null +++ b/plugins/multi-scrobbler/src/ui/pages/Debug.tsx @@ -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 ( + + + No debug information available + + + ); + } + + return ( + + + {debugInfo} + + + ); + } catch (error) { + logComponentError("Debug", error); + console.error("Debug component error:", error); + return ( + + + Error loading debug information: {String(error)} + + + ); + } +}); diff --git a/plugins/multi-scrobbler/src/ui/pages/Settings.tsx b/plugins/multi-scrobbler/src/ui/pages/Settings.tsx new file mode 100644 index 0000000..f833244 --- /dev/null +++ b/plugins/multi-scrobbler/src/ui/pages/Settings.tsx @@ -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 ( + + + + + { + set("username", v); + forceUpdate(); + }} + isClearable + /> + { + set("apiKey", v); + forceUpdate(); + }} + secureTextEntry={true} + isClearable + /> + + + + + } + onPress={testConnection} + /> + } + 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"), + ); + } + }} + /> + + + + + ); +} + +// 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 ( + + + + + { + set("librefmUsername", v); + forceUpdate(); + }} + isClearable + /> + { + set("librefmApiKey", v); + forceUpdate(); + }} + secureTextEntry={true} + isClearable + /> + + + + + } + onPress={testConnection} + /> + } + 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"), + ); + } + }} + /> + + + + + ); +} + +// 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 ( + + + + + { + set("listenbrainzUsername", v); + forceUpdate(); + }} + isClearable + /> + { + set("listenbrainzToken", v); + forceUpdate(); + }} + secureTextEntry={true} + isClearable + /> + + + + + } + onPress={testConnection} + /> + } + 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"), + ); + } + }} + /> + + + + + ); +} + +// Display Settings Page +function DisplaySettingsPage() { + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + + return ( + + + + + { + set("appName", v); + forceUpdate(); + }} + isClearable + /> + { + const interval = Number(v); + if (interval >= Constants.MIN_UPDATE_INTERVAL) { + set("timeInterval", interval); + forceUpdate(); + } + }} + keyboardType="numeric" + isClearable + /> + + + + + { + set("listeningTo", value); + forceUpdate(); + }} + /> + { + set("showTimestamp", value); + forceUpdate(); + }} + /> + { + set("ignoreSpotify", value); + forceUpdate(); + }} + /> + + + + + ); +} + +// Logging Settings Page +function LoggingSettingsPage() { + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + + return ( + + + + { + set("verboseLogging", value); + forceUpdate(); + }} + /> + + + + + + + + + + + + + + + + ); +} + +// 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 ( + + + {/* Service Selection */} + + + {(["lastfm", "librefm", "listenbrainz"] as ServiceType[]).map( + (service) => ( + { + if (value && service !== currentService) { + set("service", service); + forceUpdate(); + } + }} + /> + ), + )} + + + {/* Service Configuration */} + + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Last.fm Settings", + render: LastFmSettingsPage, + }) + } + /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Libre.fm Settings", + render: LibreFmSettingsPage, + }) + } + /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "ListenBrainz Settings", + render: ListenBrainzSettingsPage, + }) + } + /> + + + {/* Plugin Configuration */} + + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Display Settings", + render: DisplaySettingsPage, + }) + } + /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "Logging Settings", + render: LoggingSettingsPage, + }) + } + /> + { + set("addToSidebar", value); + forceUpdate(); + }} + /> + + + {/* About */} + + + + + + + + + ); +} diff --git a/plugins/multi-scrobbler/src/utils/activity.ts b/plugins/multi-scrobbler/src/utils/activity.ts new file mode 100644 index 0000000..2612ab6 --- /dev/null +++ b/plugins/multi-scrobbler/src/utils/activity.ts @@ -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 { + 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 []; + } +} diff --git a/plugins/multi-scrobbler/src/utils/debug.ts b/plugins/multi-scrobbler/src/utils/debug.ts new file mode 100644 index 0000000..de3ed30 --- /dev/null +++ b/plugins/multi-scrobbler/src/utils/debug.ts @@ -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; + apiCallCount?: number; + lastSuccessfulUpdate?: string; + currentService?: ServiceType; + connectionAttempts?: number; + lastCredentialValidation?: Record; +}; + +// 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, + ); + } +} diff --git a/plugins/multi-scrobbler/src/utils/time.ts b/plugins/multi-scrobbler/src/utils/time.ts new file mode 100644 index 0000000..4e28257 --- /dev/null +++ b/plugins/multi-scrobbler/src/utils/time.ts @@ -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(); +}