367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
//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('./md5.js');
|
|
return md5(signatureString);
|
|
} catch (e) {
|
|
console.error('MD5 library not available', e);
|
|
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(async () => {
|
|
await 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);
|
|
}
|
|
}
|
|
|
|
async onTrackChange(track) {
|
|
if (!this.isAuthenticated()) return;
|
|
await this.updateNowPlaying(track);
|
|
}
|
|
|
|
onPlaybackStop() {
|
|
this.clearScrobbleTimer();
|
|
}
|
|
|
|
disconnect() {
|
|
this.clearSession();
|
|
this.clearScrobbleTimer();
|
|
this.currentTrack = null;
|
|
}
|
|
}
|