jedmund-svelte/src/routes/api/lastfm/stream/+server.ts
Justin Edmund b84a2637c0 feat: implement smart polling intervals based on track duration
- Adjust update frequency based on remaining track time
- Poll every 5s when <20s remaining, 10s for 20-60s, 15s for >60s
- Add heartbeat timestamps to track update timing
- Implement time-based fallback for tracks without Apple Music data
- Assume tracks scrobbled within 5 minutes are still playing

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-10 21:32:29 -07:00

169 lines
No EOL
5.3 KiB
TypeScript

import { LastClient } from '@musicorum/lastfm'
import type { RequestHandler } from './$types'
import { SimpleLastfmStreamManager } from '$lib/utils/simpleLastfmStreamManager'
import { logger } from '$lib/server/logger'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund'
const UPDATE_INTERVAL = 30000 // 30 seconds default
const FAST_UPDATE_INTERVAL = 10000 // 10 seconds when music is playing
export const GET: RequestHandler = async ({ request }) => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const client = new LastClient(LASTFM_API_KEY || '')
const streamManager = new SimpleLastfmStreamManager(client, USERNAME)
let intervalId: NodeJS.Timeout | null = null
let isClosed = false
let currentInterval = UPDATE_INTERVAL
let isPlaying = false
// Send initial connection message
try {
controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n'))
} catch (e) {
logger.error('Failed to send initial message:', e as Error, undefined, 'music')
return
}
const checkForUpdates = async () => {
if (isClosed) {
if (intervalId) {
clearInterval(intervalId)
}
return
}
try {
const update = await streamManager.checkForUpdates()
// Send album updates if any
if (update.albums && !isClosed) {
try {
const data = JSON.stringify(update.albums)
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
// Check if music is playing and calculate smart interval
const nowPlayingAlbum = update.albums.find((a) => a.isNowPlaying)
const musicIsPlaying = !!nowPlayingAlbum
// Calculate remaining time if we have track duration
let remainingMs = 0
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks) {
const track = nowPlayingAlbum.appleMusicData.tracks.find(
t => t.name === nowPlayingAlbum.nowPlayingTrack
)
if (track?.durationMs && nowPlayingAlbum.lastScrobbleTime) {
const elapsed = Date.now() - new Date(nowPlayingAlbum.lastScrobbleTime).getTime()
remainingMs = Math.max(0, track.durationMs - elapsed)
}
}
logger.music('debug', '📤 SSE: Sent album update:', {
totalAlbums: update.albums.length,
nowPlaying: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none',
remainingMs: remainingMs,
albumsWithStatus: update.albums.map(a => ({
name: a.name,
artist: a.artist.name,
isNowPlaying: a.isNowPlaying,
track: a.nowPlayingTrack
}))
})
// Smart interval adjustment based on remaining track time
let targetInterval = UPDATE_INTERVAL // Default 30s
if (musicIsPlaying && remainingMs > 0) {
// If track is ending soon (within 20 seconds), check more frequently
if (remainingMs < 20000) {
targetInterval = 5000 // 5 seconds
}
// If track has 20-60 seconds left, moderate frequency
else if (remainingMs < 60000) {
targetInterval = 10000 // 10 seconds
}
// If track has more than 60 seconds, check every 15 seconds
else {
targetInterval = 15000 // 15 seconds
}
} else if (musicIsPlaying) {
// If playing but no duration info, use fast interval
targetInterval = FAST_UPDATE_INTERVAL
}
// Apply new interval if it changed significantly (more than 1 second difference)
if (Math.abs(targetInterval - currentInterval) > 1000) {
currentInterval = targetInterval
logger.music('debug', `Adjusting interval to ${currentInterval}ms (playing: ${isPlaying}, remaining: ${Math.round(remainingMs/1000)}s)`)
// Reset interval with new timing
if (intervalId) {
clearInterval(intervalId)
intervalId = setInterval(checkForUpdates, currentInterval)
}
}
} catch (e) {
isClosed = true
}
}
// Always send heartbeat with timestamp to keep client synced
if (!isClosed) {
try {
const heartbeatData = JSON.stringify({
timestamp: new Date().toISOString(),
interval: currentInterval,
hasUpdates: !!update.albums
})
controller.enqueue(encoder.encode(`event: heartbeat\ndata: ${heartbeatData}\n\n`))
} catch (e) {
// Expected when client disconnects
isClosed = true
}
}
} catch (error) {
logger.error('Error checking for updates:', error as Error, undefined, 'music')
}
}
// Initial check
await checkForUpdates()
// Set up interval
intervalId = setInterval(checkForUpdates, currentInterval)
// Handle client disconnect
request.signal.addEventListener('abort', () => {
isClosed = true
if (intervalId) {
clearInterval(intervalId)
}
try {
controller.close()
} catch (e) {
// Already closed
}
})
},
cancel() {
// Cleanup when stream is cancelled
logger.music('debug', 'SSE stream cancelled')
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no' // Disable Nginx buffering
}
})
}