more scrobbling sources
This commit is contained in:
parent
776c63ca3e
commit
2a572aec42
9 changed files with 830 additions and 23 deletions
90
index.html
90
index.html
|
|
@ -2315,6 +2315,19 @@
|
|||
style="width: 250px"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item" id="listenbrainz-custom-url-setting" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Custom API URL (Optional)</span>
|
||||
<span class="description">Leave empty to use official ListenBrainz server</span>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="listenbrainz-custom-url-input"
|
||||
placeholder="https://api.listenbrainz.org/1"
|
||||
class="template-input"
|
||||
style="width: 250px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
|
|
@ -2354,6 +2367,83 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Maloja Scrobbling</span>
|
||||
<span class="description">Submit listens to a self-hosted Maloja server</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="maloja-enabled-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="maloja-token-setting" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">API Key</span>
|
||||
<span class="description">Found in your Maloja settings</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="maloja-token-input"
|
||||
placeholder="Enter API Key"
|
||||
class="template-input"
|
||||
style="width: 250px"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item" id="maloja-custom-url-setting" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Maloja Server URL</span>
|
||||
<span class="description">Your Maloja instance URL</span>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="maloja-custom-url-input"
|
||||
placeholder="https://maloja.example.com"
|
||||
class="template-input"
|
||||
style="width: 250px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Libre.fm Scrobbling</span>
|
||||
<span class="description" id="librefm-status"
|
||||
>Connect your Libre.fm account to scrobble tracks</span
|
||||
>
|
||||
</div>
|
||||
<div id="librefm-controls">
|
||||
<button id="librefm-connect-btn" class="btn-secondary">Connect Libre.fm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="librefm-toggle-setting" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Enable Scrobbling</span>
|
||||
<span class="description">Automatically scrobble played tracks</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="librefm-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="librefm-love-setting" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Love on Like</span>
|
||||
<span class="description"
|
||||
>Automatically 'love' tracks on Libre.fm when you like them</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="librefm-love-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-content" id="settings-tab-audio">
|
||||
|
|
|
|||
11
js/events.js
11
js/events.js
|
|
@ -10,7 +10,7 @@ import {
|
|||
SVG_BIN,
|
||||
getTrackArtists,
|
||||
} from './utils.js';
|
||||
import { lastFMStorage, waveformSettings } from './storage.js';
|
||||
import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js';
|
||||
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
||||
import { downloadQualitySettings } from './storage.js';
|
||||
import { updateTabTitle, navigate } from './router.js';
|
||||
|
|
@ -858,8 +858,13 @@ export async function handleTrackAction(
|
|||
const added = await db.toggleFavorite(type, item);
|
||||
syncManager.syncLibraryItem(type, item, added);
|
||||
|
||||
if (added && type === 'track' && scrobbler && lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
|
||||
scrobbler.loveTrack(item);
|
||||
if (added && type === 'track' && scrobbler) {
|
||||
if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
|
||||
scrobbler.loveTrack(item);
|
||||
}
|
||||
if (libreFmSettings.isEnabled() && libreFmSettings.shouldLoveOnLike()) {
|
||||
scrobbler.loveTrack(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all instances of this item's like button on the page
|
||||
|
|
|
|||
289
js/librefm.js
Normal file
289
js/librefm.js
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { libreFmSettings } 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.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('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 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;
|
||||
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);
|
||||
|
||||
this.scrobbleThreshold = Math.min(track.duration / 2, 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(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
clearScrobbleTimer() {
|
||||
if (this.scrobbleTimer) {
|
||||
clearTimeout(this.scrobbleTimer);
|
||||
this.scrobbleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async scrobbleCurrentTrack() {
|
||||
if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
if (!this.isAuthenticated()) return;
|
||||
this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
this.clearScrobbleTimer();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.clearSession();
|
||||
this.clearScrobbleTimer();
|
||||
this.currentTrack = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,18 @@ import { listenBrainzSettings } from './storage.js';
|
|||
|
||||
export class ListenBrainzScrobbler {
|
||||
constructor() {
|
||||
this.API_URL = 'https://api.listenbrainz.org/1';
|
||||
this.DEFAULT_API_URL = 'https://api.listenbrainz.org/1';
|
||||
this.currentTrack = null;
|
||||
this.scrobbleTimer = null;
|
||||
this.scrobbleThreshold = 0;
|
||||
this.hasScrobbled = false;
|
||||
}
|
||||
|
||||
getApiUrl() {
|
||||
const customUrl = listenBrainzSettings.getCustomUrl();
|
||||
return customUrl || this.DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return listenBrainzSettings.isEnabled() && !!listenBrainzSettings.getToken();
|
||||
}
|
||||
|
|
@ -89,7 +94,8 @@ export class ListenBrainzScrobbler {
|
|||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_URL}/submit-listens`, {
|
||||
const apiUrl = this.getApiUrl();
|
||||
const response = await fetch(`${apiUrl}/submit-listens`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${this.getToken()}`,
|
||||
|
|
|
|||
163
js/maloja.js
Normal file
163
js/maloja.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { malojaSettings } from './storage.js';
|
||||
|
||||
export class MalojaScrobbler {
|
||||
constructor() {
|
||||
this.currentTrack = null;
|
||||
this.scrobbleTimer = null;
|
||||
this.scrobbleThreshold = 0;
|
||||
this.hasScrobbled = 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;
|
||||
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
|
||||
|
||||
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
|
||||
this.scheduleScrobble(this.scrobbleThreshold * 1000);
|
||||
}
|
||||
|
||||
scheduleScrobble(delay) {
|
||||
this.clearScrobbleTimer();
|
||||
this.scrobbleTimer = setTimeout(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
clearScrobbleTimer() {
|
||||
if (this.scrobbleTimer) {
|
||||
clearTimeout(this.scrobbleTimer);
|
||||
this.scrobbleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async scrobbleCurrentTrack() {
|
||||
if (!this.isEnabled() || !this.currentTrack || this.hasScrobbled) return;
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
await this.submitScrobble(this.currentTrack, timestamp);
|
||||
this.hasScrobbled = true;
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
this.clearScrobbleTimer();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.clearScrobbleTimer();
|
||||
this.currentTrack = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { LastFMScrobbler } from './lastfm.js';
|
||||
import { ListenBrainzScrobbler } from './listenbrainz.js';
|
||||
import { MalojaScrobbler } from './maloja.js';
|
||||
import { LibreFmScrobbler } from './librefm.js';
|
||||
|
||||
export class MultiScrobbler {
|
||||
constructor() {
|
||||
this.lastfm = new LastFMScrobbler();
|
||||
this.listenbrainz = new ListenBrainzScrobbler();
|
||||
this.maloja = new MalojaScrobbler();
|
||||
this.librefm = new LibreFmScrobbler();
|
||||
}
|
||||
|
||||
// Proxy method for Last.fm specific usage (auth flow)
|
||||
|
|
@ -12,30 +16,47 @@ export class MultiScrobbler {
|
|||
return this.lastfm;
|
||||
}
|
||||
|
||||
// Proxy method for Libre.fm specific usage (auth flow)
|
||||
getLibreFm() {
|
||||
return this.librefm;
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
// Return true if any service is configured, so events.js will proceed to call updateNowPlaying
|
||||
// Individual services check their own enabled/auth state internally
|
||||
return this.lastfm.isAuthenticated() || this.listenbrainz.isEnabled();
|
||||
return (
|
||||
this.lastfm.isAuthenticated() ||
|
||||
this.listenbrainz.isEnabled() ||
|
||||
this.maloja.isEnabled() ||
|
||||
this.librefm.isAuthenticated()
|
||||
);
|
||||
}
|
||||
|
||||
updateNowPlaying(track) {
|
||||
this.lastfm.updateNowPlaying(track);
|
||||
this.listenbrainz.updateNowPlaying(track);
|
||||
this.maloja.updateNowPlaying(track);
|
||||
this.librefm.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
this.lastfm.onTrackChange(track);
|
||||
this.listenbrainz.onTrackChange(track);
|
||||
this.maloja.onTrackChange(track);
|
||||
this.librefm.onTrackChange(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
this.lastfm.onPlaybackStop();
|
||||
this.listenbrainz.onPlaybackStop();
|
||||
this.maloja.onPlaybackStop();
|
||||
this.librefm.onPlaybackStop();
|
||||
}
|
||||
|
||||
// Love/Like is currently Last.fm specific in the UI, but we can extend later
|
||||
// Love/Like tracks on all services that support it
|
||||
async loveTrack(track) {
|
||||
await this.lastfm.loveTrack(track);
|
||||
// ListenBrainz feedback could be added here
|
||||
await this.librefm.loveTrack(track);
|
||||
// ListenBrainz and Maloja feedback could be added here when supported
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
js/settings.js
164
js/settings.js
|
|
@ -18,6 +18,8 @@ import {
|
|||
playlistSettings,
|
||||
equalizerSettings,
|
||||
listenBrainzSettings,
|
||||
malojaSettings,
|
||||
libreFmSettings,
|
||||
homePageSettings,
|
||||
sidebarSectionSettings,
|
||||
} from './storage.js';
|
||||
|
|
@ -223,13 +225,17 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
// ========================================
|
||||
const lbToggle = document.getElementById('listenbrainz-enabled-toggle');
|
||||
const lbTokenSetting = document.getElementById('listenbrainz-token-setting');
|
||||
const lbCustomUrlSetting = document.getElementById('listenbrainz-custom-url-setting');
|
||||
const lbTokenInput = document.getElementById('listenbrainz-token-input');
|
||||
const lbCustomUrlInput = document.getElementById('listenbrainz-custom-url-input');
|
||||
|
||||
const updateListenBrainzUI = () => {
|
||||
const isEnabled = listenBrainzSettings.isEnabled();
|
||||
if (lbToggle) lbToggle.checked = isEnabled;
|
||||
if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||
if (lbCustomUrlSetting) lbCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||
if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken();
|
||||
if (lbCustomUrlInput) lbCustomUrlInput.value = listenBrainzSettings.getCustomUrl();
|
||||
};
|
||||
|
||||
updateListenBrainzUI();
|
||||
|
|
@ -248,6 +254,164 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
if (lbCustomUrlInput) {
|
||||
lbCustomUrlInput.addEventListener('change', (e) => {
|
||||
listenBrainzSettings.setCustomUrl(e.target.value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Maloja Settings
|
||||
// ========================================
|
||||
const malojaToggle = document.getElementById('maloja-enabled-toggle');
|
||||
const malojaTokenSetting = document.getElementById('maloja-token-setting');
|
||||
const malojaCustomUrlSetting = document.getElementById('maloja-custom-url-setting');
|
||||
const malojaTokenInput = document.getElementById('maloja-token-input');
|
||||
const malojaCustomUrlInput = document.getElementById('maloja-custom-url-input');
|
||||
|
||||
const updateMalojaUI = () => {
|
||||
const isEnabled = malojaSettings.isEnabled();
|
||||
if (malojaToggle) malojaToggle.checked = isEnabled;
|
||||
if (malojaTokenSetting) malojaTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||
if (malojaCustomUrlSetting) malojaCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||
if (malojaTokenInput) malojaTokenInput.value = malojaSettings.getToken();
|
||||
if (malojaCustomUrlInput) malojaCustomUrlInput.value = malojaSettings.getCustomUrl();
|
||||
};
|
||||
|
||||
updateMalojaUI();
|
||||
|
||||
if (malojaToggle) {
|
||||
malojaToggle.addEventListener('change', (e) => {
|
||||
const enabled = e.target.checked;
|
||||
malojaSettings.setEnabled(enabled);
|
||||
updateMalojaUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (malojaTokenInput) {
|
||||
malojaTokenInput.addEventListener('change', (e) => {
|
||||
malojaSettings.setToken(e.target.value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
if (malojaCustomUrlInput) {
|
||||
malojaCustomUrlInput.addEventListener('change', (e) => {
|
||||
malojaSettings.setCustomUrl(e.target.value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Libre.fm Settings
|
||||
// ========================================
|
||||
const librefmConnectBtn = document.getElementById('librefm-connect-btn');
|
||||
const librefmStatus = document.getElementById('librefm-status');
|
||||
const librefmToggle = document.getElementById('librefm-toggle');
|
||||
const librefmToggleSetting = document.getElementById('librefm-toggle-setting');
|
||||
const librefmLoveToggle = document.getElementById('librefm-love-toggle');
|
||||
const librefmLoveSetting = document.getElementById('librefm-love-setting');
|
||||
|
||||
function updateLibreFmUI() {
|
||||
if (scrobbler.librefm.isAuthenticated()) {
|
||||
librefmStatus.textContent = `Connected as ${scrobbler.librefm.username}`;
|
||||
librefmConnectBtn.textContent = 'Disconnect';
|
||||
librefmConnectBtn.classList.add('danger');
|
||||
librefmToggleSetting.style.display = 'flex';
|
||||
librefmLoveSetting.style.display = 'flex';
|
||||
librefmToggle.checked = libreFmSettings.isEnabled();
|
||||
librefmLoveToggle.checked = libreFmSettings.shouldLoveOnLike();
|
||||
} else {
|
||||
librefmStatus.textContent = 'Connect your Libre.fm account to scrobble tracks';
|
||||
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||
librefmConnectBtn.classList.remove('danger');
|
||||
librefmToggleSetting.style.display = 'none';
|
||||
librefmLoveSetting.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (librefmConnectBtn) {
|
||||
updateLibreFmUI();
|
||||
|
||||
librefmConnectBtn.addEventListener('click', async () => {
|
||||
if (scrobbler.librefm.isAuthenticated()) {
|
||||
if (confirm('Disconnect from Libre.fm?')) {
|
||||
scrobbler.librefm.disconnect();
|
||||
updateLibreFmUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const authWindow = window.open('', '_blank');
|
||||
librefmConnectBtn.disabled = true;
|
||||
librefmConnectBtn.textContent = 'Opening Libre.fm...';
|
||||
|
||||
try {
|
||||
const { token, url } = await scrobbler.librefm.getAuthUrl();
|
||||
|
||||
if (authWindow) {
|
||||
authWindow.location.href = url;
|
||||
} else {
|
||||
alert('Popup blocked! Please allow popups.');
|
||||
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||
librefmConnectBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
librefmConnectBtn.textContent = 'Waiting for authorization...';
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
|
||||
const checkAuth = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(checkAuth);
|
||||
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||
librefmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
alert('Authorization timed out. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scrobbler.librefm.completeAuthentication(token);
|
||||
|
||||
if (result.success) {
|
||||
clearInterval(checkAuth);
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
updateLibreFmUI();
|
||||
librefmConnectBtn.disabled = false;
|
||||
libreFmSettings.setEnabled(true);
|
||||
librefmToggle.checked = true;
|
||||
alert(`Successfully connected to Libre.fm as ${result.username}!`);
|
||||
}
|
||||
} catch {
|
||||
// Still waiting
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Libre.fm connection failed:', error);
|
||||
alert('Failed to connect to Libre.fm: ' + error.message);
|
||||
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||
librefmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Libre.fm Toggles
|
||||
if (librefmToggle) {
|
||||
librefmToggle.addEventListener('change', (e) => {
|
||||
libreFmSettings.setEnabled(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
if (librefmLoveToggle) {
|
||||
librefmLoveToggle.addEventListener('change', (e) => {
|
||||
libreFmSettings.setLoveOnLike(e.target.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Theme picker
|
||||
const themePicker = document.getElementById('theme-picker');
|
||||
const currentTheme = themeManager.getTheme();
|
||||
|
|
|
|||
|
|
@ -902,6 +902,7 @@ export const queueManager = {
|
|||
export const listenBrainzSettings = {
|
||||
ENABLED_KEY: 'listenbrainz-enabled',
|
||||
TOKEN_KEY: 'listenbrainz-token',
|
||||
CUSTOM_URL_KEY: 'listenbrainz-custom-url',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
|
|
@ -926,6 +927,89 @@ export const listenBrainzSettings = {
|
|||
setToken(token) {
|
||||
localStorage.setItem(this.TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
getCustomUrl() {
|
||||
try {
|
||||
return localStorage.getItem(this.CUSTOM_URL_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
setCustomUrl(url) {
|
||||
localStorage.setItem(this.CUSTOM_URL_KEY, url);
|
||||
},
|
||||
};
|
||||
|
||||
export const malojaSettings = {
|
||||
ENABLED_KEY: 'maloja-enabled',
|
||||
TOKEN_KEY: 'maloja-token',
|
||||
CUSTOM_URL_KEY: 'maloja-custom-url',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.ENABLED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
getToken() {
|
||||
try {
|
||||
return localStorage.getItem(this.TOKEN_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
setToken(token) {
|
||||
localStorage.setItem(this.TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
getCustomUrl() {
|
||||
try {
|
||||
return localStorage.getItem(this.CUSTOM_URL_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
setCustomUrl(url) {
|
||||
localStorage.setItem(this.CUSTOM_URL_KEY, url);
|
||||
},
|
||||
};
|
||||
|
||||
export const libreFmSettings = {
|
||||
ENABLED_KEY: 'librefm-enabled',
|
||||
LOVE_ON_LIKE_KEY: 'librefm-love-on-like',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.ENABLED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
shouldLoveOnLike() {
|
||||
try {
|
||||
return localStorage.getItem(this.LOVE_ON_LIKE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setLoveOnLike(enabled) {
|
||||
localStorage.setItem(this.LOVE_ON_LIKE_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
};
|
||||
|
||||
export const homePageSettings = {
|
||||
|
|
|
|||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -74,7 +74,6 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -1604,7 +1603,6 @@
|
|||
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
|
|
@ -1646,7 +1644,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -1690,7 +1687,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -3142,7 +3138,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
|
||||
"integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
|
|
@ -3191,7 +3186,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3215,7 +3209,6 @@
|
|||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -3503,7 +3496,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -4288,7 +4280,6 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -6645,7 +6636,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -6729,7 +6719,6 @@
|
|||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
|
|
@ -7679,7 +7668,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
|
||||
|
|
@ -8094,7 +8082,6 @@
|
|||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
|
|
@ -8419,7 +8406,6 @@
|
|||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -8807,7 +8793,6 @@
|
|||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue