//js/lastfm.js import { lastFMStorage } from './storage.js'; export class LastFMScrobbler { constructor() { this.DEFAULT_API_KEY = '85214f5abbc730e78770f27784b9bdf7'; this.DEFAULT_API_SECRET = '2c2c37fd86739191860db810dd063292'; this.API_URL = 'https://ws.audioscrobbler.com/2.0/'; this.sessionKey = null; this.username = null; this.currentTrack = null; this.scrobbleTimer = null; this.scrobbleThreshold = 0; this.hasScrobbled = false; this.isScrobbling = false; this.loadCredentials(); this.loadSession(); } loadCredentials() { if (lastFMStorage.useCustomCredentials()) { this.API_KEY = lastFMStorage.getCustomApiKey() || this.DEFAULT_API_KEY; this.API_SECRET = lastFMStorage.getCustomApiSecret() || this.DEFAULT_API_SECRET; } else { this.API_KEY = this.DEFAULT_API_KEY; this.API_SECRET = this.DEFAULT_API_SECRET; } } reloadCredentials() { this.loadCredentials(); } loadSession() { try { const session = localStorage.getItem('lastfm-session'); if (session) { const data = JSON.parse(session); this.sessionKey = data.key; this.username = data.name; } } catch { console.error('Failed to load Last.fm session'); } } saveSession(sessionKey, username) { this.sessionKey = sessionKey; this.username = username; localStorage.setItem( 'lastfm-session', JSON.stringify({ key: sessionKey, name: username, }) ); } clearSession() { this.sessionKey = null; this.username = null; localStorage.removeItem('lastfm-session'); } isAuthenticated() { return !!this.sessionKey && lastFMStorage.isEnabled(); } _getScrobbleArtist(track) { if (!track) return 'Unknown Artist'; // Get the primary artist name 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) { // Only use the FIRST artist (main artist) const first = track.artists[0]; artistName = typeof first === 'string' ? first : first.name || 'Unknown Artist'; } if (typeof artistName !== 'string') return 'Unknown Artist'; // Strip featured artists: split on &, feat., ft., featuring, with, etc. // Only keep the part BEFORE these indicators 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 generateSignature(params) { const filteredParams = { ...params }; delete filteredParams.format; delete filteredParams.callback; const sortedKeys = Object.keys(filteredParams).sort(); const signatureString = sortedKeys.map((key) => `${key}${filteredParams[key]}`).join('') + this.API_SECRET; console.log('Signature string:', signatureString); try { const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm'); return md5(signatureString); } catch { console.error('MD5 library not available'); throw new Error('MD5 library required for Last.fm'); } } async makeRequest(method, params = {}, requiresAuth = false) { const requestParams = { method, api_key: this.API_KEY, ...params, }; if (requiresAuth && this.sessionKey) { requestParams.sk = this.sessionKey; } const signature = await this.generateSignature(requestParams); const formData = new URLSearchParams({ ...requestParams, api_sig: signature, format: 'json', }); try { const response = await fetch(this.API_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formData, }); const data = await response.json(); if (data.error) { throw new Error(data.message || 'Last.fm API error'); } return data; } catch (error) { console.error('Last.fm API request failed:', error); throw error; } } async getAuthUrl() { try { const data = await this.makeRequest('auth.getToken'); const token = data.token; return { token, url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}`, }; } catch (error) { console.error('Failed to get auth URL:', error); throw error; } } async completeAuthentication(token) { try { const data = await this.makeRequest('auth.getSession', { token }); if (data.session) { this.saveSession(data.session.key, data.session.name); return { success: true, username: data.session.name, }; } throw new Error('No session returned'); } catch (error) { console.error('Authentication failed:', error); throw error; } } async authenticateWithCredentials(username, password) { try { const params = { username: username, password: password, api_key: this.API_KEY, method: 'auth.getMobileSession', }; const signature = await this.generateSignature(params); const formData = new URLSearchParams({ username: username, password: password, api_key: this.API_KEY, method: 'auth.getMobileSession', api_sig: signature, format: 'json', }); const response = await fetch(this.API_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formData, }); const data = await response.json(); if (data.error) { throw new Error(data.message || 'Last.fm authentication error'); } if (data.session) { this.saveSession(data.session.key, data.session.name); return { success: true, username: data.session.name, }; } throw new Error('No session returned'); } catch (error) { console.error('Mobile authentication failed:', error); throw error; } } async updateNowPlaying(track) { if (!this.isAuthenticated()) 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(); try { const scrobbleTitle = track.cleanTitle || track.title; const params = { artist: this._getScrobbleArtist(track), track: scrobbleTitle, }; if (track.album?.title) { params.album = track.album.title; } if (track.duration) { params.duration = Math.floor(track.duration); } if (track.trackNumber) { params.trackNumber = track.trackNumber; } await this.makeRequest('track.updateNowPlaying', params, true); console.log('Now playing updated:', scrobbleTitle); const scrobblePercentage = lastFMStorage.getScrobblePercentage() / 100; this.scrobbleThreshold = Math.min(track.duration * scrobblePercentage, 240); this.scheduleScrobble(this.scrobbleThreshold * 1000); } catch (error) { console.error('Failed to update now playing:', error); } } scheduleScrobble(delay) { this.clearScrobbleTimer(); this.scrobbleTimer = setTimeout(() => { this.scrobbleCurrentTrack(); }, delay); } clearScrobbleTimer() { if (this.scrobbleTimer) { clearTimeout(this.scrobbleTimer); this.scrobbleTimer = null; } } async scrobbleCurrentTrack() { if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return; this.isScrobbling = true; try { const timestamp = Math.floor(Date.now() / 1000); const scrobbleTitle = this.currentTrack.cleanTitle || this.currentTrack.title; const params = { artist: this._getScrobbleArtist(this.currentTrack), track: scrobbleTitle, timestamp: timestamp, }; if (this.currentTrack.album?.title) { params.album = this.currentTrack.album.title; } if (this.currentTrack.duration) { params.duration = Math.floor(this.currentTrack.duration); } if (this.currentTrack.trackNumber) { params.trackNumber = this.currentTrack.trackNumber; } await this.makeRequest('track.scrobble', params, true); this.hasScrobbled = true; console.log('Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title); } catch (error) { console.error('Failed to scrobble:', error); } finally { this.isScrobbling = false; } } async loveTrack(track) { if (!this.isAuthenticated()) return; try { const params = { artist: this._getScrobbleArtist(track), track: track.title, }; await this.makeRequest('track.love', params, true); console.log('Loved track on Last.fm:', track.title); } catch (error) { console.error('Failed to love track on Last.fm:', error); } } onTrackChange(track) { if (!this.isAuthenticated()) return; this.updateNowPlaying(track); } onPlaybackStop() { this.clearScrobbleTimer(); } disconnect() { this.clearSession(); this.clearScrobbleTimer(); this.currentTrack = null; } }