From 7bcb9e1fb52aaff02ff39dac68b9859859ff497b Mon Sep 17 00:00:00 2001 From: edidealt Date: Fri, 20 Mar 2026 18:07:06 +0000 Subject: [PATCH] listenbrainz love on like --- index.html | 47 ++++++++++++++++----- js/events.js | 5 ++- js/listenbrainz.js | 96 +++++++++++++++++++++++++++++++++++++++---- js/multi-scrobbler.js | 3 +- js/settings.js | 10 +++++ js/storage.js | 15 ++++++- 6 files changed, 153 insertions(+), 23 deletions(-) diff --git a/index.html b/index.html index b2d24ff..0f6a9ba 100644 --- a/index.html +++ b/index.html @@ -476,15 +476,12 @@ +
diff --git a/js/events.js b/js/events.js index 11f1ffa..dcd9b2f 100644 --- a/js/events.js +++ b/js/events.js @@ -8,7 +8,7 @@ import { getShareUrl, escapeHtml, } from './utils.js'; -import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js'; +import { lastFMStorage, libreFmSettings, listenBrainzSettings, waveformSettings } from './storage.js'; import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js'; import { downloadQualitySettings } from './storage.js'; import { updateTabTitle, navigate } from './router.js'; @@ -1050,6 +1050,9 @@ export async function handleTrackAction( if (libreFmSettings.isEnabled() && libreFmSettings.shouldLoveOnLike()) { scrobbler.loveTrack(item); } + if (listenBrainzSettings.isEnabled() && listenBrainzSettings.shouldLoveOnLike()) { + scrobbler.loveTrack(item); + } } // Update all instances of this item's like button on the page diff --git a/js/listenbrainz.js b/js/listenbrainz.js index 44ff52c..e0dee3b 100644 --- a/js/listenbrainz.js +++ b/js/listenbrainz.js @@ -2,7 +2,7 @@ import { listenBrainzSettings, lastFMStorage } from './storage.js'; export class ListenBrainzScrobbler { constructor() { - this.DEFAULT_API_URL = 'https://api.listenbrainz.org/1'; + this.DEFAULT_API_URL = 'https://api.listenbrainz.org'; this.currentTrack = null; this.scrobbleTimer = null; this.scrobbleThreshold = 0; @@ -12,7 +12,8 @@ export class ListenBrainzScrobbler { getApiUrl() { const customUrl = listenBrainzSettings.getCustomUrl(); - return customUrl || this.DEFAULT_API_URL; + const base = customUrl || this.DEFAULT_API_URL; + return base.replace(/\/1\/?$/, ''); } isEnabled() { @@ -26,7 +27,6 @@ export class ListenBrainzScrobbler { _getMetadata(track) { if (!track) return null; - // Get the primary artist name let artistName = 'Unknown Artist'; if (track.artist?.name) { @@ -38,10 +38,9 @@ export class ListenBrainzScrobbler { 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] + .split(/\s*[&]\s*|\s+feat\.?\s*|\s+ft\.?\s*|\s+featuring\s+|\s+with\s+|\s+x\s+/i)[0] .trim(); } @@ -73,6 +72,54 @@ export class ListenBrainzScrobbler { return payload; } + async _lookupRecordingMbid(track) { + 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'; + } + artistName = artistName + .split(/\s*[&]\s*|\s+feat\.?\s*|\s+ft\.?\s*|\s+featuring\s+|\s+with\s+|\s+x\s+/i)[0] + .trim(); + + const trackName = track.cleanTitle || track.title; + if (!artistName || !trackName) return null; + + try { + const apiUrl = this.getApiUrl(); + const params = new URLSearchParams({ + recording_name: trackName, + artist_name: artistName, + }); + + const response = await fetch(`${apiUrl}/1/metadata/lookup/?${params}`, { + method: 'GET', + headers: { + Authorization: `Token ${this.getToken()}`, + }, + }); + + if (!response.ok) { + console.warn(`[ListenBrainz] MBID lookup failed: ${response.status}`); + return null; + } + + const data = await response.json(); + if (data?.recording_mbid) { + console.log(`[ListenBrainz] Found MBID: ${data.recording_mbid}`); + return data.recording_mbid; + } + console.warn('[ListenBrainz] No recording_mbid found in lookup response'); + } catch (error) { + console.error('[ListenBrainz] MBID lookup error:', error); + } + return null; + } + async submitListen(listenType, track, timestamp = null) { if (!this.isEnabled()) return; @@ -96,7 +143,7 @@ export class ListenBrainzScrobbler { try { const apiUrl = this.getApiUrl(); - const response = await fetch(`${apiUrl}/submit-listens`, { + const response = await fetch(`${apiUrl}/1/submit-listens`, { method: 'POST', headers: { Authorization: `Token ${this.getToken()}`, @@ -106,7 +153,6 @@ export class ListenBrainzScrobbler { }); 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}`); } @@ -121,8 +167,6 @@ export class ListenBrainzScrobbler { if (!this.isEnabled()) 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; } @@ -175,4 +219,38 @@ export class ListenBrainzScrobbler { this.clearScrobbleTimer(); this.currentTrack = null; } + + async loveTrack(track) { + if (!this.isEnabled()) return; + + try { + const apiUrl = this.getApiUrl(); + const mbid = await this._lookupRecordingMbid(track); + + if (mbid) { + const response = await fetch(`${apiUrl}/1/feedback/recording-feedback`, { + method: 'POST', + headers: { + Authorization: `Token ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recording_mbid: mbid, + score: 1, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`ListenBrainz Feedback Error ${response.status}: ${text}`); + } + + console.log('[ListenBrainz] Loved track:', track.title); + } else { + console.warn('[ListenBrainz] Could not find recording MBID for love feedback'); + } + } catch (error) { + console.error('[ListenBrainz] Failed to love track:', error); + } + } } diff --git a/js/multi-scrobbler.js b/js/multi-scrobbler.js index 0e057ab..d4616ac 100644 --- a/js/multi-scrobbler.js +++ b/js/multi-scrobbler.js @@ -57,6 +57,7 @@ export class MultiScrobbler { async loveTrack(track) { await this.lastfm.loveTrack(track); await this.librefm.loveTrack(track); - // ListenBrainz and Maloja feedback could be added here when supported + await this.listenbrainz.loveTrack(track); + // Maloja feedback could be added here when supported } } diff --git a/js/settings.js b/js/settings.js index 8ec3c3e..060b910 100644 --- a/js/settings.js +++ b/js/settings.js @@ -467,6 +467,8 @@ export async 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 lbLoveSetting = document.getElementById('listenbrainz-love-setting'); + const lbLoveToggle = document.getElementById('listenbrainz-love-toggle'); const lbTokenInput = document.getElementById('listenbrainz-token-input'); const lbCustomUrlInput = document.getElementById('listenbrainz-custom-url-input'); @@ -475,8 +477,10 @@ export async function initializeSettings(scrobbler, player, api, ui) { if (lbToggle) lbToggle.checked = isEnabled; if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none'; if (lbCustomUrlSetting) lbCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none'; + if (lbLoveSetting) lbLoveSetting.style.display = isEnabled ? 'flex' : 'none'; if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken(); if (lbCustomUrlInput) lbCustomUrlInput.value = listenBrainzSettings.getCustomUrl(); + if (lbLoveToggle) lbLoveToggle.checked = listenBrainzSettings.shouldLoveOnLike(); }; updateListenBrainzUI(); @@ -501,6 +505,12 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); } + if (lbLoveToggle) { + lbLoveToggle.addEventListener('change', (e) => { + listenBrainzSettings.setLoveOnLike(e.target.checked); + }); + } + // ======================================== // Maloja Settings // ======================================== diff --git a/js/storage.js b/js/storage.js index 1108284..dd4942a 100644 --- a/js/storage.js +++ b/js/storage.js @@ -98,7 +98,7 @@ export const apiSettings = { { url: 'https://katze.qqdl.site', version: '2.2' }, { url: 'https://hund.qqdl.site', version: '2.2' }, { url: 'https://wolf.qqdl.site', version: '2.2' }, - { url: 'https://hifi.p1nkhamster.xyz/', version: '2.6'}, + { url: 'https://hifi.p1nkhamster.xyz/', version: '2.6' }, ], }; this.instancesLoaded = true; @@ -1592,6 +1592,7 @@ export const listenBrainzSettings = { ENABLED_KEY: 'listenbrainz-enabled', TOKEN_KEY: 'listenbrainz-token', CUSTOM_URL_KEY: 'listenbrainz-custom-url', + LOVE_ON_LIKE_KEY: 'listenbrainz-love-on-like', isEnabled() { try { @@ -1628,6 +1629,18 @@ export const listenBrainzSettings = { setCustomUrl(url) { localStorage.setItem(this.CUSTOM_URL_KEY, url); }, + + 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 malojaSettings = {