299 lines
8.8 KiB
JavaScript
299 lines
8.8 KiB
JavaScript
import { libreFmSettings, lastFMStorage } from './storage.js';
|
|
|
|
export class LibreFmScrobbler {
|
|
constructor() {
|
|
this.API_KEY = 'monochrome_music_app';
|
|
this.API_SECRET = 'monochrome_music_secret_2024';
|
|
this.API_URL = 'https://libre.fm/2.0/';
|
|
|
|
this.sessionKey = null;
|
|
this.username = null;
|
|
this.currentTrack = null;
|
|
this.scrobbleTimer = null;
|
|
this.scrobbleThreshold = 0;
|
|
this.hasScrobbled = false;
|
|
this.isScrobbling = false;
|
|
|
|
this.loadSession();
|
|
}
|
|
|
|
loadSession() {
|
|
try {
|
|
const session = localStorage.getItem('librefm-session');
|
|
if (session) {
|
|
const data = JSON.parse(session);
|
|
this.sessionKey = data.key;
|
|
this.username = data.name;
|
|
}
|
|
} catch {
|
|
console.error('Failed to load Libre.fm session');
|
|
}
|
|
}
|
|
|
|
saveSession(sessionKey, username) {
|
|
this.sessionKey = sessionKey;
|
|
this.username = username;
|
|
localStorage.setItem(
|
|
'librefm-session',
|
|
JSON.stringify({
|
|
key: sessionKey,
|
|
name: username,
|
|
})
|
|
);
|
|
}
|
|
|
|
clearSession() {
|
|
this.sessionKey = null;
|
|
this.username = null;
|
|
localStorage.removeItem('librefm-session');
|
|
}
|
|
|
|
isAuthenticated() {
|
|
return !!this.sessionKey && libreFmSettings.isEnabled();
|
|
}
|
|
|
|
_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 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;
|
|
|
|
try {
|
|
const { default: md5 } = await import('./md5.js');
|
|
return md5(signatureString);
|
|
} catch {
|
|
console.error('MD5 library not available');
|
|
throw new Error('MD5 library required for Libre.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 || 'Libre.fm API error');
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Libre.fm API request failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getAuthUrl() {
|
|
try {
|
|
// First, get a token from Libre.fm
|
|
const data = await this.makeRequest('auth.getToken');
|
|
const token = data.token;
|
|
|
|
localStorage.setItem('librefm-pending-token', token);
|
|
|
|
return {
|
|
token,
|
|
url: `https://libre.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);
|
|
localStorage.removeItem('librefm-pending-token');
|
|
return {
|
|
success: true,
|
|
username: data.session.name,
|
|
};
|
|
}
|
|
|
|
throw new Error('No session returned');
|
|
} catch (error) {
|
|
console.error('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('[Libre.fm] 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('[Libre.fm] 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('[Libre.fm] Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title);
|
|
} catch (error) {
|
|
console.error('[Libre.fm] 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('[Libre.fm] Loved track:', track.title);
|
|
} catch (error) {
|
|
console.error('[Libre.fm] Failed to love track:', error);
|
|
}
|
|
}
|
|
|
|
async onTrackChange(track) {
|
|
if (!this.isAuthenticated()) return;
|
|
await this.updateNowPlaying(track);
|
|
}
|
|
|
|
onPlaybackStop() {
|
|
this.clearScrobbleTimer();
|
|
}
|
|
|
|
disconnect() {
|
|
this.clearSession();
|
|
this.clearScrobbleTimer();
|
|
this.currentTrack = null;
|
|
}
|
|
}
|