From d1c56372a4bb4de15e80838ac698282dcab6213c Mon Sep 17 00:00:00 2001 From: EduardPrigoana Date: Sun, 1 Feb 2026 22:34:52 +0200 Subject: [PATCH] listenbrainz --- index.html | 22 ++++++ js/app.js | 4 +- js/events.js | 2 +- js/lastfm.js | 4 +- js/listenbrainz.js | 161 ++++++++++++++++++++++++++++++++++++++++++ js/multi-scrobbler.js | 42 +++++++++++ js/settings.js | 63 +++++++++++++---- js/storage.js | 29 ++++++++ 8 files changed, 311 insertions(+), 16 deletions(-) create mode 100644 js/listenbrainz.js create mode 100644 js/multi-scrobbler.js diff --git a/index.html b/index.html index 25192c9..9d35d85 100644 --- a/index.html +++ b/index.html @@ -1440,6 +1440,28 @@ + +
+
+
+ ListenBrainz Scrobbling + Submit listens to ListenBrainz (requires User Token) +
+ +
+ +
+
diff --git a/js/app.js b/js/app.js index af1e15c..5437e8d 100644 --- a/js/app.js +++ b/js/app.js @@ -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); diff --git a/js/events.js b/js/events.js index 8ef87c8..666d9bf 100644 --- a/js/events.js +++ b/js/events.js @@ -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); } diff --git a/js/lastfm.js b/js/lastfm.js index 27c8709..9f4844e 100644 --- a/js/lastfm.js +++ b/js/lastfm.js @@ -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) { diff --git a/js/listenbrainz.js b/js/listenbrainz.js new file mode 100644 index 0000000..40a2612 --- /dev/null +++ b/js/listenbrainz.js @@ -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; + } +} diff --git a/js/multi-scrobbler.js b/js/multi-scrobbler.js new file mode 100644 index 0000000..75063ce --- /dev/null +++ b/js/multi-scrobbler.js @@ -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 + } +} diff --git a/js/settings.js b/js/settings.js index 26ba788..3756e7a 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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'); diff --git a/js/storage.js b/js/storage.js index 9f2a7ce..8174659 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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) => {