Implement WebSocket connection for real-time song updates and refactor Last.fm song fetching logic
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s

This commit is contained in:
Cameron Redmore 2025-08-08 12:19:45 +01:00
parent db371a6d21
commit a6dfc77244
No known key found for this signature in database
2 changed files with 225 additions and 112 deletions

View file

@ -18,22 +18,56 @@ interface LastSong {
image: LastSongImage[]; image: LastSongImage[];
} }
// Function to fetch and display last song // WebSocket connection
async function fetchLastSong(): Promise<void> { let musicWebSocket: any = null;
let connected = false;
// Function to connect to WebSocket for real-time updates
function connectMusicWebSocket(): void {
try { try {
const { data, error } = await client.api['last-song'].get(); connected = false;
if (error) { // Subscribe to the music WebSocket endpoint
console.error('Error fetching last song:', error); musicWebSocket = client.music.subscribe();
updateLastSongDisplay(null, 'Failed to load song data');
return; 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) { } catch (error) {
console.error('Network error:', error); console.error('Failed to connect to music WebSocket:', error);
updateLastSongDisplay(null, 'Network error occurred'); 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 ''; return '';
} }
// Function to preload an image and return a promise
function preloadImage(url: string): Promise<HTMLImageElement> {
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 to update the UI with song information
function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): void { async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): Promise<void> {
console.log('Updating last song display:', song, errorMessage);
const containers = [ const containers = [
document.getElementById('last-song-container'), document.getElementById('last-song-container'),
document.getElementById('last-song-container-mobile') document.getElementById('last-song-container-mobile')
]; ];
containers.forEach(container => { for (const container of containers) {
if (!container) return; if (!container) continue;
if (errorMessage || !song) { if (errorMessage || !song) {
container.innerHTML = ` container.innerHTML = `
<div class="text-red-400 text-sm"> <div class="text-cyan-900 text-sm">
${errorMessage || 'No song data available'} ${errorMessage || 'No song data available'}
</div> </div>
`; `;
return; continue;
} }
const statusText = song.nowPlaying ? 'Currently Listening To' : 'Last Played'; const statusText = song.nowPlaying ? 'Currently Listening To' : 'Last Played';
@ -102,14 +147,64 @@ function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): vo
${song.album ? `<div class="album-name">from "${song.album}"</div>` : ''} ${song.album ? `<div class="album-name">from "${song.album}"</div>` : ''}
`; `;
const imageElement = imageUrl let imageElement: string;
? `<img src="${imageUrl}" alt="${song.name}" class="w-24 h-24 rounded-lg object-cover mx-auto border border-white/20" loading="lazy">`
: `<div class="w-16 h-16 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
<svg class="w-8 h-8 text-cyan-300" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 3a1 1 0 00-1.447-.894L8.763 6H5a3 3 0 000 6h.28l1.771 5.316A1 1 0 008 18a1 1 0 001-1v-4.382l6.553 3.276A1 1 0 0017 15V3z" clip-rule="evenodd" />
</svg>
</div>`;
// If there's an image URL, preload it before updating the display
if (imageUrl) {
try {
// Show loading state while preloading
const loadingElement = `
<div class="w-24 h-24 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-cyan-300"></div>
</div>
`;
// Temporarily show loading state
container.innerHTML = `
<div class="flex flex-col items-center space-y-3">
<div class="tooltip music-tooltip">
<a href="${song.url}" target="_blank" class="block hover:text-cyan-200 transition-colors duration-200 w-36">
${loadingElement}
<div class="text-center w-full">
<p class="text-sm text-cyan-300 font-medium w-36">${statusText}</p>
<div class="font-semibold text-white-900 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.name}</div>
<div class="text-sm text-white-200 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.artist}</div>
${song.album ? `<div class="text-xs text-white-600 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.album}</div>` : ''}
</div>
</a>
<span class="tooltip-text">${tooltipContent}</span>
</div>
</div>
`;
// Preload the image
await preloadImage(imageUrl);
// Now set the actual image
imageElement = `<img src="${imageUrl}" alt="${song.name}" class="w-24 h-24 rounded-lg object-cover mx-auto border border-white/20" loading="lazy">`;
} catch (error) {
console.warn('Failed to preload image:', imageUrl, error);
// Fallback to default icon if image fails to load
imageElement = `
<div class="w-24 h-24 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
<svg class="w-8 h-8 text-cyan-300" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 3a1 1 0 00-1.447-.894L8.763 6H5a3 3 0 000 6h.28l1.771 5.316A1 1 0 008 18a1 1 0 001-1v-4.382l6.553 3.276A1 1 0 0017 15V3z" clip-rule="evenodd" />
</svg>
</div>
`;
}
} else {
// No image URL, use default icon
imageElement = `
<div class="w-24 h-24 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
<svg class="w-8 h-8 text-cyan-300" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 3a1 1 0 00-1.447-.894L8.763 6H5a3 3 0 000 6h.28l1.771 5.316A1 1 0 008 18a1 1 0 001-1v-4.382l6.553 3.276A1 1 0 0017 15V3z" clip-rule="evenodd" />
</svg>
</div>
`;
}
// Final update with the actual image
container.innerHTML = ` container.innerHTML = `
<div class="flex flex-col items-center space-y-3"> <div class="flex flex-col items-center space-y-3">
<div class="tooltip music-tooltip"> <div class="tooltip music-tooltip">
@ -126,35 +221,21 @@ function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): vo
</div> </div>
</div> </div>
`; `;
});
}
// 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 // Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
startAutoRefresh(); // Connect to WebSocket for real-time updates
connectMusicWebSocket();
}); });
// Clean up on page unload // Clean up on page unload
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
stopAutoRefresh(); if (musicWebSocket) {
musicWebSocket.close();
}
}); });
// Export functions for manual control if needed // Export functions for manual control if needed
export { fetchLastSong, startAutoRefresh, stopAutoRefresh }; export { connectMusicWebSocket };

View file

@ -1,87 +1,119 @@
import { Elysia, t } from 'elysia'; import { Elysia, t } from 'elysia';
import { staticPlugin } from '@elysiajs/static'; import { staticPlugin } from '@elysiajs/static';
// Simple cache implementation // Song data interface
interface CacheEntry { interface LastSong {
data: any; name: string;
timestamp: number; artist: string;
album: string;
url: string;
nowPlaying: boolean;
image: any[];
} }
const cache = new Map<string, CacheEntry>(); // Current song state
const CACHE_TTL = 30 * 1000; // 30 seconds in milliseconds let currentSong: LastSong | null = null;
let lastSongId: string | null = null;
function getCachedData(key: string): any | null { // WebSocket connections
const entry = cache.get(key); const connections = new Set<any>();
if (!entry) return null;
const now = Date.now(); // Function to fetch song from Last.fm
if (now - entry.timestamp > CACHE_TTL) { async function fetchLastFmSong(): Promise<LastSong | null> {
cache.delete(key); 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; return null;
} }
return entry.data; 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;
}
} }
function setCachedData(key: string, data: any): void { // Function to check for song changes and notify clients
cache.set(key, { async function checkAndUpdateSong(): Promise<void> {
data, const newSong = await fetchLastFmSong();
timestamp: Date.now()
}); 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() const app = new Elysia()
.use(staticPlugin({ .use(staticPlugin({
assets: 'dist', assets: 'dist',
prefix: '' prefix: ''
})) }))
.get('/api/last-song', async () => { .ws('/music', {
const apiKey = Bun.env.LASTFM_API_KEY; message(ws, message) {
const username = Bun.env.LASTFM_USERNAME; // Handle incoming messages if needed
console.log('Received message from client:', message);
},
open(ws) {
console.log('WebSocket client connected');
connections.add(ws);
if (!apiKey || !username) { // Send current song to new client immediately
return new Response( if (currentSong) {
JSON.stringify({ error: 'Last.fm API key or username not configured' }), const message = JSON.stringify({
{ status: 500, headers: { 'Content-Type': 'application/json' } } type: 'song-update',
); data: currentSong
} });
ws.send(message);
// Check cache first }
const cacheKey = `lastfm:${username}`; },
const cachedData = getCachedData(cacheKey); close(ws) {
if (cachedData) { console.log('WebSocket client disconnected');
console.log('Returning cached Last.fm data'); connections.delete(ws);
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];
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' },
});
}
}) })
.listen({ .listen({
port: process.env.PORT || 3000, port: process.env.PORT || 3000,