import { malojaSettings } from './storage.js'; import { lastFMStorage } from './storage.js'; export class MalojaScrobbler { constructor() { this.currentTrack = null; this.scrobbleTimer = null; this.scrobbleThreshold = 0; this.hasScrobbled = false; this.isScrobbling = false; } getApiUrl() { const customUrl = malojaSettings.getCustomUrl(); // Remove trailing slash if present return customUrl ? customUrl.replace(/\/$/, '') : ''; } isEnabled() { return malojaSettings.isEnabled() && !!malojaSettings.getToken() && !!this.getApiUrl(); } getApiKey() { return malojaSettings.getToken(); } _getScrobbleArtist(track) { if (!track) return 'Unknown Artist'; let artistName = 'Unknown Artist'; if (track.artist?.name) { artistName = track.artist.name; } else if (typeof track.artist === 'string') { artistName = track.artist; } else if (track.artists && track.artists.length > 0) { const first = track.artists[0]; artistName = typeof first === 'string' ? first : first.name || 'Unknown Artist'; } if (typeof artistName !== 'string') return 'Unknown Artist'; artistName = artistName .split(/\s*[&]\s*|\s+feat\.?\s+|\s+ft\.?\s+|\s+featuring\s+|\s+with\s+|\s+x\s+/i)[0] .trim(); return artistName || 'Unknown Artist'; } async submitScrobble(track, timestamp = null) { if (!this.isEnabled()) return; const apiUrl = this.getApiUrl(); const apiKey = this.getApiKey(); if (!apiUrl || !apiKey) return; const artist = this._getScrobbleArtist(track); const title = track.cleanTitle || track.title; // Build the scrobble data const scrobbleData = { artist: artist, title: title, key: apiKey, }; if (track.album?.title) { scrobbleData.album = track.album.title; } if (track.duration) { scrobbleData.duration = Math.floor(track.duration); } if (track.trackNumber) { scrobbleData.track_number = track.trackNumber; } if (timestamp) { scrobbleData.time = timestamp; } try { // Try the newer Maloja API format first (mlj_1) let response = await fetch(`${apiUrl}/apis/mlj_1/newscrobble`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams(scrobbleData), }); if (!response.ok) { // Fallback to older API format response = await fetch(`${apiUrl}/apis/native/newscrobble`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams(scrobbleData), }); } if (!response.ok) { const text = await response.text(); throw new Error(`Maloja API Error ${response.status}: ${text}`); } console.log(`[Maloja] Submitted scrobble: ${title} by ${artist}`); } catch (error) { console.error('[Maloja] Submission failed:', error); } } async updateNowPlaying(track) { if (!this.isEnabled()) return; this.currentTrack = track; // Only reset hasScrobbled if we're not currently in the middle of scrobbling // to prevent race conditions that could cause double scrobbles if (!this.isScrobbling) { this.hasScrobbled = false; } this.clearScrobbleTimer(); // Maloja doesn't have a separate "now playing" endpoint like Last.fm // It just scrobbles when the track is actually played // We'll set up the timer to scrobble after the threshold const scrobblePercentage = lastFMStorage.getScrobblePercentage() / 100; this.scrobbleThreshold = Math.min(track.duration * scrobblePercentage, 240); this.scheduleScrobble(this.scrobbleThreshold * 1000); } scheduleScrobble(delay) { this.clearScrobbleTimer(); this.scrobbleTimer = setTimeout(async () => { await this.scrobbleCurrentTrack(); }, delay); } clearScrobbleTimer() { if (this.scrobbleTimer) { clearTimeout(this.scrobbleTimer); this.scrobbleTimer = null; } } async scrobbleCurrentTrack() { if (!this.isEnabled() || !this.currentTrack || this.hasScrobbled) return; this.isScrobbling = true; try { const timestamp = Math.floor(Date.now() / 1000); await this.submitScrobble(this.currentTrack, timestamp); this.hasScrobbled = true; } finally { this.isScrobbling = false; } } async onTrackChange(track) { await this.updateNowPlaying(track); } onPlaybackStop() { this.clearScrobbleTimer(); } disconnect() { this.clearScrobbleTimer(); this.currentTrack = null; } }