import { LastClient } from '@musicorum/lastfm' import type { RequestHandler } from './$types' import type { Album, AlbumImages } from '$lib/types/lastfm' import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common' import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client' import redis from '../../redis-client' import { logger } from '$lib/server/logger' const LASTFM_API_KEY = process.env.LASTFM_API_KEY const USERNAME = 'jedmund' const UPDATE_INTERVAL = 30000 // 30 seconds to reduce API load interface NowPlayingUpdate { albumName: string artistName: string isNowPlaying: boolean nowPlayingTrack?: string } // Store recent tracks for duration-based detection interface TrackPlayInfo { albumName: string trackName: string scrobbleTime: Date durationMs?: number } let recentTracks: TrackPlayInfo[] = [] export const GET: RequestHandler = async ({ request }) => { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { const client = new LastClient(LASTFM_API_KEY || '') let lastNowPlayingState: Map = new Map() let lastAlbumOrder: string[] = [] // Track album order changes let intervalId: NodeJS.Timeout | null = null let isClosed = 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 { // Fetch full album data const albums = await getRecentAlbums(client) // Update recentTracks for duration-based now playing detection await getNowPlayingAlbums(client) // This populates recentTracks // Enrich albums with additional info and check now playing status const enrichedAlbums = await Promise.all( albums.map(async (album) => { try { const enriched = await enrichAlbumWithInfo(client, album) const withAppleMusic = await searchAppleMusicForAlbum(enriched) // Check if this album is currently playing using duration-based detection if (withAppleMusic.appleMusicData?.tracks && !withAppleMusic.isNowPlaying) { const nowPlayingCheck = checkWithTracks( withAppleMusic.name, withAppleMusic.appleMusicData.tracks ) if (nowPlayingCheck?.isNowPlaying) { withAppleMusic.isNowPlaying = true withAppleMusic.nowPlayingTrack = nowPlayingCheck.nowPlayingTrack } } return withAppleMusic } catch (error) { logger.error( `Error enriching album ${album.name}:`, error as Error, undefined, 'music' ) return album } }) ) // Ensure only one album is marked as now playing in the enriched albums const nowPlayingCount = enrichedAlbums.filter((a) => a.isNowPlaying).length if (nowPlayingCount > 1) { logger.music( 'debug', `Multiple enriched albums marked as now playing (${nowPlayingCount}), keeping only the most recent one` ) // The albums are already in order from most recent to oldest // So we keep the first now playing album and mark others as not playing let foundFirst = false enrichedAlbums.forEach((album, index) => { if (album.isNowPlaying) { if (foundFirst) { logger.music( 'debug', `Marking album "${album.name}" at position ${index} as not playing` ) album.isNowPlaying = false album.nowPlayingTrack = undefined } else { logger.music( 'debug', `Keeping album "${album.name}" at position ${index} as now playing` ) foundFirst = true } } }) } // Check if album order has changed or now playing status changed const currentAlbumOrder = enrichedAlbums.map((a) => `${a.artist.name}:${a.name}`) const albumOrderChanged = JSON.stringify(currentAlbumOrder) !== JSON.stringify(lastAlbumOrder) // Also check if any now playing status changed let nowPlayingChanged = false for (const album of enrichedAlbums) { const key = `${album.artist.name}:${album.name}` const lastState = lastNowPlayingState.get(key) if ( album.isNowPlaying !== (lastState?.isPlaying || false) || (album.isNowPlaying && album.nowPlayingTrack !== lastState?.track) ) { nowPlayingChanged = true break } } if (albumOrderChanged || nowPlayingChanged) { lastAlbumOrder = currentAlbumOrder // Update now playing state for (const album of enrichedAlbums) { const key = `${album.artist.name}:${album.name}` lastNowPlayingState.set(key, { isPlaying: album.isNowPlaying || false, track: album.nowPlayingTrack }) } // Send full album update if (!isClosed) { try { const data = JSON.stringify(enrichedAlbums) controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`)) const nowPlayingAlbum = enrichedAlbums.find((a) => a.isNowPlaying) logger.music('debug', 'Sent album update with now playing status:', { totalAlbums: enrichedAlbums.length, nowPlayingAlbum: nowPlayingAlbum ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` : 'none' }) } catch (e) { isClosed = true } } } // Send now playing updates for albums not in the recent list const nowPlayingAlbums = await getNowPlayingAlbums(client) const updates: NowPlayingUpdate[] = [] // Only send now playing updates for albums that aren't in the recent albums list // (Recent albums already have their now playing status included) for (const album of nowPlayingAlbums) { const isInRecentAlbums = enrichedAlbums.some( (a) => a.artist.name === album.artistName && a.name === album.albumName ) if (!isInRecentAlbums) { const key = `${album.artistName}:${album.albumName}` const lastState = lastNowPlayingState.get(key) const wasPlaying = lastState?.isPlaying || false const lastTrack = lastState?.track // Update if playing status changed OR if the track changed if ( album.isNowPlaying !== wasPlaying || (album.isNowPlaying && album.nowPlayingTrack !== lastTrack) ) { updates.push(album) logger.music( 'debug', `Now playing update for non-recent album ${album.albumName}: playing=${album.isNowPlaying}, track=${album.nowPlayingTrack}` ) } lastNowPlayingState.set(key, { isPlaying: album.isNowPlaying, track: album.nowPlayingTrack }) } } // Check if controller is still open before sending if (!isClosed) { // Send updates if any if (updates.length > 0) { const data = JSON.stringify(updates) try { controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`)) } catch (e) { // This is expected when client disconnects isClosed = true } } // Send heartbeat to keep connection alive try { controller.enqueue(encoder.encode('event: heartbeat\ndata: {}\n\n')) } catch (e) { // This is 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, UPDATE_INTERVAL) // 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 } }) } async function getNowPlayingAlbums(client: LastClient): Promise { // Check cache for recent tracks const cacheKey = `lastfm:recent:${USERNAME}` const cached = await redis.get(cacheKey) let recentTracksResponse if (cached) { logger.music('debug', 'Using cached Last.fm recent tracks for streaming') recentTracksResponse = JSON.parse(cached) // Convert date strings back to Date objects if (recentTracksResponse.tracks) { recentTracksResponse.tracks = recentTracksResponse.tracks.map((track: any) => ({ ...track, date: track.date ? new Date(track.date) : undefined })) } } else { logger.music('debug', 'Fetching fresh Last.fm recent tracks for streaming') recentTracksResponse = await client.user.getRecentTracks(USERNAME, { limit: 50, extended: true }) // Cache for 30 seconds - reasonable for "recent" data await redis.set(cacheKey, JSON.stringify(recentTracksResponse), 'EX', 30) } const albums: Map = new Map() let hasOfficialNowPlaying = false // Clear old tracks and collect new track play information recentTracks = [] // First pass: check if Last.fm reports any track as now playing for (const track of recentTracksResponse.tracks) { if (track.nowPlaying) { hasOfficialNowPlaying = true break } } for (const track of recentTracksResponse.tracks) { // Store track play information if (track.date) { recentTracks.push({ albumName: track.album.name, trackName: track.name, scrobbleTime: track.date }) } const albumKey = `${track.artist.name}:${track.album.name}` if (!albums.has(albumKey)) { const album: NowPlayingUpdate = { albumName: track.album.name, artistName: track.artist.name, isNowPlaying: track.nowPlaying || false, nowPlayingTrack: track.nowPlaying ? track.name : undefined } // Only use duration-based detection if Last.fm doesn't report any now playing if (!album.isNowPlaying && !hasOfficialNowPlaying) { const updatedStatus = await checkNowPlayingWithDuration(album.albumName, album.artistName) if (updatedStatus) { album.isNowPlaying = updatedStatus.isNowPlaying album.nowPlayingTrack = updatedStatus.nowPlayingTrack } } albums.set(albumKey, album) } } // Ensure only one album is marked as now playing - keep the most recent one const nowPlayingAlbums = Array.from(albums.values()).filter((a) => a.isNowPlaying) if (nowPlayingAlbums.length > 1) { logger.music( 'debug', `Multiple albums marked as now playing (${nowPlayingAlbums.length}), keeping only the most recent one` ) // Find the most recent track let mostRecentTime = new Date(0) let mostRecentAlbum = nowPlayingAlbums[0] for (const album of nowPlayingAlbums) { // Find the most recent track for this album const albumTracks = recentTracks.filter((t) => t.albumName === album.albumName) if (albumTracks.length > 0) { const latestTrack = albumTracks.reduce((latest, track) => track.scrobbleTime > latest.scrobbleTime ? track : latest ) if (latestTrack.scrobbleTime > mostRecentTime) { mostRecentTime = latestTrack.scrobbleTime mostRecentAlbum = album } } } // Mark all others as not playing nowPlayingAlbums.forEach((album) => { if (album !== mostRecentAlbum) { const key = `${album.artistName}:${album.albumName}` albums.set(key, { ...album, isNowPlaying: false, nowPlayingTrack: undefined }) } }) } return Array.from(albums.values()) } async function checkNowPlayingWithDuration( albumName: string, artistName: string ): Promise<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null> { try { // Check cache for Apple Music data const cacheKey = `apple:album:${artistName}:${albumName}` const cached = await redis.get(cacheKey) if (!cached) { // Try to fetch from Apple Music if not cached const appleMusicAlbum = await findAlbum(artistName, albumName) if (!appleMusicAlbum) return null const transformedData = await transformAlbumData(appleMusicAlbum) await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400) return checkWithTracks(albumName, transformedData.tracks) } const appleMusicData = JSON.parse(cached) return checkWithTracks(albumName, appleMusicData.tracks) } catch (error) { logger.error(`Error checking duration for ${albumName}:`, error as Error, undefined, 'music') return null } } function checkWithTracks( albumName: string, tracks?: Array<{ name: string; durationMs?: number }> ): { isNowPlaying: boolean; nowPlayingTrack?: string } | null { if (!tracks) return null const now = new Date() const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes // Clean up old tracks first recentTracks = recentTracks.filter((track) => { // Keep tracks from last hour only const hourAgo = new Date(now.getTime() - 60 * 60 * 1000) return track.scrobbleTime > hourAgo }) // Find the most recent track from this album let mostRecentTrack: TrackPlayInfo | null = null for (const trackInfo of recentTracks) { if (trackInfo.albumName === albumName) { if (!mostRecentTrack || trackInfo.scrobbleTime > mostRecentTrack.scrobbleTime) { mostRecentTrack = trackInfo } } } if (mostRecentTrack) { const trackData = tracks.find( (t) => t.name.toLowerCase() === mostRecentTrack.trackName.toLowerCase() ) if (trackData?.durationMs) { const trackEndTime = new Date( mostRecentTrack.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG ) if (now < trackEndTime) { logger.music( 'debug', `Track "${mostRecentTrack.trackName}" is still playing (ends at ${trackEndTime.toLocaleTimeString()})` ) return { isNowPlaying: true, nowPlayingTrack: mostRecentTrack.trackName } } } } return { isNowPlaying: false } } // Helper functions for album data function transformImages(images: LastfmImage[]): AlbumImages { const imageMap: AlbumImages = { small: '', medium: '', large: '', extralarge: '', mega: '', default: '' } for (const image of images) { const size = image.size as keyof AlbumImages if (size in imageMap) { imageMap[size] = image.url } } return imageMap } async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise { // Check cache for album info const cacheKey = `lastfm:albuminfo:${album.artist.name}:${album.name}` const cached = await redis.get(cacheKey) if (cached) { logger.music('debug', `Using cached album info for "${album.name}"`) const albumInfo = JSON.parse(cached) return { ...album, url: albumInfo?.url || '', images: transformImages(albumInfo?.images || []) } } logger.music('debug', `Fetching fresh album info for "${album.name}"`) const albumInfo = await client.album.getInfo(album.name, album.artist.name) // Cache for 1 hour - album info rarely changes await redis.set(cacheKey, JSON.stringify(albumInfo), 'EX', 3600) return { ...album, url: albumInfo?.url || '', images: transformImages(albumInfo?.images || []) } } async function searchAppleMusicForAlbum(album: Album): Promise { try { // Check cache first const cacheKey = `apple:album:${album.artist.name}:${album.name}` const cached = await redis.get(cacheKey) if (cached) { const cachedData = JSON.parse(cached) return { ...album, images: { ...album.images, itunes: cachedData.highResArtwork || album.images.itunes }, appleMusicData: cachedData } } // Search Apple Music const appleMusicAlbum = await findAlbum(album.artist.name, album.name) if (appleMusicAlbum) { const transformedData = await transformAlbumData(appleMusicAlbum) // Cache the result for 24 hours await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400) return { ...album, images: { ...album.images, itunes: transformedData.highResArtwork || album.images.itunes }, appleMusicData: transformedData } } } catch (error) { logger.error( `Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`, error as Error, undefined, 'music' ) } // Return album unchanged if Apple Music search fails return album } async function getRecentAlbums(client: LastClient, limit: number = 4): Promise { // Check cache for recent tracks const cacheKey = `lastfm:recent:${USERNAME}` const cached = await redis.get(cacheKey) let recentTracksResponse if (cached) { logger.music('debug', 'Using cached Last.fm recent tracks for album stream') recentTracksResponse = JSON.parse(cached) // Convert date strings back to Date objects if (recentTracksResponse.tracks) { recentTracksResponse.tracks = recentTracksResponse.tracks.map((track: any) => ({ ...track, date: track.date ? new Date(track.date) : undefined })) } } else { logger.music('debug', 'Fetching fresh Last.fm recent tracks for album stream') recentTracksResponse = await client.user.getRecentTracks(USERNAME, { limit: 50, extended: true }) // Cache for 30 seconds - reasonable for "recent" data await redis.set(cacheKey, JSON.stringify(recentTracksResponse), 'EX', 30) } const uniqueAlbums = new Map() for (const track of recentTracksResponse.tracks) { if (uniqueAlbums.size >= limit) break const albumKey = `${track.album.mbid || track.album.name}` if (!uniqueAlbums.has(albumKey)) { uniqueAlbums.set(albumKey, { name: track.album.name, artist: { name: track.artist.name, mbid: track.artist.mbid || '' }, playCount: 1, images: transformImages(track.images), mbid: track.album.mbid || '', url: track.url, rank: uniqueAlbums.size + 1, isNowPlaying: track.nowPlaying || false, nowPlayingTrack: track.nowPlaying ? track.name : undefined }) } else if (track.nowPlaying) { // If album already exists but this track is now playing, update it const existingAlbum = uniqueAlbums.get(albumKey)! uniqueAlbums.set(albumKey, { ...existingAlbum, isNowPlaying: true, nowPlayingTrack: track.name }) } } return Array.from(uniqueAlbums.values()) }