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
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
This commit is contained in:
parent
db371a6d21
commit
a6dfc77244
2 changed files with 225 additions and 112 deletions
169
src/client.ts
169
src/client.ts
|
@ -18,22 +18,56 @@ interface LastSong {
|
|||
image: LastSongImage[];
|
||||
}
|
||||
|
||||
// Function to fetch and display last song
|
||||
async function fetchLastSong(): Promise<void> {
|
||||
// WebSocket connection
|
||||
let musicWebSocket: any = null;
|
||||
let connected = false;
|
||||
|
||||
// Function to connect to WebSocket for real-time updates
|
||||
function connectMusicWebSocket(): void {
|
||||
try {
|
||||
const { data, error } = await client.api['last-song'].get();
|
||||
connected = false;
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching last song:', error);
|
||||
updateLastSongDisplay(null, 'Failed to load song data');
|
||||
return;
|
||||
}
|
||||
// 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<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 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 = [
|
||||
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 = `
|
||||
<div class="text-red-400 text-sm">
|
||||
<div class="text-cyan-900 text-sm">
|
||||
${errorMessage || 'No song data available'}
|
||||
</div>
|
||||
`;
|
||||
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 ? `<div class="album-name">from "${song.album}"</div>` : ''}
|
||||
`;
|
||||
|
||||
const imageElement = imageUrl
|
||||
? `<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>`;
|
||||
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 = `
|
||||
<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 = `
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<div class="tooltip music-tooltip">
|
||||
|
@ -126,35 +221,21 @@ function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): vo
|
|||
</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
|
||||
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 };
|
||||
|
|
160
src/server.ts
160
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<string, CacheEntry>();
|
||||
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;
|
||||
// WebSocket connections
|
||||
const connections = new Set<any>();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - entry.timestamp > CACHE_TTL) {
|
||||
cache.delete(key);
|
||||
// Function to fetch song from Last.fm
|
||||
async function fetchLastFmSong(): Promise<LastSong | null> {
|
||||
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 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 {
|
||||
cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
// Function to check for song changes and notify clients
|
||||
async function checkAndUpdateSong(): Promise<void> {
|
||||
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;
|
||||
.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);
|
||||
|
||||
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];
|
||||
|
||||
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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue