From a6dfc77244d8503833b38ffd22295467771ee32b Mon Sep 17 00:00:00 2001 From: Cameron Redmore Date: Fri, 8 Aug 2025 12:19:45 +0100 Subject: [PATCH] Implement WebSocket connection for real-time song updates and refactor Last.fm song fetching logic --- src/client.ts | 173 ++++++++++++++++++++++++++++++++++++-------------- src/server.ts | 164 ++++++++++++++++++++++++++++------------------- 2 files changed, 225 insertions(+), 112 deletions(-) diff --git a/src/client.ts b/src/client.ts index e3b1655..51d95d2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,22 +18,56 @@ interface LastSong { image: LastSongImage[]; } -// Function to fetch and display last song -async function fetchLastSong(): Promise { - try { - const { data, error } = await client.api['last-song'].get(); - - if (error) { - console.error('Error fetching last song:', error); - updateLastSongDisplay(null, 'Failed to load song data'); - return; - } +// WebSocket connection +let musicWebSocket: any = null; +let connected = false; + +// Function to connect to WebSocket for real-time updates +function connectMusicWebSocket(): void { + try { + connected = false; + + // Subscribe to the music WebSocket endpoint + musicWebSocket = client.music.subscribe(); + + musicWebSocket.on('open', () => { + console.log('Connected to music WebSocket'); + connected = true; + }); + + musicWebSocket.subscribe((evt: any) => { + const {data} = evt; + console.log('Received music update:', data); + if (data.type === 'song-update') { + updateLastSongDisplay(data.data); + } + }); + + musicWebSocket.on('close', () => { + if(!connected) + { + return; + } + console.log('Music WebSocket disconnected, attempting to reconnect...'); + updateLastSongDisplay(null, 'Connection lost, reconnecting...'); + // Attempt to reconnect after a delay + setTimeout(connectMusicWebSocket, 3000); + }); + + musicWebSocket.on('error', (error: any) => { + if(!connected) + { + return; + } + console.error('Music WebSocket error:', error); + updateLastSongDisplay(null, 'Connection error occurred'); + }); - const song = data as LastSong; - updateLastSongDisplay(song); } catch (error) { - console.error('Network error:', error); - updateLastSongDisplay(null, 'Network error occurred'); + console.error('Failed to connect to music WebSocket:', error); + updateLastSongDisplay(null, 'Failed to connect to music service'); + // Retry connection after delay + setTimeout(connectMusicWebSocket, 5000); } } @@ -73,23 +107,34 @@ function getOptimalImageUrl(images: LastSongImage[]): string { return ''; } +// Function to preload an image and return a promise +function preloadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); +} + // Function to update the UI with song information -function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): void { +async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): Promise { + console.log('Updating last song display:', song, errorMessage); const containers = [ document.getElementById('last-song-container'), document.getElementById('last-song-container-mobile') ]; - containers.forEach(container => { - if (!container) return; + for (const container of containers) { + if (!container) continue; if (errorMessage || !song) { container.innerHTML = ` -
+
${errorMessage || 'No song data available'}
`; - return; + continue; } const statusText = song.nowPlaying ? 'Currently Listening To' : 'Last Played'; @@ -102,14 +147,64 @@ function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): vo ${song.album ? `
from "${song.album}"
` : ''} `; - const imageElement = imageUrl - ? `${song.name}` - : `
- - - -
`; + let imageElement: string; + + // If there's an image URL, preload it before updating the display + if (imageUrl) { + try { + // Show loading state while preloading + const loadingElement = ` +
+
+
+ `; + + // Temporarily show loading state + container.innerHTML = ` + + `; + + // Preload the image + await preloadImage(imageUrl); + + // Now set the actual image + imageElement = `${song.name}`; + } catch (error) { + console.warn('Failed to preload image:', imageUrl, error); + // Fallback to default icon if image fails to load + imageElement = ` +
+ + + +
+ `; + } + } else { + // No image URL, use default icon + imageElement = ` +
+ + + +
+ `; + } + // Final update with the actual image container.innerHTML = `
@@ -126,35 +221,21 @@ function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): vo
`; - }); -} - -// Auto-refresh functionality -let refreshInterval: number; - -function startAutoRefresh(): void { - // Fetch immediately - fetchLastSong(); - - // Then fetch every 30 seconds - refreshInterval = window.setInterval(fetchLastSong, 30000); -} - -function stopAutoRefresh(): void { - if (refreshInterval) { - clearInterval(refreshInterval); } } // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { - startAutoRefresh(); + // Connect to WebSocket for real-time updates + connectMusicWebSocket(); }); // Clean up on page unload window.addEventListener('beforeunload', () => { - stopAutoRefresh(); + if (musicWebSocket) { + musicWebSocket.close(); + } }); // Export functions for manual control if needed -export { fetchLastSong, startAutoRefresh, stopAutoRefresh }; +export { connectMusicWebSocket }; diff --git a/src/server.ts b/src/server.ts index e2dbb6b..eb186c0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,87 +1,119 @@ import { Elysia, t } from 'elysia'; import { staticPlugin } from '@elysiajs/static'; -// Simple cache implementation -interface CacheEntry { - data: any; - timestamp: number; +// Song data interface +interface LastSong { + name: string; + artist: string; + album: string; + url: string; + nowPlaying: boolean; + image: any[]; } -const cache = new Map(); -const CACHE_TTL = 30 * 1000; // 30 seconds in milliseconds +// Current song state +let currentSong: LastSong | null = null; +let lastSongId: string | null = null; -function getCachedData(key: string): any | null { - const entry = cache.get(key); - if (!entry) return null; - - const now = Date.now(); - if (now - entry.timestamp > CACHE_TTL) { - cache.delete(key); +// WebSocket connections +const connections = new Set(); + +// Function to fetch song from Last.fm +async function fetchLastFmSong(): Promise { + const apiKey = process.env.LASTFM_API_KEY; + const username = process.env.LASTFM_USERNAME; + + if (!apiKey || !username) { + console.error('Last.fm API key or username not configured'); + return null; + } + + const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=1`; + + try { + const response = await fetch(url); + const data = await response.json(); + const track = data.recenttracks.track[0]; + + return { + name: track.name, + artist: track.artist['#text'], + album: track.album['#text'], + url: track.url, + nowPlaying: track['@attr']?.nowplaying === 'true', + image: track.image || [], + }; + } catch (error) { + console.error('Error fetching from Last.fm:', error); return null; } - - return entry.data; } -function setCachedData(key: string, data: any): void { - cache.set(key, { - data, - timestamp: Date.now() - }); +// Function to check for song changes and notify clients +async function checkAndUpdateSong(): Promise { + const newSong = await fetchLastFmSong(); + + if (!newSong) return; + + // Create a unique identifier for the song + const newSongId = `${newSong.artist}-${newSong.name}-${newSong.nowPlaying}`; + + // Check if song has changed + if (newSongId !== lastSongId) { + console.log('Song changed:', newSong.name, 'by', newSong.artist); + currentSong = newSong; + lastSongId = newSongId; + + // Notify all connected WebSocket clients + const message = JSON.stringify({ + type: 'song-update', + data: newSong + }); + + connections.forEach(ws => { + try { + ws.send(message); + } catch (error) { + console.error('Error sending to WebSocket client:', error); + connections.delete(ws); + } + }); + } } +// Start polling Last.fm every second +setInterval(checkAndUpdateSong, 1000); + +// Initial fetch +checkAndUpdateSong(); + const app = new Elysia() .use(staticPlugin({ assets: 'dist', prefix: '' })) - .get('/api/last-song', async () => { - const apiKey = Bun.env.LASTFM_API_KEY; - const username = Bun.env.LASTFM_USERNAME; - - if (!apiKey || !username) { - return new Response( - JSON.stringify({ error: 'Last.fm API key or username not configured' }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); - } - - // Check cache first - const cacheKey = `lastfm:${username}`; - const cachedData = getCachedData(cacheKey); - if (cachedData) { - console.log('Returning cached Last.fm data'); - return cachedData; - } - - const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=1`; - - try { - const response = await fetch(url); - const data = await response.json(); - const track = data.recenttracks.track[0]; + .ws('/music', { + message(ws, message) { + // Handle incoming messages if needed + console.log('Received message from client:', message); + }, + open(ws) { + console.log('WebSocket client connected'); + connections.add(ws); - const result = { - name: track.name, - artist: track.artist['#text'], - album: track.album['#text'], - url: track.url, - nowPlaying: track['@attr']?.nowplaying === 'true', - image: track.image || [], - }; - - // Cache the result - setCachedData(cacheKey, result); - console.log('Fetched and cached new Last.fm data'); - - return result; - } catch (error) { - console.error('Error fetching from Last.fm:', error); - return new Response(JSON.stringify({ error: 'Failed to fetch last song' }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); - } + // Send current song to new client immediately + if (currentSong) { + const message = JSON.stringify({ + type: 'song-update', + data: currentSong + }); + ws.send(message); + } + }, + close(ws) { + console.log('WebSocket client disconnected'); + connections.delete(ws); + }, }) .listen({ port: process.env.PORT || 3000,