listenbrainz
This commit is contained in:
parent
47cc05e60e
commit
d1c56372a4
8 changed files with 311 additions and 16 deletions
22
index.html
22
index.html
|
|
@ -1440,6 +1440,28 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ListenBrainz Scrobbling</span>
|
||||
<span class="description">Submit listens to ListenBrainz (requires User Token)</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="listenbrainz-enabled-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="listenbrainz-token-setting" style="display: none;">
|
||||
<div class="info">
|
||||
<span class="label">User Token</span>
|
||||
<span class="description">Found on your ListenBrainz profile page</span>
|
||||
</div>
|
||||
<input type="password" id="listenbrainz-token-input" placeholder="Enter Token"
|
||||
class="template-input" style="width: 250px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
161
js/listenbrainz.js
Normal file
161
js/listenbrainz.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
42
js/multi-scrobbler.js
Normal file
42
js/multi-scrobbler.js
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue