listenbrainz

This commit is contained in:
EduardPrigoana 2026-02-01 22:34:52 +02:00
parent 47cc05e60e
commit d1c56372a4
8 changed files with 311 additions and 16 deletions

View file

@ -1440,6 +1440,28 @@
</div>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">ListenBrainz Scrobbling</span>
<span class="description">Submit listens to ListenBrainz (requires User Token)</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="listenbrainz-enabled-toggle" />
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="listenbrainz-token-setting" style="display: none;">
<div class="info">
<span class="label">User Token</span>
<span class="description">Found on your ListenBrainz profile page</span>
</div>
<input type="password" id="listenbrainz-token-input" placeholder="Enter Token"
class="template-input" style="width: 250px" />
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">

View file

@ -3,7 +3,7 @@ import { LosslessAPI } from './api.js';
import { apiSettings, themeManager, nowPlayingSettings, downloadQualitySettings } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import { LastFMScrobbler } from './lastfm.js';
import { MultiScrobbler } from './multi-scrobbler.js';
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
import { createRouter, updateTabTitle, navigate } from './router.js';
import { initializeSettings } from './settings.js';
@ -218,7 +218,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const player = new Player(audioPlayer, api, currentQuality);
const ui = new UIRenderer(api, player);
const scrobbler = new LastFMScrobbler();
const scrobbler = new MultiScrobbler();
const lyricsManager = new LyricsManager(api);
const originalRenderPlaylistPage = ui.renderPlaylistPage.bind(ui);

View file

