listenbrainz love on like

This commit is contained in:
edidealt 2026-03-20 18:07:06 +00:00
parent 5d0d375242
commit 7bcb9e1fb5
6 changed files with 153 additions and 23 deletions

View file

@ -476,15 +476,12 @@
<div id="jspf-import-panel" class="import-panel" style="display: none">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from JSPF</p>
<p style="font-size: 0.8rem; margin: 0">
JSPF (JSON Shareable Playlist Format) is supported by const replayGainPreamp = document.getElementById('replay-gain-preamp');
if (replayGainPreamp) {
replayGainPreamp.value = replayGainSettings.getPreamp();
replayGainPreamp.addEventListener('change', (e) => {
const val = parseFloat(e.target.value);
replayGainSettings.setPreamp(isNaN(val) ? 3 : val);
player.applyReplayGain();
});
} Import playlists with rich metadata including MusicBrainz identifiers.
JSPF (JSON Shareable Playlist Format) is supported by const replayGainPreamp =
document.getElementById('replay-gain-preamp'); if (replayGainPreamp) {
replayGainPreamp.value = replayGainSettings.getPreamp();
replayGainPreamp.addEventListener('change', (e) => { const val = parseFloat(e.target.value);
replayGainSettings.setPreamp(isNaN(val) ? 3 : val); player.applyReplayGain(); }); } Import
playlists with rich metadata including MusicBrainz identifiers.
</p>
<br />
<input
@ -2350,7 +2347,23 @@
</section>
<section class="content-section" id="artist-section-in-library" style="display: none">
<h2 class="section-title">
<button id="in-library-toggle" aria-expanded="false" aria-controls="artist-detail-in-library" style="background: none; border: none; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; padding: 0; user-select: none;">
<button
id="in-library-toggle"
aria-expanded="false"
aria-controls="artist-detail-in-library"
style="
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0;
user-select: none;
"
>
In Your Library
<use
svg="!lucide/chevron-right.svg"
@ -3592,11 +3605,23 @@
<input
type="url"
id="listenbrainz-custom-url-input"
placeholder="https://api.listenbrainz.org/1"
placeholder="https://api.listenbrainz.org"
class="template-input"
style="width: 250px"
/>
</div>
<div class="setting-item" id="listenbrainz-love-setting" style="display: none">
<div class="info">
<span class="label">Love on Like</span>
<span class="description"
>Automatically 'love' tracks on ListenBrainz when you like them</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="listenbrainz-love-toggle" />
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-group">

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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
}
}

View file

@ -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
// ========================================

View file

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