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) => {