From 32fd4b5179bf5698f64b364b83059da1b02a6b36 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 26 Jun 2025 13:47:45 -0400 Subject: [PATCH] refactor: simplify LastFM stream server using extracted utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce from 625 lines to 115 lines (82% reduction) - Use LastfmStreamManager to handle complex logic - Cleaner separation of SSE streaming from business logic - Improved error handling and state management This completes the LastFM stream server simplification task. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/api/lastfm/stream/+server.ts | 562 ++---------------------- 1 file changed, 26 insertions(+), 536 deletions(-) diff --git a/src/routes/api/lastfm/stream/+server.ts b/src/routes/api/lastfm/stream/+server.ts index bf250e0..03cab37 100644 --- a/src/routes/api/lastfm/stream/+server.ts +++ b/src/routes/api/lastfm/stream/+server.ts @@ -1,40 +1,19 @@ 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 { LastfmStreamManager } from '$lib/utils/lastfmStreamManager' 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 + const streamManager = new LastfmStreamManager(client, USERNAME) let intervalId: NodeJS.Timeout | null = null let isClosed = false @@ -55,174 +34,38 @@ export const GET: RequestHandler = async ({ request }) => { } try { - // Fetch full album data - const albums = await getRecentAlbums(client) + const update = await streamManager.checkForUpdates() - // 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 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`)) + + const nowPlayingAlbum = update.albums.find(a => a.isNowPlaying) + logger.music('debug', 'Sent album update with now playing status:', { + totalAlbums: update.albums.length, + nowPlayingAlbum: nowPlayingAlbum + ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` + : 'none' }) - } - - // 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 - } + } 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 - }) + // Send now playing updates if any + if (update.nowPlayingUpdates && update.nowPlayingUpdates.length > 0 && !isClosed) { + try { + const data = JSON.stringify(update.nowPlayingUpdates) + controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`)) + } catch (e) { + isClosed = true } } - // Check if controller is still open before sending + // Send heartbeat to keep connection alive 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) { @@ -269,357 +112,4 @@ export const GET: RequestHandler = async ({ request }) => { '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()) -} +} \ No newline at end of file