@ -61,7 +61,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (player.currentTrack) {
// Scrobble
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
if (scrobbler.isAuthenticated()) {
scrobbler.updateNowPlaying(player.currentTrack);
}

View file

@ -1,4 +1,6 @@
//js/lastfm.js
import { lastFMStorage } from './storage.js';
export class LastFMScrobbler {
constructor() {
this.API_KEY = '0ecf01914957b40c17030db822845a76';
@ -47,7 +49,7 @@ export class LastFMScrobbler {
}
isAuthenticated() {
return !!this.sessionKey;
return !!this.sessionKey && lastFMStorage.isEnabled();
}
_getScrobbleArtist(track) {

161
js/listenbrainz.js Normal file
View file

@ -0,0 +1,161 @@
import { listenBrainzSettings } from './storage.js';
export class ListenBrainzScrobbler {
constructor() {
this.API_URL = 'https://api.listenbrainz.org/1';
this.currentTrack = null;
this.scrobbleTimer = null;
this.scrobbleThreshold = 0;
this.hasScrobbled = false;
}
isEnabled() {
return listenBrainzSettings.isEnabled() && !!listenBrainzSettings.getToken();
}
getToken() {
return listenBrainzSettings.getToken();
}
_getMetadata(track) {
if (!track) return null;
// 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) {
const first = track.artists[0];
artistName = typeof first === 'string' ? first : first.name || 'Unknown Artist';
}
// Clean artist name
if (typeof artistName === 'string') {
artistName = artistName
.split(/\s*[&]\s*|\s+feat\.?\s+|\s+ft\.?\s+|\s+featuring\s+|\s+with\s+|\s+x\s+/i)[0]
.trim();
}
const payload = {
artist_name: artistName,
track_name: track.cleanTitle || track.title,
additional_info: {
submission_client: 'Monochrome',
submission_client_version: '1.0.0',
}
};
if (track.album?.title) {
payload.release_name = track.album.title;
}
if (track.duration) {
payload.additional_info.duration = Math.floor(track.duration);
}
if (track.trackNumber) {
payload.additional_info.track_number = track.trackNumber;
}
if (track.isLocal) {
payload.additional_info.is_local = true;
}
return payload;
}
async submitListen(listenType, track, timestamp = null) {
if (!this.isEnabled()) return;
const metadata = this._getMetadata(track);
if (!metadata) return;
const payload = [
{
track_metadata: metadata,
}
];
if (timestamp) {
payload[0].listened_at = timestamp;
}
const body = {
listen_type: listenType,
payload: payload
};
try {
const response = await fetch(`${this.API_URL}/submit-listens`, {
method: 'POST',
headers: {
'Authorization': `Token ${this.getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
// ListenBrainz doesn't always return JSON on error
const text = await response.text();
throw new Error(`ListenBrainz API Error ${response.status}: ${text}`);
}
console.log(`[ListenBrainz] Submitted ${listenType}: ${metadata.track_name}`);
} catch (error) {
console.error('[ListenBrainz] Submission failed:', error);
}
}
async updateNowPlaying(track) {
if (!this.isEnabled()) return;
this.currentTrack = track;
this.hasScrobbled = false;
this.clearScrobbleTimer();
await this.submitListen('playing_now', track);
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.submitListen('single', this.currentTrack, timestamp);
this.hasScrobbled = true;
}
onTrackChange(track) {
this.updateNowPlaying(track);
}
onPlaybackStop() {
this.clearScrobbleTimer();
}
disconnect() {
this.clearScrobbleTimer();
this.currentTrack = null;
}
}

42
js/multi-scrobbler.js Normal file
View file

@ -0,0 +1,42 @@
import { LastFMScrobbler } from './lastfm.js';
import { ListenBrainzScrobbler } from './listenbrainz.js';
export class MultiScrobbler {
constructor() {
this.lastfm = new LastFMScrobbler();
this.listenbrainz = new ListenBrainzScrobbler();
}
// Proxy method for Last.fm specific usage (auth flow)
getLastFM() {
return this.lastfm;
}
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();
}
updateNowPlaying(track) {
this.lastfm.updateNowPlaying(track);
this.listenbrainz.updateNowPlaying(track);
}
onTrackChange(track) {
this.lastfm.onTrackChange(track);
this.listenbrainz.onTrackChange(track);
}
onPlaybackStop() {
this.lastfm.onPlaybackStop();
this.listenbrainz.onPlaybackStop();
}
// Love/Like is currently Last.fm specific in the UI, but we can extend later
async loveTrack(track) {
await this.lastfm.loveTrack(track);
// ListenBrainz feedback could be added here
}
}

View file

@ -16,6 +16,7 @@ import {
bulkDownloadSettings,
playlistSettings,
equalizerSettings,
listenBrainzSettings,
} from './storage.js';
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
import { db } from './db.js';
@ -113,8 +114,8 @@ export function initializeSettings(scrobbler, player, api, ui) {
const lastfmLoveSetting = document.getElementById('lastfm-love-setting');
function updateLastFMUI() {
if (scrobbler.isAuthenticated()) {
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
if (scrobbler.lastfm.isAuthenticated()) {
lastfmStatus.textContent = `Connected as ${scrobbler.lastfm.username}`;
lastfmConnectBtn.textContent = 'Disconnect';
lastfmConnectBtn.classList.add('danger');
lastfmToggleSetting.style.display = 'flex';
@ -133,9 +134,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
updateLastFMUI();
lastfmConnectBtn?.addEventListener('click', async () => {
if (scrobbler.isAuthenticated()) {
if (scrobbler.lastfm.isAuthenticated()) {
if (confirm('Disconnect from Last.fm?')) {
scrobbler.disconnect();
scrobbler.lastfm.disconnect();
updateLastFMUI();
}
return;
@ -146,7 +147,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
lastfmConnectBtn.textContent = 'Opening Last.fm...';
try {
const { token, url } = await scrobbler.getAuthUrl();
const { token, url } = await scrobbler.lastfm.getAuthUrl();
if (authWindow) {
authWindow.location.href = url;
@ -175,7 +176,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
}
try {
const result = await scrobbler.completeAuthentication(token);
const result = await scrobbler.lastfm.completeAuthentication(token);
if (result.success) {
clearInterval(checkAuth);
@ -199,13 +200,51 @@ export function initializeSettings(scrobbler, player, api, ui) {
}
});
lastfmToggle?.addEventListener('change', (e) => {
lastFMStorage.setEnabled(e.target.checked);
});
// Last.fm Toggles
if (lastfmToggle) {
lastfmToggle.addEventListener('change', (e) => {
lastFMStorage.setEnabled(e.target.checked);
});
}
if (lastfmLoveToggle) {
lastfmLoveToggle.addEventListener('change', (e) => {
lastFMStorage.setLoveOnLike(e.target.checked);
});
}
// ========================================
// ListenBrainz Settings
// ========================================
const lbToggle = document.getElementById('listenbrainz-enabled-toggle');
const lbTokenSetting = document.getElementById('listenbrainz-token-setting');
const lbTokenInput = document.getElementById('listenbrainz-token-input');
const updateListenBrainzUI = () => {
const isEnabled = listenBrainzSettings.isEnabled();
if (lbToggle) lbToggle.checked = isEnabled;
if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none';
if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken();
};
updateListenBrainzUI();
if (lbToggle) {
lbToggle.addEventListener('change', (e) => {
const enabled = e.target.checked;
listenBrainzSettings.setEnabled(enabled);
updateListenBrainzUI();
});
}
if (lbTokenInput) {
lbTokenInput.addEventListener('change', (e) => {
listenBrainzSettings.setToken(e.target.value.trim());
});
}
lastfmLoveToggle?.addEventListener('change', (e) => {
lastFMStorage.setLoveOnLike(e.target.checked);
});
// Theme picker
const themePicker = document.getElementById('theme-picker');

View file

@ -842,6 +842,35 @@ export const queueManager = {
},
};
export const listenBrainzSettings = {
ENABLED_KEY: 'listenbrainz-enabled',
TOKEN_KEY: 'listenbrainz-token',
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);
},
};
// System theme listener
if (typeof window !== 'undefined' && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {