lastfm integration

This commit is contained in:
Eduard Prigoana 2025-10-21 18:18:03 +03:00
parent b0f6a11a94
commit 8ebc1542d5
8 changed files with 418 additions and 235 deletions

View file

@ -222,7 +222,26 @@
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
</div>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Last.fm Scrobbling</span>
<span class="description" id="lastfm-status">Connect your Last.fm account to scrobble tracks</span>
</div>
<div id="lastfm-controls">
<button id="lastfm-connect-btn" class="btn-secondary">Connect Last.fm</button>
</div>
</div>
<div class="setting-item" id="lastfm-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="lastfm-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Audio Quality</span>

138
js/app.js
View file

@ -1,7 +1,8 @@
import { LosslessAPI } from './api.js';
import { apiSettings, themeManager } from './storage.js';
import { apiSettings, themeManager, lastFMStorage } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import { LastFMScrobbler } from './lastfm.js';
import {
REPEAT_MODE, SVG_PLAY, SVG_PAUSE,
SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore,
@ -337,6 +338,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
const player = new Player(audioPlayer, api, currentQuality);
const scrobbler = new LastFMScrobbler();
const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true';
const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5');
player.setCrossfade(savedCrossfade, savedCrossfadeDuration);
@ -371,6 +374,90 @@ document.addEventListener('DOMContentLoaded', async () => {
let contextTrack = null;
let draggedQueueIndex = null;
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
const lastfmStatus = document.getElementById('lastfm-status');
const lastfmToggle = document.getElementById('lastfm-toggle');
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
function updateLastFMUI() {
if (scrobbler.isAuthenticated()) {
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
lastfmConnectBtn.textContent = 'Disconnect';
lastfmConnectBtn.classList.add('danger');
lastfmToggleSetting.style.display = 'flex';
lastfmToggle.checked = lastFMStorage.isEnabled();
} else {
lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.classList.remove('danger');
lastfmToggleSetting.style.display = 'none';
}
}
updateLastFMUI();
lastfmConnectBtn?.addEventListener('click', async () => {
if (scrobbler.isAuthenticated()) {
if (confirm('Disconnect from Last.fm?')) {
scrobbler.disconnect();
updateLastFMUI();
}
} else {
try {
lastfmConnectBtn.disabled = true;
lastfmConnectBtn.textContent = 'Opening Last.fm...';
const { token, url } = await scrobbler.getAuthUrl();
const authWindow = window.open(url, 'lastfm-auth', 'width=800,height=600');
lastfmConnectBtn.textContent = 'Waiting for authorization...';
let attempts = 0;
const maxAttempts = 30;
const checkAuth = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(checkAuth);
lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.disabled = false;
alert('Authorization timed out. Please try again.');
return;
}
try {
const result = await scrobbler.completeAuthentication(token);
if (result.success) {
clearInterval(checkAuth);
if (authWindow && !authWindow.closed) {
authWindow.close();
}
updateLastFMUI();
lastfmConnectBtn.disabled = false;
lastFMStorage.setEnabled(true);
lastfmToggle.checked = true;
alert(`Successfully connected to Last.fm as ${result.username}!`);
}
} catch (e) {
}
}, 2000);
} catch (error) {
console.error('Last.fm connection failed:', error);
alert('Failed to connect to Last.fm: ' + error.message);
lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.disabled = false;
}
}
});
lastfmToggle?.addEventListener('change', (e) => {
lastFMStorage.setEnabled(e.target.checked);
});
const themePicker = document.getElementById('theme-picker');
themePicker.querySelectorAll('.theme-option').forEach(option => {
if (option.dataset.theme === currentTheme) {
@ -392,29 +479,31 @@ document.addEventListener('DOMContentLoaded', async () => {
}
});
});
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('refresh-speed-test-btn');
const originalText = btn.textContent;
btn.textContent = 'Testing...';
btn.disabled = true;
try {
await apiSettings.refreshSpeedTests();
ui.renderApiSettings();
btn.textContent = 'Done!';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
} catch (error) {
console.error('Failed to refresh speed tests:', error);
btn.textContent = 'Error';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
}
});
const btn = document.getElementById('refresh-speed-test-btn');
const originalText = btn.textContent;
btn.textContent = 'Testing...';
btn.disabled = true;
try {
await apiSettings.refreshSpeedTests();
ui.renderApiSettings();
btn.textContent = 'Done!';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
} catch (error) {
console.error('Failed to refresh speed tests:', error);
btn.textContent = 'Error';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
}
});
function renderCustomThemeEditor() {
const grid = document.getElementById('theme-color-grid');
const customTheme = themeManager.getCustomTheme() || {
@ -809,6 +898,9 @@ document.addEventListener('DOMContentLoaded', async () => {
});
audioPlayer.addEventListener('play', () => {
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
scrobbler.updateNowPlaying(player.currentTrack);
}
playPauseBtn.innerHTML = SVG_PAUSE;
player.updateMediaSessionPlaybackState();
});

248
js/lastfm.js Normal file
View file

@ -0,0 +1,248 @@
import { delay } from './utils.js';
export class LastFMScrobbler {
constructor() {
this.API_KEY = '0fc32c426d943d34a662977b31b98b67';
this.API_SECRET = '53acf2466be726db021e7fdfd0ad1084';
this.API_URL = 'https://ws.audioscrobbler.com/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('lastfm-session');
if (session) {
const data = JSON.parse(session);
this.sessionKey = data.key;
this.username = data.name;
}
} catch (e) {
console.error('Failed to load Last.fm session:', e);
}
}
saveSession(sessionKey, username) {
this.sessionKey = sessionKey;
this.username = username;
localStorage.setItem('lastfm-session', JSON.stringify({
key: sessionKey,
name: username
}));
}
clearSession() {
this.sessionKey = null;
this.username = null;
localStorage.removeItem('lastfm-session');
}
isAuthenticated() {
return !!this.sessionKey;
}
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;
console.log('Signature string:', signatureString);
try {
const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm');
return md5(signatureString);
} catch (e) {
console.error('MD5 library not available');
throw new Error('MD5 library required for Last.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 || 'Last.fm API error');
}
return data;
} catch (error) {
console.error('Last.fm API request failed:', error);
throw error;
}
}
async getAuthUrl() {
try {
const data = await this.makeRequest('auth.getToken');
const token = data.token;
return {
token,
url: `https://www.last.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);
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 params = {
artist: track.artist?.name || 'Unknown Artist',
track: track.title
};
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('Now playing updated:', track.title);
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
this.scheduleScrobble(this.scrobbleThreshold * 1000);
} catch (error) {
console.error('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 params = {
artist: this.currentTrack.artist?.name || 'Unknown Artist',
track: this.currentTrack.title,
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('Scrobbled:', this.currentTrack.title);
} catch (error) {
console.error('Failed to scrobble:', error);
}
}
onTrackChange(track) {
if (!this.isAuthenticated()) return;
this.updateNowPlaying(track);
}
onPlaybackStop() {
this.clearScrobbleTimer();
}
disconnect() {
this.clearSession();
this.clearScrobbleTimer();
this.currentTrack = null;
}
}

View file

@ -1,210 +0,0 @@
export class MetadataEmbedder {
constructor() {
this.ffmpegLoaded = false;
this.ffmpeg = null;
this.fetchFile = null;
}
async loadFFmpeg() {
if (this.ffmpegLoaded) return;
try {
console.log('[FFmpeg] Loading FFmpeg...');
if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') {
throw new Error('FFmpeg libraries not loaded. Please check your internet connection.');
}
const { FFmpeg } = FFmpegWASM;
const { fetchFile } = FFmpegUtil;
this.ffmpeg = new FFmpeg();
this.fetchFile = fetchFile;
this.ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message);
});
const baseURL = window.location.origin + '/ffmpeg';
await this.ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`
});
this.ffmpegLoaded = true;
console.log('[FFmpeg] Loaded successfully');
} catch (error) {
console.error('[FFmpeg] Failed to load:', error);
throw error;
}
}
async embedMetadata(audioBlob, track, coverImageUrl, onProgress) {
console.log('[Metadata] Starting embedding for:', track.title);
if (!this.ffmpegLoaded) {
try {
await this.loadFFmpeg();
} catch (error) {
console.error('[Metadata] Cannot load FFmpeg, skipping metadata:', error);
return audioBlob;
}
}
if (!this.ffmpeg || !this.fetchFile) {
console.error('[Metadata] FFmpeg not properly initialized');
return audioBlob;
}
const inputName = 'input.flac';
const coverName = 'cover.jpg';
const outputName = 'output.flac';
try {
const arrayBuffer = await audioBlob.arrayBuffer();
await this.ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer));
console.log('[Metadata] Wrote input file:', inputName, 'size:', arrayBuffer.byteLength);
let hasCover = false;
if (coverImageUrl) {
try {
console.log('[Metadata] Fetching cover from:', coverImageUrl);
const coverData = await this.fetchFile(coverImageUrl);
await this.ffmpeg.writeFile(coverName, coverData);
hasCover = true;
console.log('[Metadata] Cover image written successfully, size:', coverData.length);
} catch (coverError) {
console.warn('[Metadata] Failed to fetch cover image:', coverError);
}
}
const metadata = this.buildMetadataArgs(track);
console.log('[Metadata] Building metadata with', metadata.length / 2, 'fields');
let args;
if (hasCover) {
args = [
'-i', inputName,
'-i', coverName,
'-map', '0:a',
'-map', '1',
'-c:a', 'copy',
'-c:v', 'copy',
...metadata,
'-metadata:s:v', 'title=Album cover',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic',
outputName
];
} else {
args = [
'-i', inputName,
...metadata,
'-c:a', 'copy',
outputName
];
}
console.log('[Metadata] Executing FFmpeg...');
if (onProgress) {
this.ffmpeg.on('progress', ({ progress }) => {
onProgress(progress);
});
}
await this.ffmpeg.exec(args);
console.log('[Metadata] FFmpeg exec completed successfully');
const outputData = await this.ffmpeg.readFile(outputName);
const outputBlob = new Blob([outputData], { type: 'audio/flac' });
console.log('[Metadata] ✓ Success! Input:', arrayBuffer.byteLength, 'bytes → Output:', outputBlob.size, 'bytes');
await this.ffmpeg.deleteFile(inputName);
await this.ffmpeg.deleteFile(outputName);
if (hasCover) {
await this.ffmpeg.deleteFile(coverName);
}
console.log('[Metadata] Cleanup complete');
return outputBlob;
} catch (error) {
console.error('[Metadata] ✗ Embedding failed:', error);
console.error('[Metadata] Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
return audioBlob;
}
}
buildMetadataArgs(track) {
const args = [];
if (track.title) {
args.push('-metadata', `title=${this.escapeMetadata(track.title)}`);
}
if (track.artist?.name) {
args.push('-metadata', `artist=${this.escapeMetadata(track.artist.name)}`);
}
if (track.album?.title) {
args.push('-metadata', `album=${this.escapeMetadata(track.album.title)}`);
}
if (track.album?.artist?.name) {
args.push('-metadata', `album_artist=${this.escapeMetadata(track.album.artist.name)}`);
}
if (track.trackNumber) {
const trackNum = Number(track.trackNumber);
if (Number.isFinite(trackNum) && trackNum > 0) {
const totalTracks = track.album?.numberOfTracks;
if (totalTracks && Number.isFinite(totalTracks) && totalTracks > 0) {
args.push('-metadata', `track=${trackNum}/${totalTracks}`);
} else {
args.push('-metadata', `track=${trackNum}`);
}
}
}
if (track.volumeNumber) {
const discNum = Number(track.volumeNumber);
if (Number.isFinite(discNum) && discNum > 0) {
const totalDiscs = track.album?.numberOfVolumes;
if (totalDiscs && Number.isFinite(totalDiscs) && totalDiscs > 0) {
args.push('-metadata', `disc=${discNum}/${totalDiscs}`);
} else {
args.push('-metadata', `disc=${discNum}`);
}
}
}
if (track.album?.releaseDate) {
const year = new Date(track.album.releaseDate).getFullYear();
if (!isNaN(year)) {
args.push('-metadata', `date=${year}`);
args.push('-metadata', `year=${year}`);
}
}
if (track.album?.upc) {
args.push('-metadata', `barcode=${track.album.upc}`);
}
if (track.isrc) {
args.push('-metadata', `isrc=${track.isrc}`);
}
args.push('-metadata', 'comment=https://monochrome.tf/');
return args;
}
escapeMetadata(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
}

View file

@ -276,4 +276,20 @@ export const themeManager = {
root.style.setProperty(`--${key}`, value);
}
}
};
export const lastFMStorage = {
STORAGE_KEY: 'lastfm-enabled',
isEnabled() {
try {
return localStorage.getItem(this.STORAGE_KEY) === 'true';
} catch (e) {
return false;
}
},
setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
}
};

View file

@ -1,3 +1,4 @@
//utils.js
export const QUALITY = 'LOSSLESS';
export const REPEAT_MODE = {

View file

@ -27,7 +27,10 @@
"purpose": "maskable"
}
],
"categories": ["music", "entertainment"],
"categories": [
"music",
"entertainment"
],
"shortcuts": [
{
"name": "Search",

View file

@ -1748,4 +1748,18 @@ input:checked + .slider:before {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;
}
}
.btn-secondary.danger {
background: #ef4444;
color: white;
}
.btn-secondary.danger:hover {
background: #dc2626;
}
#lastfm-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}