more scrobbling sources
This commit is contained in:
parent
776c63ca3e
commit
2a572aec42
9 changed files with 830 additions and 23 deletions
90
index.html
90
index.html
|
|
@ -2315,6 +2315,19 @@
|
||||||
style="width: 250px"
|
style="width: 250px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item" id="listenbrainz-custom-url-setting" style="display: none">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Custom API URL (Optional)</span>
|
||||||
|
<span class="description">Leave empty to use official ListenBrainz server</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="listenbrainz-custom-url-input"
|
||||||
|
placeholder="https://api.listenbrainz.org/1"
|
||||||
|
class="template-input"
|
||||||
|
style="width: 250px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
|
|
@ -2354,6 +2367,83 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Maloja Scrobbling</span>
|
||||||
|
<span class="description">Submit listens to a self-hosted Maloja server</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="maloja-enabled-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item" id="maloja-token-setting" style="display: none">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">API Key</span>
|
||||||
|
<span class="description">Found in your Maloja settings</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="maloja-token-input"
|
||||||
|
placeholder="Enter API Key"
|
||||||
|
class="template-input"
|
||||||
|
style="width: 250px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item" id="maloja-custom-url-setting" style="display: none">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Maloja Server URL</span>
|
||||||
|
<span class="description">Your Maloja instance URL</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="maloja-custom-url-input"
|
||||||
|
placeholder="https://maloja.example.com"
|
||||||
|
class="template-input"
|
||||||
|
style="width: 250px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Libre.fm Scrobbling</span>
|
||||||
|
<span class="description" id="librefm-status"
|
||||||
|
>Connect your Libre.fm account to scrobble tracks</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div id="librefm-controls">
|
||||||
|
<button id="librefm-connect-btn" class="btn-secondary">Connect Libre.fm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" id="librefm-toggle-setting" style="display: none">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Enable Scrobbling</span>
|
||||||
|
<span class="description">Automatically scrobble played tracks</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="librefm-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" id="librefm-love-setting" style="display: none">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Love on Like</span>
|
||||||
|
<span class="description"
|
||||||
|
>Automatically 'love' tracks on Libre.fm when you like them</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="librefm-love-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-tab-content" id="settings-tab-audio">
|
<div class="settings-tab-content" id="settings-tab-audio">
|
||||||
|
|
|
||||||
11
js/events.js
11
js/events.js
|
|
@ -10,7 +10,7 @@ import {
|
||||||
SVG_BIN,
|
SVG_BIN,
|
||||||
getTrackArtists,
|
getTrackArtists,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { lastFMStorage, waveformSettings } from './storage.js';
|
import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js';
|
||||||
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
||||||
import { downloadQualitySettings } from './storage.js';
|
import { downloadQualitySettings } from './storage.js';
|
||||||
import { updateTabTitle, navigate } from './router.js';
|
import { updateTabTitle, navigate } from './router.js';
|
||||||
|
|
@ -858,8 +858,13 @@ export async function handleTrackAction(
|
||||||
const added = await db.toggleFavorite(type, item);
|
const added = await db.toggleFavorite(type, item);
|
||||||
syncManager.syncLibraryItem(type, item, added);
|
syncManager.syncLibraryItem(type, item, added);
|
||||||
|
|
||||||
if (added && type === 'track' && scrobbler && lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
|
if (added && type === 'track' && scrobbler) {
|
||||||
scrobbler.loveTrack(item);
|
if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
|
||||||
|
scrobbler.loveTrack(item);
|
||||||
|
}
|
||||||
|
if (libreFmSettings.isEnabled() && libreFmSettings.shouldLoveOnLike()) {
|
||||||
|
scrobbler.loveTrack(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update all instances of this item's like button on the page
|
// Update all instances of this item's like button on the page
|
||||||
|
|
|
||||||
289
js/librefm.js
Normal file
289
js/librefm.js
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { libreFmSettings } from './storage.js';
|
||||||
|
|
||||||
|
export class LibreFmScrobbler {
|
||||||
|
constructor() {
|
||||||
|
this.API_KEY = 'monochrome_music_app';
|
||||||
|
this.API_SECRET = 'monochrome_music_secret_2024';
|
||||||
|
this.API_URL = 'https://libre.fm/2.0/';
|
||||||
|
|
||||||
|
this.sessionKey = null;
|
||||||
|
this.username = null;
|
||||||
|
this.currentTrack = null;
|
||||||
|
this.scrobbleTimer = null;
|
||||||
|
this.scrobbleThreshold = 0;
|
||||||
|
this.hasScrobbled = false;
|
||||||
|
|
||||||
|
this.loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession() {
|
||||||
|
try {
|
||||||
|
const session = localStorage.getItem('librefm-session');
|
||||||
|
if (session) {
|
||||||
|
const data = JSON.parse(session);
|
||||||
|
this.sessionKey = data.key;
|
||||||
|
this.username = data.name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to load Libre.fm session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSession(sessionKey, username) {
|
||||||
|
this.sessionKey = sessionKey;
|
||||||
|
this.username = username;
|
||||||
|
localStorage.setItem(
|
||||||
|
'librefm-session',
|
||||||
|
JSON.stringify({
|
||||||
|
key: sessionKey,
|
||||||
|
name: username,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSession() {
|
||||||
|
this.sessionKey = null;
|
||||||
|
this.username = null;
|
||||||
|
localStorage.removeItem('librefm-session');
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated() {
|
||||||
|
return !!this.sessionKey && libreFmSettings.isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
_getScrobbleArtist(track) {
|
||||||
|
if (!track) return 'Unknown Artist';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof artistName !== 'string') return '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();
|
||||||
|
|
||||||
|
return artistName || 'Unknown Artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateSignature(params) {
|
||||||
|
const filteredParams = { ...params };
|
||||||
|
delete filteredParams.format;
|
||||||
|
delete filteredParams.callback;
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(filteredParams).sort();
|
||||||
|
const signatureString = sortedKeys.map((key) => `${key}${filteredParams[key]}`).join('') + this.API_SECRET;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm');
|
||||||
|
return md5(signatureString);
|
||||||
|
} catch {
|
||||||
|
console.error('MD5 library not available');
|
||||||
|
throw new Error('MD5 library required for Libre.fm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeRequest(method, params = {}, requiresAuth = false) {
|
||||||
|
const requestParams = {
|
||||||
|
method,
|
||||||
|
api_key: this.API_KEY,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requiresAuth && this.sessionKey) {
|
||||||
|
requestParams.sk = this.sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = await this.generateSignature(requestParams);
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
...requestParams,
|
||||||
|
api_sig: signature,
|
||||||
|
format: 'json',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || 'Libre.fm API error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Libre.fm API request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthUrl() {
|
||||||
|
try {
|
||||||
|
// First, get a token from Libre.fm
|
||||||
|
const data = await this.makeRequest('auth.getToken');
|
||||||
|
const token = data.token;
|
||||||
|
|
||||||
|
localStorage.setItem('librefm-pending-token', token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
url: `https://libre.fm/api/auth/?api_key=${this.API_KEY}&token=${token}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get auth URL:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeAuthentication(token) {
|
||||||
|
try {
|
||||||
|
const data = await this.makeRequest('auth.getSession', { token });
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
this.saveSession(data.session.key, data.session.name);
|
||||||
|
localStorage.removeItem('librefm-pending-token');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
username: data.session.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No session returned');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNowPlaying(track) {
|
||||||
|
if (!this.isAuthenticated()) return;
|
||||||
|
|
||||||
|
this.currentTrack = track;
|
||||||
|
this.hasScrobbled = false;
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scrobbleTitle = track.cleanTitle || track.title;
|
||||||
|
const params = {
|
||||||
|
artist: this._getScrobbleArtist(track),
|
||||||
|
track: scrobbleTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (track.album?.title) {
|
||||||
|
params.album = track.album.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.duration) {
|
||||||
|
params.duration = Math.floor(track.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.trackNumber) {
|
||||||
|
params.trackNumber = track.trackNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.makeRequest('track.updateNowPlaying', params, true);
|
||||||
|
|
||||||
|
console.log('[Libre.fm] Now playing updated:', scrobbleTitle);
|
||||||
|
|
||||||
|
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
|
||||||
|
this.scheduleScrobble(this.scrobbleThreshold * 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Libre.fm] Failed to update now playing:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleScrobble(delay) {
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
|
||||||
|
this.scrobbleTimer = setTimeout(() => {
|
||||||
|
this.scrobbleCurrentTrack();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScrobbleTimer() {
|
||||||
|
if (this.scrobbleTimer) {
|
||||||
|
clearTimeout(this.scrobbleTimer);
|
||||||
|
this.scrobbleTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrobbleCurrentTrack() {
|
||||||
|
if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const scrobbleTitle = this.currentTrack.cleanTitle || this.currentTrack.title;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
artist: this._getScrobbleArtist(this.currentTrack),
|
||||||
|
track: scrobbleTitle,
|
||||||
|
timestamp: timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.currentTrack.album?.title) {
|
||||||
|
params.album = this.currentTrack.album.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentTrack.duration) {
|
||||||
|
params.duration = Math.floor(this.currentTrack.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentTrack.trackNumber) {
|
||||||
|
params.trackNumber = this.currentTrack.trackNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.makeRequest('track.scrobble', params, true);
|
||||||
|
|
||||||
|
this.hasScrobbled = true;
|
||||||
|
console.log('[Libre.fm] Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Libre.fm] Failed to scrobble:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loveTrack(track) {
|
||||||
|
if (!this.isAuthenticated()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
artist: this._getScrobbleArtist(track),
|
||||||
|
track: track.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.makeRequest('track.love', params, true);
|
||||||
|
console.log('[Libre.fm] Loved track:', track.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Libre.fm] Failed to love track:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTrackChange(track) {
|
||||||
|
if (!this.isAuthenticated()) return;
|
||||||
|
this.updateNowPlaying(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlaybackStop() {
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.clearSession();
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
this.currentTrack = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,18 @@ import { listenBrainzSettings } from './storage.js';
|
||||||
|
|
||||||
export class ListenBrainzScrobbler {
|
export class ListenBrainzScrobbler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.API_URL = 'https://api.listenbrainz.org/1';
|
this.DEFAULT_API_URL = 'https://api.listenbrainz.org/1';
|
||||||
this.currentTrack = null;
|
this.currentTrack = null;
|
||||||
this.scrobbleTimer = null;
|
this.scrobbleTimer = null;
|
||||||
this.scrobbleThreshold = 0;
|
this.scrobbleThreshold = 0;
|
||||||
this.hasScrobbled = false;
|
this.hasScrobbled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApiUrl() {
|
||||||
|
const customUrl = listenBrainzSettings.getCustomUrl();
|
||||||
|
return customUrl || this.DEFAULT_API_URL;
|
||||||
|
}
|
||||||
|
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
return listenBrainzSettings.isEnabled() && !!listenBrainzSettings.getToken();
|
return listenBrainzSettings.isEnabled() && !!listenBrainzSettings.getToken();
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +94,8 @@ export class ListenBrainzScrobbler {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.API_URL}/submit-listens`, {
|
const apiUrl = this.getApiUrl();
|
||||||
|
const response = await fetch(`${apiUrl}/submit-listens`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Token ${this.getToken()}`,
|
Authorization: `Token ${this.getToken()}`,
|
||||||
|
|
|
||||||
163
js/maloja.js
Normal file
163
js/maloja.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { malojaSettings } from './storage.js';
|
||||||
|
|
||||||
|
export class MalojaScrobbler {
|
||||||
|
constructor() {
|
||||||
|
this.currentTrack = null;
|
||||||
|
this.scrobbleTimer = null;
|
||||||
|
this.scrobbleThreshold = 0;
|
||||||
|
this.hasScrobbled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiUrl() {
|
||||||
|
const customUrl = malojaSettings.getCustomUrl();
|
||||||
|
// Remove trailing slash if present
|
||||||
|
return customUrl ? customUrl.replace(/\/$/, '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return malojaSettings.isEnabled() && !!malojaSettings.getToken() && !!this.getApiUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiKey() {
|
||||||
|
return malojaSettings.getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
_getScrobbleArtist(track) {
|
||||||
|
if (!track) return 'Unknown Artist';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof artistName !== 'string') return '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();
|
||||||
|
|
||||||
|
return artistName || 'Unknown Artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitScrobble(track, timestamp = null) {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
const apiUrl = this.getApiUrl();
|
||||||
|
const apiKey = this.getApiKey();
|
||||||
|
|
||||||
|
if (!apiUrl || !apiKey) return;
|
||||||
|
|
||||||
|
const artist = this._getScrobbleArtist(track);
|
||||||
|
const title = track.cleanTitle || track.title;
|
||||||
|
|
||||||
|
// Build the scrobble data
|
||||||
|
const scrobbleData = {
|
||||||
|
artist: artist,
|
||||||
|
title: title,
|
||||||
|
key: apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (track.album?.title) {
|
||||||
|
scrobbleData.album = track.album.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.duration) {
|
||||||
|
scrobbleData.duration = Math.floor(track.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.trackNumber) {
|
||||||
|
scrobbleData.track_number = track.trackNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp) {
|
||||||
|
scrobbleData.time = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try the newer Maloja API format first (mlj_1)
|
||||||
|
let response = await fetch(`${apiUrl}/apis/mlj_1/newscrobble`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(scrobbleData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Fallback to older API format
|
||||||
|
response = await fetch(`${apiUrl}/apis/native/newscrobble`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(scrobbleData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Maloja API Error ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Maloja] Submitted scrobble: ${title} by ${artist}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maloja] Submission failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNowPlaying(track) {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
this.currentTrack = track;
|
||||||
|
this.hasScrobbled = false;
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
|
||||||
|
// Maloja doesn't have a separate "now playing" endpoint like Last.fm
|
||||||
|
// It just scrobbles when the track is actually played
|
||||||
|
// We'll set up the timer to scrobble after the threshold
|
||||||
|
|
||||||
|
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.submitScrobble(this.currentTrack, timestamp);
|
||||||
|
this.hasScrobbled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTrackChange(track) {
|
||||||
|
this.updateNowPlaying(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlaybackStop() {
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.clearScrobbleTimer();
|
||||||
|
this.currentTrack = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { LastFMScrobbler } from './lastfm.js';
|
import { LastFMScrobbler } from './lastfm.js';
|
||||||
import { ListenBrainzScrobbler } from './listenbrainz.js';
|
import { ListenBrainzScrobbler } from './listenbrainz.js';
|
||||||
|
import { MalojaScrobbler } from './maloja.js';
|
||||||
|
import { LibreFmScrobbler } from './librefm.js';
|
||||||
|
|
||||||
export class MultiScrobbler {
|
export class MultiScrobbler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.lastfm = new LastFMScrobbler();
|
this.lastfm = new LastFMScrobbler();
|
||||||
this.listenbrainz = new ListenBrainzScrobbler();
|
this.listenbrainz = new ListenBrainzScrobbler();
|
||||||
|
this.maloja = new MalojaScrobbler();
|
||||||
|
this.librefm = new LibreFmScrobbler();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy method for Last.fm specific usage (auth flow)
|
// Proxy method for Last.fm specific usage (auth flow)
|
||||||
|
|
@ -12,30 +16,47 @@ export class MultiScrobbler {
|
||||||
return this.lastfm;
|
return this.lastfm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proxy method for Libre.fm specific usage (auth flow)
|
||||||
|
getLibreFm() {
|
||||||
|
return this.librefm;
|
||||||
|
}
|
||||||
|
|
||||||
isAuthenticated() {
|
isAuthenticated() {
|
||||||
// Return true if any service is configured, so events.js will proceed to call updateNowPlaying
|
// Return true if any service is configured, so events.js will proceed to call updateNowPlaying
|
||||||
// Individual services check their own enabled/auth state internally
|
// Individual services check their own enabled/auth state internally
|
||||||
return this.lastfm.isAuthenticated() || this.listenbrainz.isEnabled();
|
return (
|
||||||
|
this.lastfm.isAuthenticated() ||
|
||||||
|
this.listenbrainz.isEnabled() ||
|
||||||
|
this.maloja.isEnabled() ||
|
||||||
|
this.librefm.isAuthenticated()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNowPlaying(track) {
|
updateNowPlaying(track) {
|
||||||
this.lastfm.updateNowPlaying(track);
|
this.lastfm.updateNowPlaying(track);
|
||||||
this.listenbrainz.updateNowPlaying(track);
|
this.listenbrainz.updateNowPlaying(track);
|
||||||
|
this.maloja.updateNowPlaying(track);
|
||||||
|
this.librefm.updateNowPlaying(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
onTrackChange(track) {
|
onTrackChange(track) {
|
||||||
this.lastfm.onTrackChange(track);
|
this.lastfm.onTrackChange(track);
|
||||||
this.listenbrainz.onTrackChange(track);
|
this.listenbrainz.onTrackChange(track);
|
||||||
|
this.maloja.onTrackChange(track);
|
||||||
|
this.librefm.onTrackChange(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
onPlaybackStop() {
|
onPlaybackStop() {
|
||||||
this.lastfm.onPlaybackStop();
|
this.lastfm.onPlaybackStop();
|
||||||
this.listenbrainz.onPlaybackStop();
|
this.listenbrainz.onPlaybackStop();
|
||||||
|
this.maloja.onPlaybackStop();
|
||||||
|
this.librefm.onPlaybackStop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Love/Like is currently Last.fm specific in the UI, but we can extend later
|
// Love/Like tracks on all services that support it
|
||||||
async loveTrack(track) {
|
async loveTrack(track) {
|
||||||
await this.lastfm.loveTrack(track);
|
await this.lastfm.loveTrack(track);
|
||||||
// ListenBrainz feedback could be added here
|
await this.librefm.loveTrack(track);
|
||||||
|
// ListenBrainz and Maloja feedback could be added here when supported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
164
js/settings.js
164
js/settings.js
|
|
@ -18,6 +18,8 @@ import {
|
||||||
playlistSettings,
|
playlistSettings,
|
||||||
equalizerSettings,
|
equalizerSettings,
|
||||||
listenBrainzSettings,
|
listenBrainzSettings,
|
||||||
|
malojaSettings,
|
||||||
|
libreFmSettings,
|
||||||
homePageSettings,
|
homePageSettings,
|
||||||
sidebarSectionSettings,
|
sidebarSectionSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
|
|
@ -223,13 +225,17 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// ========================================
|
// ========================================
|
||||||
const lbToggle = document.getElementById('listenbrainz-enabled-toggle');
|
const lbToggle = document.getElementById('listenbrainz-enabled-toggle');
|
||||||
const lbTokenSetting = document.getElementById('listenbrainz-token-setting');
|
const lbTokenSetting = document.getElementById('listenbrainz-token-setting');
|
||||||
|
const lbCustomUrlSetting = document.getElementById('listenbrainz-custom-url-setting');
|
||||||
const lbTokenInput = document.getElementById('listenbrainz-token-input');
|
const lbTokenInput = document.getElementById('listenbrainz-token-input');
|
||||||
|
const lbCustomUrlInput = document.getElementById('listenbrainz-custom-url-input');
|
||||||
|
|
||||||
const updateListenBrainzUI = () => {
|
const updateListenBrainzUI = () => {
|
||||||
const isEnabled = listenBrainzSettings.isEnabled();
|
const isEnabled = listenBrainzSettings.isEnabled();
|
||||||
if (lbToggle) lbToggle.checked = isEnabled;
|
if (lbToggle) lbToggle.checked = isEnabled;
|
||||||
if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||||
|
if (lbCustomUrlSetting) lbCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||||
if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken();
|
if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken();
|
||||||
|
if (lbCustomUrlInput) lbCustomUrlInput.value = listenBrainzSettings.getCustomUrl();
|
||||||
};
|
};
|
||||||
|
|
||||||
updateListenBrainzUI();
|
updateListenBrainzUI();
|
||||||
|
|
@ -248,6 +254,164 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lbCustomUrlInput) {
|
||||||
|
lbCustomUrlInput.addEventListener('change', (e) => {
|
||||||
|
listenBrainzSettings.setCustomUrl(e.target.value.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Maloja Settings
|
||||||
|
// ========================================
|
||||||
|
const malojaToggle = document.getElementById('maloja-enabled-toggle');
|
||||||
|
const malojaTokenSetting = document.getElementById('maloja-token-setting');
|
||||||
|
const malojaCustomUrlSetting = document.getElementById('maloja-custom-url-setting');
|
||||||
|
const malojaTokenInput = document.getElementById('maloja-token-input');
|
||||||
|
const malojaCustomUrlInput = document.getElementById('maloja-custom-url-input');
|
||||||
|
|
||||||
|
const updateMalojaUI = () => {
|
||||||
|
const isEnabled = malojaSettings.isEnabled();
|
||||||
|
if (malojaToggle) malojaToggle.checked = isEnabled;
|
||||||
|
if (malojaTokenSetting) malojaTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||||
|
if (malojaCustomUrlSetting) malojaCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none';
|
||||||
|
if (malojaTokenInput) malojaTokenInput.value = malojaSettings.getToken();
|
||||||
|
if (malojaCustomUrlInput) malojaCustomUrlInput.value = malojaSettings.getCustomUrl();
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMalojaUI();
|
||||||
|
|
||||||
|
if (malojaToggle) {
|
||||||
|
malojaToggle.addEventListener('change', (e) => {
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
malojaSettings.setEnabled(enabled);
|
||||||
|
updateMalojaUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (malojaTokenInput) {
|
||||||
|
malojaTokenInput.addEventListener('change', (e) => {
|
||||||
|
malojaSettings.setToken(e.target.value.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (malojaCustomUrlInput) {
|
||||||
|
malojaCustomUrlInput.addEventListener('change', (e) => {
|
||||||
|
malojaSettings.setCustomUrl(e.target.value.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Libre.fm Settings
|
||||||
|
// ========================================
|
||||||
|
const librefmConnectBtn = document.getElementById('librefm-connect-btn');
|
||||||
|
const librefmStatus = document.getElementById('librefm-status');
|
||||||
|
const librefmToggle = document.getElementById('librefm-toggle');
|
||||||
|
const librefmToggleSetting = document.getElementById('librefm-toggle-setting');
|
||||||
|
const librefmLoveToggle = document.getElementById('librefm-love-toggle');
|
||||||
|
const librefmLoveSetting = document.getElementById('librefm-love-setting');
|
||||||
|
|
||||||
|
function updateLibreFmUI() {
|
||||||
|
if (scrobbler.librefm.isAuthenticated()) {
|
||||||
|
librefmStatus.textContent = `Connected as ${scrobbler.librefm.username}`;
|
||||||
|
librefmConnectBtn.textContent = 'Disconnect';
|
||||||
|
librefmConnectBtn.classList.add('danger');
|
||||||
|
librefmToggleSetting.style.display = 'flex';
|
||||||
|
librefmLoveSetting.style.display = 'flex';
|
||||||
|
librefmToggle.checked = libreFmSettings.isEnabled();
|
||||||
|
librefmLoveToggle.checked = libreFmSettings.shouldLoveOnLike();
|
||||||
|
} else {
|
||||||
|
librefmStatus.textContent = 'Connect your Libre.fm account to scrobble tracks';
|
||||||
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||||
|
librefmConnectBtn.classList.remove('danger');
|
||||||
|
librefmToggleSetting.style.display = 'none';
|
||||||
|
librefmLoveSetting.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (librefmConnectBtn) {
|
||||||
|
updateLibreFmUI();
|
||||||
|
|
||||||
|
librefmConnectBtn.addEventListener('click', async () => {
|
||||||
|
if (scrobbler.librefm.isAuthenticated()) {
|
||||||
|
if (confirm('Disconnect from Libre.fm?')) {
|
||||||
|
scrobbler.librefm.disconnect();
|
||||||
|
updateLibreFmUI();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authWindow = window.open('', '_blank');
|
||||||
|
librefmConnectBtn.disabled = true;
|
||||||
|
librefmConnectBtn.textContent = 'Opening Libre.fm...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token, url } = await scrobbler.librefm.getAuthUrl();
|
||||||
|
|
||||||
|
if (authWindow) {
|
||||||
|
authWindow.location.href = url;
|
||||||
|
} else {
|
||||||
|
alert('Popup blocked! Please allow popups.');
|
||||||
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||||
|
librefmConnectBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
librefmConnectBtn.textContent = 'Waiting for authorization...';
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30;
|
||||||
|
|
||||||
|
const checkAuth = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (attempts > maxAttempts) {
|
||||||
|
clearInterval(checkAuth);
|
||||||
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||||
|
librefmConnectBtn.disabled = false;
|
||||||
|
if (authWindow && !authWindow.closed) authWindow.close();
|
||||||
|
alert('Authorization timed out. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await scrobbler.librefm.completeAuthentication(token);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
clearInterval(checkAuth);
|
||||||
|
if (authWindow && !authWindow.closed) authWindow.close();
|
||||||
|
updateLibreFmUI();
|
||||||
|
librefmConnectBtn.disabled = false;
|
||||||
|
libreFmSettings.setEnabled(true);
|
||||||
|
librefmToggle.checked = true;
|
||||||
|
alert(`Successfully connected to Libre.fm as ${result.username}!`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Still waiting
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Libre.fm connection failed:', error);
|
||||||
|
alert('Failed to connect to Libre.fm: ' + error.message);
|
||||||
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
||||||
|
librefmConnectBtn.disabled = false;
|
||||||
|
if (authWindow && !authWindow.closed) authWindow.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Libre.fm Toggles
|
||||||
|
if (librefmToggle) {
|
||||||
|
librefmToggle.addEventListener('change', (e) => {
|
||||||
|
libreFmSettings.setEnabled(e.target.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (librefmLoveToggle) {
|
||||||
|
librefmLoveToggle.addEventListener('change', (e) => {
|
||||||
|
libreFmSettings.setLoveOnLike(e.target.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Theme picker
|
// Theme picker
|
||||||
const themePicker = document.getElementById('theme-picker');
|
const themePicker = document.getElementById('theme-picker');
|
||||||
const currentTheme = themeManager.getTheme();
|
const currentTheme = themeManager.getTheme();
|
||||||
|
|
|
||||||
|
|
@ -902,6 +902,7 @@ export const queueManager = {
|
||||||
export const listenBrainzSettings = {
|
export const listenBrainzSettings = {
|
||||||
ENABLED_KEY: 'listenbrainz-enabled',
|
ENABLED_KEY: 'listenbrainz-enabled',
|
||||||
TOKEN_KEY: 'listenbrainz-token',
|
TOKEN_KEY: 'listenbrainz-token',
|
||||||
|
CUSTOM_URL_KEY: 'listenbrainz-custom-url',
|
||||||
|
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -926,6 +927,89 @@ export const listenBrainzSettings = {
|
||||||
setToken(token) {
|
setToken(token) {
|
||||||
localStorage.setItem(this.TOKEN_KEY, token);
|
localStorage.setItem(this.TOKEN_KEY, token);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCustomUrl() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.CUSTOM_URL_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCustomUrl(url) {
|
||||||
|
localStorage.setItem(this.CUSTOM_URL_KEY, url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const malojaSettings = {
|
||||||
|
ENABLED_KEY: 'maloja-enabled',
|
||||||
|
TOKEN_KEY: 'maloja-token',
|
||||||
|
CUSTOM_URL_KEY: 'maloja-custom-url',
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomUrl() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.CUSTOM_URL_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCustomUrl(url) {
|
||||||
|
localStorage.setItem(this.CUSTOM_URL_KEY, url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const libreFmSettings = {
|
||||||
|
ENABLED_KEY: 'librefm-enabled',
|
||||||
|
LOVE_ON_LIKE_KEY: 'librefm-love-on-like',
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.ENABLED_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
|
||||||
|
},
|
||||||
|
|
||||||
|
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 homePageSettings = {
|
export const homePageSettings = {
|
||||||
|
|
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -74,7 +74,6 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1604,7 +1603,6 @@
|
||||||
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/serialize": "^1.1.1"
|
"@keyv/serialize": "^1.1.1"
|
||||||
}
|
}
|
||||||
|
|
@ -1646,7 +1644,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -1690,7 +1687,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -3142,7 +3138,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
|
||||||
"integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
|
"integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
|
|
@ -3191,7 +3186,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3215,7 +3209,6 @@
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|
@ -3503,7 +3496,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -4288,7 +4280,6 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -6645,7 +6636,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -6729,7 +6719,6 @@
|
||||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
|
|
@ -7679,7 +7668,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
|
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
|
||||||
|
|
@ -8094,7 +8082,6 @@
|
||||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
|
|
@ -8419,7 +8406,6 @@
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -8807,7 +8793,6 @@
|
||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue