diff --git a/index.html b/index.html
index b2d24ff..0f6a9ba 100644
--- a/index.html
+++ b/index.html
@@ -476,15 +476,12 @@
+
+
+ Love on Like
+ Automatically 'love' tracks on ListenBrainz when you like them
+
+
+
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 = {