Add multi-service scrobbler plugin with Last.fm, Libre.fm, and

ListenBrainz support
This commit is contained in:
kmmiio99o 2025-10-04 16:25:19 +02:00
parent 19168b1238
commit a5d2b77f40
18 changed files with 2882 additions and 0 deletions

View 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"
}
}

View 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";

View 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;

View 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,
};

View 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();

View 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");

View 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;
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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";
}
}

View 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();

View 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 () => {};
}
}

View 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>
);
}

View 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>
);
}
});

View 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>
);
}

View 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 [];
}
}

View 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,
);
}
}

View 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();
}