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[];
|
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 };
|
||||||
|
|
160
src/server.ts
160
src/server.ts
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue